Repository: aeon0/d4lf Branch: main Commit: ae38f65d37ec Files: 153 Total size: 948.8 KB Directory structure: gitextract_9j8i29wl/ ├── .clang-format ├── .gitattributes ├── .github/ │ ├── actions/ │ │ └── setup_env/ │ │ └── action.yml │ └── workflows/ │ ├── ci.yml │ ├── notify.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── README.md ├── assets/ │ └── lang/ │ └── enUS/ │ ├── How to add to these files.md │ ├── affixes.json │ ├── aspects.json │ ├── corrections.json │ ├── item_types.json │ ├── paragon_maxroll_ids.json │ ├── sigils.json │ ├── tooltips.json │ ├── tributes.json │ └── uniques.json ├── build.py ├── pyproject.toml ├── pytest.ini ├── src/ │ ├── __init__.py │ ├── autoupdater.py │ ├── cam.py │ ├── config/ │ │ ├── __init__.py │ │ ├── data.py │ │ ├── helper.py │ │ ├── loader.py │ │ ├── profile_models.py │ │ ├── settings_models.py │ │ └── ui.py │ ├── dataloader.py │ ├── gui/ │ │ ├── __init__.py │ │ ├── activity_log_widget.py │ │ ├── collapsible_widget.py │ │ ├── config_tab.py │ │ ├── config_window.py │ │ ├── d4lfitem.py │ │ ├── dialog.py │ │ ├── importer/ │ │ │ ├── __init__.py │ │ │ ├── d4builds.py │ │ │ ├── diablo_trade.py │ │ │ ├── gui_common.py │ │ │ ├── importer_config.py │ │ │ ├── maxroll.py │ │ │ ├── mobalytics.py │ │ │ └── paragon_export.py │ │ ├── importer_window.py │ │ ├── open_user_config_button.py │ │ ├── profile_editor/ │ │ │ ├── __init__.py │ │ │ ├── affixes_tab.py │ │ │ ├── aspect_upgrades_tab.py │ │ │ ├── global_uniques_tab.py │ │ │ ├── profile_editor.py │ │ │ ├── sigils_tab.py │ │ │ └── tributes_tab.py │ │ ├── profile_editor_window.py │ │ ├── profile_tab.py │ │ ├── themes.py │ │ └── unified_window.py │ ├── item/ │ │ ├── __init__.py │ │ ├── data/ │ │ │ ├── __init__.py │ │ │ ├── affix.py │ │ │ ├── aspect.py │ │ │ ├── item_type.py │ │ │ ├── rarity.py │ │ │ └── seasonal_attribute.py │ │ ├── descr/ │ │ │ ├── __init__.py │ │ │ ├── read_descr_tts.py │ │ │ ├── text.py │ │ │ └── texture.py │ │ ├── filter.py │ │ ├── find_descr.py │ │ └── models.py │ ├── logger.py │ ├── loot_mover.py │ ├── main.py │ ├── overlay.py │ ├── paragon_overlay.py │ ├── scripts/ │ │ ├── __init__.py │ │ ├── common.py │ │ ├── handler.py │ │ ├── loot_filter_tts.py │ │ ├── vision_mode_fast.py │ │ └── vision_mode_with_highlighting.py │ ├── startup_messages.py │ ├── template_finder.py │ ├── tools/ │ │ ├── __init__.py │ │ ├── data/ │ │ │ ├── custom_affixes_enUS.json │ │ │ └── custom_sigils_enUS.json │ │ └── gen_data.py │ ├── tts.py │ ├── ui/ │ │ ├── __init__.py │ │ ├── char_inventory.py │ │ ├── inventory_base.py │ │ ├── menu.py │ │ ├── stash.py │ │ └── vendor.py │ └── utils/ │ ├── __init__.py │ ├── custom_mouse.py │ ├── image_operations.py │ ├── misc.py │ ├── process_handler.py │ ├── roi_operations.py │ └── window.py ├── tests/ │ ├── __init__.py │ ├── config/ │ │ ├── __init__.py │ │ ├── data/ │ │ │ ├── __init__.py │ │ │ ├── sigils.py │ │ │ └── uniques.py │ │ ├── helper_test.py │ │ ├── loader_test.py │ │ ├── models_test.py │ │ └── ui_test.py │ ├── conftest.py │ ├── gui/ │ │ ├── __init__.py │ │ └── importer/ │ │ ├── __init__.py │ │ ├── test_d4builds.py │ │ ├── test_diablo_trade.py │ │ ├── test_gui_common.py │ │ ├── test_maxroll.py │ │ └── test_mobalytics.py │ ├── item/ │ │ ├── __init__.py │ │ ├── descr/ │ │ │ └── __init__.py │ │ ├── filter/ │ │ │ ├── __init__.py │ │ │ ├── data/ │ │ │ │ ├── __init__.py │ │ │ │ ├── affixes.py │ │ │ │ ├── aspects.py │ │ │ │ ├── filters.py │ │ │ │ ├── items.py │ │ │ │ ├── sigils.py │ │ │ │ ├── tributes.py │ │ │ │ └── uniques.py │ │ │ └── filter_test.py │ │ ├── read_descr_season6_tts_test.py │ │ ├── read_descr_season8_tts_test.py │ │ ├── read_descr_season_11_tts_test.py │ │ ├── read_descr_season_12_tts_test.py │ │ ├── read_descr_season_13_tts_test.py │ │ └── read_descr_tts_test.py │ ├── template_finder_test.py │ ├── ui/ │ │ ├── __init__.py │ │ ├── char_inventory_test.py │ │ └── chest_test.py │ └── utils/ │ ├── __init__.py │ ├── image_operations_test.py │ └── roi_operations_test.py └── tts/ ├── install_dll.cmd ├── saapi.cpp ├── saapi.h └── saapi.vcxproj ================================================ FILE CONTENTS ================================================ ================================================ FILE: .clang-format ================================================ Language: Cpp BasedOnStyle: Google ColumnLimit: 140 IndentWidth: 4 TabWidth: 4 UseTab: Never ================================================ FILE: .gitattributes ================================================ # Set the default behavior, in case people don't have core.autocrlf set. * text=auto # Denote all files that are truly binary and should not be modified. *.jpg binary *.png binary # Explicitly declare text files you want to always be normalized and converted to native line endings on checkout. *.md text eol=lf *.sh text eol=lf *.yaml text eol=lf *.yml text eol=lf ================================================ FILE: .github/actions/setup_env/action.yml ================================================ name: Setup env runs: using: "composite" steps: - name: Setup uv uses: astral-sh/setup-uv@v8.1.0 with: activate-environment: true cache-dependency-glob: | **/uv.lock enable-cache: true ignore-nothing-to-cache: true python-version: 3.14 - name: Install dependencies shell: powershell run: uv sync --frozen --all-groups ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: push: branches: [main] concurrency: group: "${{github.workflow}}-${{github.ref}}" cancel-in-progress: true jobs: tests: runs-on: windows-latest steps: - uses: actions/checkout@v6 - name: Setup env uses: ./.github/actions/setup_env - name: Run prek uses: j178/prek-action@v2 - name: Pytest shell: powershell run: pytest . -m "not selenium" -v -n logical # - name: Pytest selenium # shell: powershell # run: pytest . -m "selenium" -v ================================================ FILE: .github/workflows/notify.yml ================================================ name: Notify on: release: types: [published] jobs: github-releases-to-discord: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: SethCohen/github-releases-to-discord@v1.20 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} color: "2105893" username: "D4LF Release" ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: pull_request: types: [closed] workflow_dispatch: concurrency: group: release jobs: release: if: | github.event_name != 'pull_request' || ( github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') ) runs-on: windows-latest steps: - uses: actions/checkout@v6 - name: Setup env uses: ./.github/actions/setup_env - name: Build & Zip exe id: build_zip shell: powershell run: | python build.py $version = python -c "from src import __version__; print(__version__)" echo "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append $folderName = "d4lf" $zipName = "d4lf_v" + $version Compress-Archive -Path $folderName -DestinationPath "$zipName.zip" - name: Create Tag shell: powershell run: | git tag "v${{ env.VERSION }}" git push origin "v${{ env.VERSION }}" - name: Check if beta id: check_beta shell: powershell run: | if ($env:VERSION -like "*beta*" -or $env:VERSION -like "*alpha*") { echo "IS_BETA=true" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 } else { echo "IS_BETA=false" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 } - uses: softprops/action-gh-release@v3 with: files: d4lf_v*.zip generate_release_notes: true name: "v${{ env.VERSION }}" prerelease: ${{ env.IS_BETA == 'true' }} tag_name: "v${{ env.VERSION }}" token: "${{ secrets.RELEASE_TOKEN }}" ================================================ FILE: .gitignore ================================================ !config/bnip/.gitkeep *.bak *.log *.pyc *.pyo *.spec *_generated.py *info_*.png *info_log_parsed.txt .coverage .idea/ .pytest_cache/ .venv .vs/ *.egg-info/ /tts/saapi /tts/.tools/ /tts/x64 __pycache__/ build/ config/bnip/* config/custom.*.ini config/custom.ini coverage.xml custom.ini custom_filter_affixes.yaml custom_filter_aspects.yaml d4lf_* dist/ generated/ htmlcov/ logs/ main.build/ main.dist/ main.onefile-build/ playground.py stats/ utils/live-view/ venv assets/last_update .codex AGENTS.md ================================================ FILE: .pre-commit-config.yaml ================================================ default_install_hook_types: [pre-push] default_language_version: python: python3.14 minimum_prek_version: '0.3.0' repos: - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.11.14 hooks: - id: uv-lock priority: &group1 4294967294 - repo: https://github.com/executablebooks/mdformat rev: 1.0.0 hooks: - id: mdformat priority: *group1 additional_dependencies: - mdformat-gfm - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.16.0 hooks: - id: pretty-format-toml priority: *group1 args: [--autofix, --indent, "4"] exclude: ^uv\.lock - id: pretty-format-yaml priority: *group1 args: [--autofix, --preserve-quotes, --offset, "2"] - repo: https://github.com/pre-commit/mirrors-clang-format rev: v22.1.5 hooks: - id: clang-format priority: *group1 files: \.(cpp|h)$ - repo: builtin hooks: - id: check-added-large-files priority: &read-only 4294967295 - id: check-case-conflict priority: *read-only - id: check-executables-have-shebangs priority: *read-only - id: check-json priority: *read-only - id: check-toml priority: *read-only - id: check-yaml priority: *read-only - id: end-of-file-fixer - id: fix-byte-order-marker exclude: ^tts/sign_dll.ps1 - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-ast priority: *read-only - id: pretty-format-json args: [--autofix, --indent=4, --no-ensure-ascii] priority: *group1 - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.13 hooks: - id: ruff-format priority: *group1 - id: ruff-check priority: &group2 4294967293 args: [--fix] - repo: https://github.com/thetestlabs/py-psscriptanalyzer rev: v0.3.1 hooks: - id: py-psscriptanalyzer priority: *read-only args: ['--severity', 'Error'] - id: py-psscriptanalyzer-format priority: *group1 ================================================ FILE: LICENSE.txt ================================================ MIT License Copyright (c) 2026 d4lf contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ![logo](assets/logo.png) ## Note: D4LF will be updated for Season 13. However, there were a lot of itemization updates and it may take a few weeks for the new features to be complete. You can monitor progress and make suggestions in discord: https://discord.com/channels/1168807680295571456/1499898416572928115 Filter items and sigils in your inventory based on affixes, aspects and thresholds of their values. For questions, feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6T) or use github issues. ![sample](assets/thumbnail.jpg) ## Features - Filter items in inventory and stash - Filter by item type, item power and greater affix count - Filter by affix and their values, with per-affix greater affix requirements - Filter uniques by their affix and aspect values - Filter sigils by blacklisting and whitelisting locations and affixes - Filter tributes by name or rarity - Quickly move items from your stash or inventory - Supported resolutions are all aspect ratios between 16:10 and 21:9 - Paragon Overlay with import from supported build planners (Mobalytics, Maxroll, D4Builds) ## How to Setup ### Installation and quick start guide (New instructions for season 12 that must be followed!) - Download and extract the latest version (.zip) from the releases: https://github.com/d4lfteam/d4lf/releases - Find your "Diablo IV" directory. Copy the path and have it in your clipboard: - In Battle.net, click the gear icon next to the Play button and select "Open in Explorer" - In Steam, right click the game, select Manage > Browse local files - D4LF gets item information by reading the screen and using TTS information sent for accessibility. TTS setup takes additional steps, detailed below. For more information on the install_dll.cmd script, see [the TTS section](https://github.com/d4lfteam/d4lf/blob/main/README.md#tts) - Navigate to your d4lf directory - Double-click `install_dll.cmd` - If asked for administrator permissions, provide them. - When asked for your Diablo 4 path, provide it - When asked to install a certificate, allow it. - If everything is successful, proceed with the guide. Otherwise join the [discord](https://discord.gg/YyzaPhAN6T) or post an issue in github. - Generate a profile of what Diablo 4 items you want to filter for. To do so you have a few options: - Run d4lf.exe and import a profile using the import window by pasting a build page from popular planner websites - Create one yourself by looking at the [examples](#how-to-filter--profiles) below - If created manually, place the profile in the `C:/Users//.d4lf/profiles` folder. The D4LF importer window has a button to open this folder directly. If imported they are placed there automatically. - Run d4lf.exe and use the config button to configure the profiles in the general section. Select the '...' next to profiles to activate which profiles you want to use. - Ensure all [game settings](#game-settings) are configured properly. - If you made changes, restart d4lf.exe and launch Diablo 4. - 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. ### Game Settings - Game Language must be English - IMPORTANT: Advanced Tooltip Information must be enabled in Options > Gameplay > Gameplay. If you don't do this then item parsing will be very inconsistent and you will receive no warning something is wrong. - Font scale in Graphics settings must be small or medium - HDR makes the screen too bright and D4LF is unable to read the state of some items on screen. It must be disabled. - Use Screen Reader must be enabled in Options > Accessibility - 3rd Party Screen Reader must be enabled in Options > Accessibility (The voice will go away when DLL is installed, see quick start guide above) ### Common problems - The tool shows a warning saying "TTS connection has not been made yet." but I've set everything up correctly. - If you're seeing this error, it means D4LF has found the DLL is in the correct location but the TTS connection is still not being made. This is most likely due to an issue with your windows user not allowing Diablo to connect to the third party screen reader. The following steps should resolve it: - Set Diablo 4 to run as administrator. First, navigate to your Diablo 4 directory. - Steam User: Right click on the game and choose Properties. In that menu, go to Installed Files and hit Browse. - Battle.net User: On the game page, click the gear icon and choose Show in Explorer - Right-click on Diablo IV.exe and go to Properties. In the Compatibility tab, check the box that says "Run this program as an administrator" - Run Diablo 4 again through Steam/Battle.net and see if that resolved the issue. - If it did not, set Steam/Battle.net to run as administrator as well and make sure you are running Diablo through Steam. This should resolve the issue. - 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 in `C:/Users//.d4lf/` and then open the GUI and configure your params.ini through the Settings window in D4LF. Using the GUI for configuration will ensure the file is always accurate. - Mouse control isn't possible - Due to your local windows settings, the tool might not be able to control the mouse. Just run the tool as admin and it should work. If you don't want to run it as admin, you can disable the mouse control in the params.ini by setting `vision_mode_only` to `true`. - 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`/`*.yml` profile files with a top-level `Paragon:` section (default: `C:/Users//.d4lf/profiles`). - Check/adjust `advanced/settings/toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. ### TTS D4 uses a third-party TTS engine called Tolk. Tolk has a feature that allows custom third-party TTS DLLs to be loaded. D4 automatically loads the DLL, which actually just sends the text to another application rather than reading it aloud. This is similar to having a Braille TTS application for D4. The TTS dll (saapi64.dll) must be signed for Diablo 4 to pick it up. The install_dll.cmd script handles all of this for you. It will: - Copy the dll file to the Diablo 4 directory - Download the signtool needed to add a local signature to the dll - Runs the signtool and signs the dll If you prefer running it from a terminal, you can run `.\install_dll.cmd`. For very advanced users that don't want to automatically download signtool.exe, you can run `.\install_dll.cmd -signtool_path ""` ### Configs The config folder in `C:/Users//.d4lf` contains: - **profiles/\*.yaml**: These files determine what should be filtered. Profiles created by the GUI will be placed here 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. - **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 (Settings window in GUI) | [general] | Description | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | profiles | A set of profiles separated by comma. d4lf will look for these yaml files in config/profiles and in C:/Users/WINDOWS_USER/.d4lf/profiles | | auto_use_temper_manuals | When using the loot filter, should found temper manuals be automatically used? Note: Will not work with stash open. | | browser | Which browser to use to get builds, please make sure you pick an installed browser: chrome, edge or firefox are currently supported. | | check_chest_tabs | Which chest tabs will be checked and filtered for items in case chest is open when starting the filter. You need to buy all slots. Counting is done left to right. E.g. 1,2,4 will check tab 1, tab 2, tab 4 | | do_not_junk_ancestral_legendaries | Do not mark ancestral legendaries as junk. | | full_dump | When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not | | handle_cosmetics | How to handle new cosmetics that do not match any filter and are not aspect upgrades. `ignore` will ignore them, `junk` will mark them as junk | | handle_uniques | How to handle uniques that do not match any filter. This property does not apply to filtered uniques. All mythics are favorited regardless of filter.
- `favorite`: Mark the unique as favorite and vision mode will show it as green (default)
- `ignore`: Do nothing with the unique and vision mode will show it as green
- `junk`: Mark any uniques that don't match any filters as junk and show as red in vision mode | | ignore_escalation_sigils | When filtering Sigils, should escalation sigils be ignored? | | keep_aspects | - `all`: Keep all legendary items
- `upgrade`: Keep all legendary items that upgrade your codex of power. If the item matches no profile, it will be highlighted in orange
- `none`: Keep no legendary items based on aspect (they are still filtered!)
- | | mark_as_favorite | Whether to favorite matched items or not. Defaults to true | | max_stash_tabs | The maximum number of stash tabs you have available to you if you bought them all. If you own the Lord of Hatred expansion you should choose 7. | | minimum_overlay_font_size | The minimum font size for the vision overlay, specifically the green text that shows which filter(s) are matching. Note: For small profile names, the font may actually be larger than this size but will never go below this size. | | move_to_inv_item_type
move_to_stash_item_type | Which types of items to move when using fast move functionality. Will only affect tabs defined in check_chest_tabs. You can select more than one option.
- `favorites`: Move favorites only
- `junk`: Move junk only
- `unmarked`: Only items not marked as favorite or junk
- `everything`: Move everything | | run_vision_mode_on_startup | If the vision mode should automatically start when starting d4lf. Otherwise has to be started manually with the vision button or the hotkey | | colorblind_mode | Enable a colorblind friendly palette for loot filter and paragon overlays | | vision_mode_type | Which vision mode you would like to use?. `highlight_matches` does the classic green highlighting of affixes on screen, but is slightly slower. `fast` just puts green text on screen but is very fast and works with controllers. | | [char] | Description | | --------- | --------------------------------- | | 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_drop | Hotkey to start/stop filtering items. Unmatched items are dropped instead of marked as junk | | 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 | | 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 d4lf.exe is the one-stop shop for all operations, including running the D4LF process and any configuration changes. If you prefer a standalone console-only experience, you can run d4lf-consoleonly.bat instead which will not open a GUI as well. It is still recommended you open the GUI for any configurations management. Except for changing the vision mode, all changes are automatically applied when made. If you make changes to a profile, those will be automatically picked up and no restart is necessary. Current functionality: - Import builds from maxroll/d4builds/mobalytics - Toggle the integrated Paragon overlay (default hotkey: F10) - Complete management of your settings through the config tab - A beta version of a manual profile editor/creator Each window gives further instructions on how to use it and what kind of input it expects. ## How to filter / Profiles All profiles define whitelist filters. If no filter included in your profiles matches the item, it will be discarded. Your config files will be validated on startup and will prevent the program from starting if the structure or syntax is incorrect. The error message will provide hints about the specific problem. The following sections will explain each type of filter that you can specify in your profiles. How you define them in your YAML files is up to you; you can put all of these into just one file or have a dedicated file for each type of filter, or even split the same type of filter over multiple files. Ultimately, all profiles specified in your `params.ini` will be used to determine if an item should be kept. If one of the profiles wants to keep the item, it will be kept regardless of the other profiles. Similarly, if a filter is missing in all profiles (e.g., there is no `Sigils` section in any profile), all corresponding items (in this case, sigils) will be kept. ### Affix / Unique Aspect Filter Syntax You have two choices on how to specify aspects or affixes of an item. For both options we recommend importing a profile first and then working from there. - You can use the Edit Profile window in the GUI, which is the recommended approach - You can also manually edit your profile. The instructions below are all about editing the file manually, but the explanations apply to the GUI as well.
Examples ```yaml # Filter for attack speed - { name: attack_speed } # Filter for attack speed with a threshold value. # The filter keeps larger rolls when the tooltip range increases and smaller rolls when the range decreases. - { name: attack_speed, value: 4 } # Filter for attack speed where the affix is greater than 50% of the potential maximum - { name: attack_speed, minPercentOfAffix: 50 } ```
### Affixes Affixes are defined by the top-level key `Affixes`. It contains a list of filters that you want to apply. Each filter has a name and can filter for any combination of the following: - `itemType`: The name of the type or a list of multiple types. See [assets/lang/enUS/item_types.json](assets/lang/enUS/item_types.json) - `minPower`: Minimum item power - `minGreaterAffixCount`: Minimum number of greater affixes expected on the overall item. See [Greater Affix Filtering](#greater-affix-filtering) for more information on filtering GAs. - `affixPool`: A list of multiple different rulesets to filter for. Each ruleset must be fulfilled or the item is discarded - `count`: Define a list of affixes (see [syntax](#affix--unique-aspect-filter-syntax)) and optionally `minCount`, `maxCount` and `minGreaterAffixCount` - `minCount`: specifies the minimum number of affixes that must match the item. defaults to amount of specified affixes - `maxCount` specifies the maximum number of affixes that must match the item. defaults to amount of specified affixes - `inherentPool`: The same rules as for `affixPool` apply, but this is evaluated against the inherent affixes of the item - `uniqueAspect`: If you're looking for a specific unique, this is how you define it. It has the following properties: - `name`: (Required) The name of the unique you are looking for. You can find a list in [uniques.json](assets/lang/enUS/uniques.json) - `value`: What is the minimum value the aspect must have. You can not have both this and minPercentOfAspect - `minPercentOfAspect`: Instead of defining a specific value, what percent of the potential maximum value of the aspect should we keep. See [this section](#filtering-on-percent-of-affix-instead-of-value) for more information.
Config Examples ```yaml Affixes: # Search for chest armor and pants that are at least item level 725 and have at least 3 affixes of the affixPool - NiceArmor: itemType: [ chest armor, pants ] minPower: 725 affixPool: - count: - { name: dexterity, value: 33 } - { name: damage_reduction, value: 5 } - { name: lucky_hit_chance, value: 3 } - { name: total_armor, value: 9 } - { name: maximum_life, value: 700 } minCount: 3 # Search for chest armor that is at least item level 925 and have at least 3 affixes of the affixPool. At least 2 of the matched affixes must be greater affixes - NiceArmor: itemType: chest armor minPower: 925 affixPool: - count: - { name: dexterity } - { name: damage_reduction } - { name: lucky_hit_chance } - { name: total_armor } - { name: maximum_life } minCount: 3 minGreaterAffixCount: 2 # Search for boots that have at least 2 of the specified affixes and either max evade charges or reduced evade cooldown as inherent affix - GreatBoots: itemType: boots minPower: 800 inherentPool: - count: - { name: maximum_evade_charges } - { name: attacks_reduce_evades_cooldown_by_seconds } minCount: 1 affixPool: - count: - { name: movement_speed, value: 16 } - { name: cold_resistance } - { name: lightning_resistance } minCount: 2 # Search for boots that have at least 2 of the specified affixes AND are a Penitent Greaves # The Greaves must have at least 19% damage multiplier to chilled enemies (Greaves's range is 15-25) # Note this would not match non-unique boots that have movement speed and cold resistance, it will only match a Penitent Greaves - GreatUniqueBoots: itemType: boots minPower: 800 affixPool: - count: - { name: movement_speed, value: 16 } - { name: cold_resistance } - { name: lightning_resistance } minCount: 2 uniqueAspect: - name: penitent_greaves minPercentOfAspect: 50 # Search for boots with movement speed and 1 resistances from a pool of all resistances. # No need to add maxCount to the resistance group since it isn't possible for an item to have more than one resistance affix - ResBoots: itemType: boots minPower: 800 affixPool: - count: - { name: movement_speed, value: 16 } - count: - { name: shadow_resistance } - { name: cold_resistance } - { name: lightning_resistance } - { name: fire_resistance } - { name: poison_resistance } minCount: 1 # Search for boots with movement speed. At least two of all item affixes must be a greater affix, but we don't care which - GreaterAffixBoots: itemType: boots minPower: 800 minGreaterAffixCount: 2 affixPool: - count: - { name: movement_speed, value: 16 } # Keep all ancestral items, even if they don't match a different filter - AncestralMatch: itemType: [amulet, axe, two-handed axe, boots, bow, chest armor, crossbow, dagger, flail, focus, glaive, gloves, helm, pants, mace, two-handed mace, totem, polearm, quarterstaff, ring, scythe, two-handed scythe, shield, staff, sword, two-handed sword, tome, wand] minPower: 900 ```
Affix names are lower case and spaces are replaced by underscore. You can find the full list of names in [assets/lang/enUS/affixes.json](assets/lang/enUS/affixes.json). ### Filtering on percent of affix instead of value You also have the option to filter on the minimum percent of the affix you want instead of a specific value. For example, say you want strength on an item. The potential values for strength are 100-150. If you say the `minPercentOfAffix` for strength is 50 (which means 50%), then strength rolls of 125 and up are kept and rolls below 125 would be discarded. A greater affix is considered to always match a `minPercentOfAffix`. You do not need to designate larger/smaller for `value` or `minPercentOfAffix`; that is automatically determined from the roll range. If you put in `minPercentOfAffix` you can not also put `value` for that affix. It must be one or the other. These rules also apply for `minPercentOfAspect` on the `uniqueAspect` and in `GlobalUniques`.
Config Examples ```yaml Affixes: # Search for chest armor that is at least item level 925 and have at least 3 affixes of the affixPool. # It must have more than 40 damage_reduction, and armor must be at least 70% of its potential maximum affix value - NiceArmor: itemType: chest armor minPower: 925 affixPool: - count: - { name: dexterity } - { name: damage_reduction, value: 40 } - { name: lucky_hit_chance } - { name: armor, minPercentOfAffix: 70 } - { name: maximum_life } minCount: 3 ```
### Greater Affix Filtering D4LF provides two complementary ways to filter items based on Greater Affixes: #### 1. Item-Level Greater Affix Count (`minGreaterAffixCount`) This filter requires a minimum total number of Greater Affixes on the entire item, regardless of which affixes they are.
Example ```yaml Affixes: - GreaterAffixBoots: itemType: boots minGreaterAffixCount: 2 # Item must have at least 2 Greater Affixes total affixPool: - count: - { name: movement_speed } - { name: maximum_life } - { name: strength } - { name: fire_resistance } minCount: 3 ```
#### 2. Per-Affix Greater Affix Requirements (`want_greater`) When using the Profile Editor GUI or when importing affixes using the importer, you can mark/import specific affixes with a "Greater" checkbox. This is shown as `want_greater` in the profile. This is a list of affixes that you would prefer to be greater affixes. The `minGreaterAffixCount` value on the item is still respected, so if you have two affixes tagged as `want_greater` but a `minGreaterAffixCount` of 1, an item with one of those two affixes as GA will be kept. If neither of those affixes are GA but a different one is, the item will not be kept.
Example ```yaml Affixes: - PerfectBoots: itemType: boots affixPool: - count: - { name: movement_speed, want_greater: true } # MUST be a Greater Affix - { name: maximum_life, want_greater: true } # MUST be a Greater Affix - { name: strength } # Can be normal or Greater - { name: fire_resistance } # Can be normal or Greater minCount: 3 minGreaterAffixCount: 2 # Auto-set by GUI if Auto-Sync is checked, or Require Greater Affixes is checked on the importer ``` **This item would match:** Boots with movement_speed (GA), maximum_life (GA), cold_resistance (normal), fire_resistance (normal)\ **Why:** movement_speed and maximum_life are both Greater Affixes as required, and item has 4 affixes (meets minCount of 3) **This item would NOT match:** Boots with movement_speed (normal), maximum_life (GA), cold_resistance (normal), fire_resistance (normal)\ **Why:** movement_speed is marked as `want_greater: true` but is not a Greater Affix on the item
#### Common Use Cases
Examples **"I want boots with at least 2 Greater Affixes, don't care which ones"** ```yaml - itemType: boots minGreaterAffixCount: 2 affixPool: - count: - { name: movement_speed } - { name: maximum_life } - { name: strength } - { name: fire_resistance } minCount: 3 ``` **"I want boots where movement_speed MUST be a Greater Affix"** ```yaml - itemType: boots minGreaterAffixCount: 1 # The minGreaterAffixCount is important, if it was 0 then movement_speed would not be required to be GA affixPool: - count: - { name: movement_speed, want_greater: true } - { name: maximum_life } - { name: strength } - { name: fire_resistance } minCount: 3 ``` **"I want boots where both movement_speed AND maximum_life MUST be Greater Affixes"** ```yaml - itemType: boots minGreaterAffixCount: 2 # minGreaterAffixCount of 2 requires both to be GA affixPool: - count: - { name: movement_speed, want_greater: true } - { name: maximum_life, want_greater: true } - { name: strength } - { name: fire_resistance } minCount: 3 ``` **"I want boots where either movement_speed OR maximum_life are Greater Affixes"** ```yaml - itemType: boots minGreaterAffixCount: 1 # minGreaterAffixCount of 1 requires either to be GA affixPool: - count: - { name: movement_speed, want_greater: true } - { name: maximum_life, want_greater: true } - { name: strength } # If strength on the item was greater and the top two were not, this would not be matched - { name: fire_resistance } minCount: 3 ```
### AspectUpgrades Legendary Aspects that you want to be notified of receiving upgrades for can be placed in your profile. They are defined in the top-level key `AspectUpgrades`. This filter is generally for build-specific aspects that you'd like to be made aware of when you receive an upgrade so you can upgrade that aspect immediately at the occultist. We notify the user by favoriting the item and showing orange text or orange highlighting when hovering over the item. If the item matches any other profile, this filter does nothing. This filter does respect the `mark_as_favorite` config property. Any aspects that do not match this filter or are not codex upgrades are handled by the `keep_aspects` config property.
Config Examples ```yaml AspectUpgrades: # This would mark Snowveiled Adventurer's Pants as a favorite if it's a codex upgrade. It would ignore the pants otherwise. - of_singed_extremities - snowveiled ``` ```yaml # This works exact same as above, it's just a different way to format it AspectUpgrades: [of_singed_extremities, snowveiled] ```
Aspect names are lower case and spaces are replaced by underscore. You can find the full list of names in [assets/lang/enUS/aspects.json](assets/lang/enUS/aspects.json). ### Sigils Sigils are defined by the top-level key `Sigils`. It contains a list of affix or location names that you want to filter for. If no Sigil filter is provided, all Sigils will be kept.
Config Examples ```yaml Sigils: blacklist: # locations - endless_gates - vault_of_the_forsaken # affixes - armor_breakers - resistance_breakers ``` If you want to filter for a specific affix or location, you can also use the `whitelist` key. Even if `whitelist` is present, `blacklist` will be used to discard sigils that match any of the blacklisted affixes or locations. ```yaml # Only keep sigils for vault_of_the_forsaken without any of the affixes armor_breakers and resistance_breakers Sigils: blacklist: - armor_breakers - resistance_breakers whitelist: - vault_of_the_forsaken ``` To switch that priority, you can add the `priority` key with the value `whitelist`. ```yaml # This will keep all vault of the forsaken sigils even if they have armor_breakers or resistance_breakers Sigils: blacklist: - armor_breakers - resistance_breakers whitelist: - vault_of_the_forsaken priority: whitelist ``` You can also create conditional filters based on a single affix or location. ```yaml # Only keep sigils for iron_hold when it also has shadow_damage Sigils: blacklist: - armor_breakers - resistance_breakers whitelist: - [ iron_hold, shadow_damage ] ```
Sigil affixes and location names are lower case and spaces are replaced by underscore. You can find the full list of names in [assets/lang/enUS/sigils.json](assets/lang/enUS/sigils.json). ### Tributes Tributes are defined by the top-level key `Tributes`. It contains a list of either tribute names or rarities you want to keep. Any not in the list are not kept. If no Tribute filter is provided, all Tributes will be kept. Mythic tributes are always kept no matter what.
Config Examples ```yaml # Keeps tribute_of_mystique and all legendary and unique tributes Tributes: - tribute_of_mystique - [legendary, unique] ``` If you're exceptionally pressed for time, you can just put the name of the tribute without "tribute_of\_" at the beginning. ```yaml # Keeps Tribute of Mystique and Tribute of Ascendance (Resolute) and nothing else Tributes: - mystique - ascendance_resolute ```
Tribute names are lower case and spaces are replaced by underscore. Parentheses are removed. Note that United and Resolute identifiers are part of the names in [assets/lang/enUS/tributes.json](assets/lang/enUS/tributes.json). You can find the list of item rarities in [rarity.py](src/item/data/rarity.py) ### GlobalUniques If you are searching for a specific Unique, use the `uniqueAspect` key in [the Affixes section](#affixes). If you additionally want to keep other uniques that have particular stats, use the `GlobalUniques` key. Global unique filters are defined by the top-level key `GlobalUniques`. It contains a list of parameters that you want to filter for. If no global unique filter is provided or if the item does not match any unique filter (affix or otherwise), uniques will be handled according to the handle_uniques configuration. All mythics are marked as favorite regardless of any filter or configuration. The following global filters are available: - `minGreaterAffixCount`: Only keep uniques with a specific number of greater affixes - `minPercentOfAspect`: Only keep uniques whose aspect is above a percentage of the total possible. For example, if this is set to 80 and an aspect has a range of 100-200, then a value of 180 would be kept but a value of 150 would be marked as junk. Situations where a smaller value is what is wanted are automatically handled as well. - `minPower`: The minimum item power of uniques to keep - `profileAlias`: In vision mode, uniques show as .. For example myuniques.yaml with fists_of_fate aspect defined would show as myuniques.fists_of_fate. The label for the filename can be configured at the aspect level using the profileAlias flag (see examples).
Config Examples ```yaml # Take all uniques with item power > 900 GlobalUniques: - minPower: 900 ``` ```yaml # Take all uniques with at least 1 greater affix. It would show in logs/vision mode as cool_stuff. GlobalUniques: - minGreaterAffixCount: 1 profileAlias: cool_stuff ``` ```yaml # Note that if a unique matches any filter, it is kept. Each - denotes a new filter. # For example, the below will keep all uniques that have two greater affixes OR an aspect percentage greater than 80 GlobalUniques: - minGreaterAffixCount: 2 - minPercentOfAspect: 80 ``` ```yaml # Conversely, this will match all uniques that have two greater affixes AND an aspect percentage greater than 80 GlobalUniques: - minGreaterAffixCount: 2 minPercentOfAspect: 80 ```
## Paragon overlay ![sample](assets/paragon_overlay.jpg) 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). 1. Enable **Import Paragon** in the importer. 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*). 1. Follow the on-screen instructions to zoom in and out of the overlay until it is the size you want. Ideally, the golden outline will be the same size as the red lines in the paragon board. The location of the overlay is automatically saved. **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, please report a bug. ## Future Plans - A video explaining the initial setup - Evaluate using joystick emulation to further increase speed for users willing to do additional setup - Finish GUI documentation - Want something done that's not mentioned here? Leave a suggestion in the [discord](https://discord.gg/YyzaPhAN6T) or use github issues. Or, make the changes yourself and open up a PR! ## Develop ### Setup using uv If you intend to submit PRs, create your own fork of d4lf and clone that in the steps below. Before beginning, [install uv](https://docs.astral.sh/uv/getting-started/installation/#winget). ```bash git clone https://github.com/d4lfteam/d4lf cd d4lf uv sync uv run python -m src.main ``` If you receive an error about missing Visual Studio code, follow the link it provides. Install Visual Studio Build Tools 2022 with the defaults selected and also select "MSVC VS 2022 C++ ..." and "Windows 11 SDK ...". Restart your terminal and try again. ### Formatting & Linting Just use prek. If it's your first setup, you will need to install the NuGet package provider. Open Windows Powershell and run:: ``` Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser ``` Then run: ```bash prek install ``` Otherwise just run: ```bash prek run -a ``` ### A note on use of AI for PRs AI usage is not banned for D4LF, but some things need to be kept in mind: - You are responsible for any PR you submit. - It is expected you have tested your code - It is expected you will fix any bugs resulting from your work - You need to have an understanding of the changes you're making and why you're making them - PRs should change as little code as possible, only what needs to be changed for the new feature you are implementing. - Unless something is being deleted, existing code comments should be maintained - There should be 1 PR per feature. Try to keep PRs small. The release notes are generated from the PR titles so if you put a lot of items into one PR we can't properly describe it in the release notes. - Be prepared for a lot of comments on your PR. Everything that's being done needs to be understandable by the maintainer because he has to fix it 3 months later if something goes wrong. Ultimately, please understand there is only 1 full-time maintainer of D4LF and that maintainer does not use AI. The code needs to remain human readable, and humans are who initially wrote it. If an AI and a human disagree, the human always wins. AIs can be very stupid. ## Credits - 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 ================================================ FILE: assets/lang/enUS/How to add to these files.md ================================================ These files are all autogenerated from data from d4companion and d4data. Any manual additions to them will be overwritten the next time that data is updated. If you want to add data to these files, do the following steps: 1. Download the latest version of d4data: https://github.com/DiabloTools/d4data.git 1. Download the latest version of d4companion: https://github.com/josdemmers/Diablo4Companion 1. Run [gen_data.py](/src/tools/gen_data.py). You provide the paths of the above two downloads. For example, you might run: `python gen_data.py C:\Users\you\code\d4data C:\Users\you\code\Diablo4Companion` If you do not see the new data you're expecting to see, you need to add it to the appropriate custom\_\* file. These files store any additional data that we were not able to find in d4data for any reason. You can find the custom files in [src/tools/data](/src/tools/data). For example, if you need to add a new aspect, you can add it to custom_aspects_enUS.json. The only exception is corrections.json, which can be modified directly. If you find a bad TTS name for a unique that is where you fix it. After adding your custom data, run gen_data again and ensure your asset file looks how you expect. Open a PR after. ================================================ FILE: assets/lang/enUS/affixes.json ================================================ { "abyss_damage": "abyss damage", "advance_resource_generation": "advance resource generation", "aegis_cooldown_reduction": "aegis cooldown reduction", "agility_cooldown_reduction": "agility cooldown reduction", "agility_damage": "agility damage", "all_damage_multiplier": "all damage multiplier", "all_stats": "all stats", "all_stats_per_ferocity_or_resolve_stack": "all stats per ferocity or resolve stack", "ancient_damage": "ancient damage", "anima_of_the_forest_grants_attack_speed": "anima of the forest grants attack speed", "arbiter_duration": "arbiter duration", "arbiter_of_justice_cooldown_reduction": "arbiter of justice cooldown reduction", "archfiend_damage": "archfiend damage", "armor": "armor", "armor_and_resistance_to_all_elements_while_you_have_three_or_more_growth_&_decay_witch_powers_equipped_you_gain_maximum_life_and_unhindered": "armor and resistance to all elements while you have three or more growth & decay witch powers equipped you gain maximum life, and unhindered", "armor_in_arbiter_form": "armor in arbiter form", "armor_while_in_human_form": "armor while in human form", "at_level_)": "at level )", "attack_speed": "attack speed", "attack_speed_for_seconds_after_casting_a_defensive_skill": "attack speed for seconds after casting a defensive skill", "attack_speed_for_seconds_after_dodging_an_attack": "attack speed for seconds after dodging an attack", "attack_speed_while_berserking": "attack speed while berserking", "attacks_reduce_evades_cooldown_by_seconds": "attacks reduce evades cooldown by seconds", "attacks_reduce_ultimate_cooldown_by_seconds": "attacks reduce ultimate cooldown by seconds", "aura_cooldown_reduction": "aura cooldown reduction", "aura_enhancement_potency": "aura enhancement potency", "aura_potency": "aura potency", "ball_lightning_can_be_cast_while_moving": "ball lightning can be cast while moving", "ball_lightning_projectile_speed": "ball lightning projectile speed", "barrier_generation": "barrier generation", "basic_attack_speed": "basic attack speed", "basic_damage": "basic damage", "basic_lucky_hit_chance": "basic lucky hit chance", "basic_resource_generation": "basic resource generation", "berserking_duration": "berserking duration", "bleeding_damage": "bleeding damage", "blight_chill_potency": "blight chill potency", "blizzard_damage": "blizzard damage", "block_chance": "block chance", "blood_attack_speed": "blood attack speed", "blood_damage": "blood damage", "blood_howl_cooldown_reduction": "blood howl cooldown reduction", "blood_howl_grants_stealth_for_seconds": "blood howl grants stealth for seconds", "blood_mist_cooldown_reduction": "blood mist cooldown reduction", "blood_orb_healing": "blood orb healing", "blood_orbs_restore_essence": "blood orbs restore essence", "blood_surge_drains_times_from_elites": "blood surge drains times from elites", "blood_wave_cooldown_reduction": "blood wave cooldown reduction", "bone_critical_strike_chance": "bone critical strike chance", "bone_critical_strike_damage": "bone critical strike damage", "bone_damage": "bone damage", "bone_prison_cooldown_reduction": "bone prison cooldown reduction", "bone_spirit_cooldown_reduction": "bone spirit cooldown reduction", "bone_spirit_damage": "bone spirit damage", "bone_storm_duration": "bone storm duration", "boulder_cooldown_reduction": "boulder cooldown reduction", "boulder_damage": "boulder damage", "brandish_resource_generation": "brandish resource generation", "brawling_cooldown_reduction": "brawling cooldown reduction", "brawling_damage": "brawling damage", "burning_damage": "burning damage", "call_of_the_ancients_cooldown_reduction": "call of the ancients cooldown reduction", "caltrops_cooldown_reduction": "caltrops cooldown reduction", "casted_hydras_have_heads": "casted hydras have heads", "casting_blood_wave_fortifies_you_for_maximum_life": "casting blood wave fortifies you for maximum life", "casting_bone_spear_reduces_blood_waves_cooldown_by_seconds": "casting bone spear reduces blood waves cooldown by seconds", "casting_justice_skills_restores_primary_resource": "casting justice skills restores primary resource", "casting_macabre_skills_restores_primary_resource": "casting macabre skills restores primary resource", "casting_ultimate_skills_restores_primary_resource": "casting ultimate skills restores primary resource", "casting_valor_skills_restores_primary_resource": "casting valor skills restores primary resource", "casting_wrath_skills_restores_primary_resource": "casting wrath skills restores primary resource", "cataclysm_cooldown_reduction": "cataclysm cooldown reduction", "cataclysm_damage": "cataclysm damage", "centipede_damage": "centipede damage", "challenging_shout_cooldown_reduction": "challenging shout cooldown reduction", "chance_for_arbiter_to_deal_double_damage": "chance for arbiter to deal double damage", "chance_for_army_of_the_dead_to_deal_double_damage": "chance for army of the dead to deal double damage", "chance_for_ball_lightning_projectiles_to_cast_twice_is_converted_to_chance_to_cast_a_super_ball_lightning": "chance for ball lightning projectiles to cast twice is converted to chance to cast a super ball lightning", "chance_for_basic_skills_to_deal_double_damage": "chance for basic skills to deal double damage", "chance_for_blood_lance_to_deal_double_damage": "chance for blood lance to deal double damage", "chance_for_bone_storm_to_deal_double_damage": "chance for bone storm to deal double damage", "chance_for_brandish_to_deal_double_damage": "chance for brandish to deal double damage", "chance_for_clash_to_deal_double_damage": "chance for clash to deal double damage", "chance_for_concussive_stomp_to_extra_hit": "chance for concussive stomp to extra hit", "chance_for_core_skills_to_hit_twice": "chance for core skills to hit twice", "chance_for_corpse_explosion_to_deal_double_damage": "chance for corpse explosion to deal double damage", "chance_for_incinerate_to_deal_double_damage": "chance for incinerate to deal double damage", "chance_for_judgement_to_deal_double_damage": "chance for judgement to deal double damage", "chance_for_minion_attacks_to_fortify_you_for_maximum_life": "chance for minion attacks to fortify you for maximum life", "chance_for_payback_to_deal_double_damage": "chance for payback to deal double damage", "chance_for_pestilent_swarm_to_deal_double_damage": "chance for pestilent swarm to deal double damage", "chance_for_potency_skills_to_deal_double_damage": "chance for potency skills to deal double damage", "chance_for_projectiles_to_cast_twice": "chance for projectiles to cast twice", "chance_for_rapid_fire_projectiles_to_cast_twice": "chance for rapid fire projectiles to cast twice", "chance_for_ravens_to_deal_double_damage": "chance for ravens to deal double damage", "chance_for_retribution_to_deal_double_damage": "chance for retribution to deal double damage", "chance_for_rock_splitter_to_deal_double_damage": "chance for rock splitter to deal double damage", "chance_for_rushing_claw_to_deal_double_damage": "chance for rushing claw to deal double damage", "chance_for_sever_to_deal_double_damage": "chance for sever to deal double damage", "chance_for_shield_bash_to_deal_double_damage": "chance for shield bash to deal double damage", "chance_for_shield_charge_to_deal_double_damage": "chance for shield charge to deal double damage", "chance_for_soar_to_deal_double_damage": "chance for soar to deal double damage", "chance_for_soulrift_to_deal_double_damage": "chance for soulrift to deal double damage", "chance_for_spear_of_the_heavens_to_deal_double_damage": "chance for spear of the heavens to deal double damage", "chance_for_the_devourer_to_deal_double_damage": "chance for the devourer to deal double damage", "chance_for_the_hunter_to_deal_double_damage": "chance for the hunter to deal double damage", "chance_for_the_protector_to_deal_double_damage": "chance for the protector to deal double damage", "chance_for_the_seeker_to_deal_double_damage": "chance for the seeker to deal double damage", "chance_for_thrash_to_deal_double_damage": "chance for thrash to deal double damage", "chance_for_thunderspike_to_deal_double_damage": "chance for thunderspike to deal double damage", "chance_for_vortex_to_extra_hit": "chance for vortex to extra hit", "chance_for_withering_fist_to_deal_double_damage": "chance for withering fist to deal double damage", "chance_for_zeal_to_deal_double_damage": "chance for zeal to deal double damage", "chance_to_cluck_thrice": "chance to cluck thrice", "chance_when_struck_to_fortify_for_life": "chance when struck to fortify for life", "chance_when_struck_to_gain_life_as_barrier_for_seconds": "chance when struck to gain life as barrier for seconds", "charge_cooldown_reduction": "charge cooldown reduction", "charge_damage": "charge damage", "chill_slow_potency": "chill slow potency", "clash_resource_generation": "clash resource generation", "cold_damage": "cold damage", "cold_damage_multiplier": "cold damage multiplier", "cold_mage_attack_speed": "cold mage attack speed", "cold_resistance": "cold resistance", "companion_cooldown_reduction": "companion cooldown reduction", "companion_damage": "companion damage", "concealment_cooldown_reduction": "concealment cooldown reduction", "condemn_cooldown_reduction": "condemn cooldown reduction", "conjuration_cooldowns_are_reduced_by_seconds_when_a_frozen_orb_explodes": "conjuration cooldowns are reduced by seconds when a frozen orb explodes", "conjuration_damage": "conjuration damage", "consecration_cooldown_reduction": "consecration cooldown reduction", "cooldown_reduction": "cooldown reduction", "core_attack_speed": "core attack speed", "core_damage": "core damage", "core_resource_cost_reduction": "core resource cost reduction", "corpse_attack_speed": "corpse attack speed", "corpse_damage": "corpse damage", "corpse_explosion_damage": "corpse explosion damage", "corpse_explosion_fears_and_slows_for_seconds": "corpse explosion fears and slows for seconds", "corpse_tendrils_damage": "corpse tendrils damage", "corrupting_damage": "corrupting damage", "counterattack_charges": "counterattack charges", "crackling_energy_damage": "crackling energy damage", "critical_strike_and_vulnerable_damage": "critical strike and vulnerable damage", "critical_strike_chance": "critical strike chance", "critical_strike_chance_against_chilled_enemies": "critical strike chance against chilled enemies", "critical_strike_chance_against_close_enemies": "critical strike chance against close enemies", "critical_strike_chance_against_crowd_controlled_enemies": "critical strike chance against crowd controlled enemies", "critical_strike_chance_against_feared_enemies": "critical strike chance against feared enemies", "critical_strike_chance_against_injured_enemies": "critical strike chance against injured enemies", "critical_strike_chance_against_stunned_enemies": "critical strike chance against stunned enemies", "critical_strike_chance_to_each_enhanced_rapid_fire_bonus": "critical strike chance to each enhanced rapid fire bonus", "critical_strike_damage": "critical strike damage", "critical_strike_damage_multiplier": "critical strike damage multiplier", "crowd_control_duration": "crowd control duration", "crowd_control_duration_lucky_hit_up_to_a_chance_to_heal_life": "crowd control duration lucky hit up to a chance to heal life", "curse_duration": "curse duration", "cutthroat_attack_speed": "cutthroat attack speed", "cutthroat_critical_strike_chance": "cutthroat critical strike chance", "cutthroat_critical_strike_damage": "cutthroat critical strike damage", "cutthroat_damage": "cutthroat damage", "cyclone_armor_cooldown_reduction": "cyclone armor cooldown reduction", "cyclone_armor_damage": "cyclone armor damage", "damage": "damage", "damage_for_seconds_after_dodging_an_attack": "damage for seconds after dodging an attack", "damage_for_seconds_after_gaining_resolve": "damage for seconds after gaining resolve", "damage_for_seconds_after_killing_an_elite": "damage for seconds after killing an elite", "damage_for_seconds_after_picking_up_a_blood_orb": "damage for seconds after picking up a blood orb", "damage_on_next_attack_after_entering_stealth": "damage on next attack after entering stealth", "damage_over_time": "damage over time", "damage_over_time_duration": "damage over time duration", "damage_over_time_multiplier": "damage over time multiplier", "damage_per_combo_point_spent": "damage per combo point spent", "damage_per_overpower_stack": "damage per overpower stack", "damage_reduction": "damage reduction", "damage_reduction_for_each_active_ball_lightning": "damage reduction for each active ball lightning", "damage_reduction_for_your_summons": "damage reduction for your summons", "damage_reduction_from_bleeding_enemies": "damage reduction from bleeding enemies", "damage_reduction_from_burning_enemies": "damage reduction from burning enemies", "damage_reduction_from_close_enemies": "damage reduction from close enemies", "damage_reduction_from_corrupted_enemies": "damage reduction from corrupted enemies", "damage_reduction_from_distant_enemies": "damage reduction from distant enemies", "damage_reduction_from_elites": "damage reduction from elites", "damage_reduction_from_enemies_affected_by_blood_skills": "damage reduction from enemies affected by blood skills", "damage_reduction_from_enemies_affected_by_curse_skills": "damage reduction from enemies affected by curse skills", "damage_reduction_from_enemies_affected_by_trap_skills": "damage reduction from enemies affected by trap skills", "damage_reduction_from_poisoned_enemies": "damage reduction from poisoned enemies", "damage_reduction_per_crackling_energy_charge": "damage reduction per crackling energy charge", "damage_reduction_while_fortified": "damage reduction while fortified", "damage_reduction_while_healthy": "damage reduction while healthy", "damage_reduction_while_injured": "damage reduction while injured", "damage_reduction_while_standing_still": "damage reduction while standing still", "damage_reduction_while_unstoppable": "damage reduction while unstoppable", "damage_reduction_while_you_have_a_barrier": "damage reduction while you have a barrier", "damage_to_angels_and_demons": "damage to angels and demons", "damage_to_bleeding_enemies": "damage to bleeding enemies", "damage_to_burning_enemies": "damage to burning enemies", "damage_to_chilled_enemies": "damage to chilled enemies", "damage_to_close_enemies": "damage to close enemies", "damage_to_corrupted_enemies": "damage to corrupted enemies", "damage_to_crowd_controlled_enemies": "damage to crowd controlled enemies", "damage_to_cursed_enemies": "damage to cursed enemies", "damage_to_dazed_enemies": "damage to dazed enemies", "damage_to_distant_enemies": "damage to distant enemies", "damage_to_elites": "damage to elites", "damage_to_frozen_enemies": "damage to frozen enemies", "damage_to_healthy_enemies": "damage to healthy enemies", "damage_to_immobilized_enemies": "damage to immobilized enemies", "damage_to_injured_enemies": "damage to injured enemies", "damage_to_judged_enemies": "damage to judged enemies", "damage_to_knockeddown_enemies": "damage to knockeddown enemies", "damage_to_poisoned_enemies": "damage to poisoned enemies", "damage_to_poultry": "damage to poultry", "damage_to_slowed_enemies": "damage to slowed enemies", "damage_to_stunned_enemies": "damage to stunned enemies", "damage_to_trapped_enemies": "damage to trapped enemies", "damage_to_weakened_enemies": "damage to weakened enemies", "damage_when_spending_resolve": "damage when spending resolve", "damage_when_swapping_weapons": "damage when swapping weapons", "damage_while_berserking": "damage while berserking", "damage_while_fortified": "damage while fortified", "damage_while_healthy": "damage while healthy", "damage_while_in_arbiter_form": "damage while in arbiter form", "damage_while_in_human_form": "damage while in human form", "damage_while_iron_maelstrom_is_active": "damage while iron maelstrom is active", "damage_while_shadowform_is_active": "damage while shadowform is active", "damage_while_shapeshifted": "damage while shapeshifted", "damage_while_war_cry_is_active": "damage while war cry is active", "damage_while_wrath_of_the_berserker_is_active": "damage while wrath of the berserker is active", "damage_with_dualwielded_weapons": "damage with dualwielded weapons", "damage_with_ranged_weapons": "damage with ranged weapons", "damage_with_twohanded_bludgeoning_weapons": "damage with twohanded bludgeoning weapons", "damage_with_twohanded_slashing_weapons": "damage with twohanded slashing weapons", "dark_shroud_cooldown_reduction": "dark shroud cooldown reduction", "darkness_damage": "darkness damage", "dash_cooldown_reduction": "dash cooldown reduction", "dash_damage": "dash damage", "death_blow_cooldown_reduction": "death blow cooldown reduction", "death_blow_damage": "death blow damage", "death_trap_cooldown_reduction": "death trap cooldown reduction", "debilitating_roar_cooldown_reduction": "debilitating roar cooldown reduction", "deep_freeze_cooldown_reduction": "deep freeze cooldown reduction", "defensive_cooldown_reduction": "defensive cooldown reduction", "defensive_damage": "defensive damage", "defiance_aura_potency": "defiance aura potency", "demonform_damage_bonus": "demonform damage bonus", "demonology_damage": "demonology damage", "desecrated_ground_damage": "desecrated ground damage", "dexterity": "dexterity", "disciple_damage": "disciple damage", "dodge_chance": "dodge chance", "dodge_chance_against_close_enemies": "dodge chance against close enemies", "dodge_chance_against_distant_enemies": "dodge chance against distant enemies", "dodge_chance_while_channeling_dance_of_knives": "dodge chance while channeling dance of knives", "drinking_a_potion_grants_movement_speed_for_seconds": "drinking a potion grants movement speed for seconds", "dust_devil_damage": "dust devil damage", "eagle_damage": "eagle damage", "earth_attack_speed": "earth attack speed", "earth_critical_strike_chance": "earth critical strike chance", "earth_critical_strike_damage": "earth critical strike damage", "earth_damage": "earth damage", "earth_lucky_hit_chance": "earth lucky hit chance", "earthen_bulwark_cooldown_reduction": "earthen bulwark cooldown reduction", "earthquake_damage": "earthquake damage", "enchantment_damage": "enchantment damage", "energy_cost_reduction": "energy cost reduction", "energy_on_kill": "energy on kill", "energy_regeneration": "energy regeneration", "energy_when_a_stun_grenade_explodes": "energy when a stun grenade explodes", "enhanced_rupture_explosion_size": "enhanced rupture explosion size", "essence_cost_reduction": "essence cost reduction", "essence_on_hit": "essence on hit", "essence_on_kill": "essence on kill", "essence_per_enemy_drained_by_blood_surge": "essence per enemy drained by blood surge", "essence_regeneration": "essence regeneration", "evade_cooldown_reduction": "evade cooldown reduction", "evade_grants_attack_speed_for_seconds": "evade grants attack speed for seconds", "evade_grants_movement_speed_for_seconds": "evade grants movement speed for seconds", "evade_grants_unhindered_for_seconds": "evade grants unhindered for seconds", "evade_leaves_behind_desecrated_ground_for_seconds": "evade leaves behind desecrated ground for seconds", "faith_on_kill": "faith on kill", "faith_regeneration": "faith regeneration", "falling_star_cooldown_reduction": "falling star cooldown reduction", "familiar_damage": "familiar damage", "familiar_lucky_hit_chance": "familiar lucky hit chance", "fanaticism_aura_potency": "fanaticism aura potency", "feast_every_kills_chains_hook_nearby_enemies": "feast every kills, chains hook nearby enemies", "feast_every_kills_gain_berserking_for_seconds": "feast every kills, gain berserking for seconds", "feast_every_kills_release_a_bloodsplosion_for_damage": "feast every kills, release a bloodsplosion for damage", "feast_every_kills_reset_random_cooldowns": "feast every kills, reset random cooldowns", "feast_every_kills_restore_of_your_maximum_primary_resource": "feast every kills, restore of your maximum primary resource", "feast_every_kills_savagely_bite_times_for_damage_and_apply_vulnerable": "feast every kills, savagely bite times for damage and apply vulnerable", "feast_every_kills_your_next_core_skill_cast_deals_additional_damage": "feast every kills, your next core skill cast deals additional damage", "ferocity_potency": "ferocity potency", "fire_and_cold_damage": "fire and cold damage", "fire_damage": "fire damage", "fire_damage_multiplier": "fire damage multiplier", "fire_damage_ranks_of_the_inner_flames_passive": "fire damage ranks of the inner flames passive", "fire_lucky_hit_chance": "fire lucky hit chance", "fire_resistance": "fire resistance", "fireball_attack_speed": "fireball attack speed", "fireball_projectile_speed": "fireball projectile speed", "focus_cooldown_reduction": "focus cooldown reduction", "focus_damage": "focus damage", "fortify_generation": "fortify generation", "fortress_cooldown_reduction": "fortress cooldown reduction", "freeze_duration": "freeze duration", "frost_critical_strike_chance": "frost critical strike chance", "frost_damage": "frost damage", "frost_nova_cooldown_reduction": "frost nova cooldown reduction", "fury_cost_reduction": "fury cost reduction", "fury_on_kill": "fury on kill", "fury_regeneration": "fury regeneration", "gem_strength_in_this_item": "gem strength in this item", "gold_drop_rate": "gold drop rate", "golem_active_cooldown_reduction": "golem active cooldown reduction", "golem_damage": "golem damage", "golems_inherit_of_your_thorns": "golems inherit of your thorns", "gorilla_damage": "gorilla damage", "grenade_damage": "grenade damage", "grizzly_rage_cooldown_reduction": "grizzly rage cooldown reduction", "ground_stomp_cooldown_reduction": "ground stomp cooldown reduction", "ground_stomp_damage": "ground stomp damage", "hammer_of_the_ancients_damage_for_seconds_after_an_earthquake_explodes": "hammer of the ancients damage for seconds after an earthquake explodes", "healing_received": "healing received", "heavens_fury_cooldown_reduction": "heavens fury cooldown reduction", "hellfire_damage": "hellfire damage", "hewed_flesh_grants_maximum_life_as_barrier_for_seconds": "hewed flesh grants maximum life as barrier for seconds", "holy_bolt_resource_generation": "holy bolt resource generation", "holy_damage": "holy damage", "holy_damage_multiplier": "holy damage multiplier", "holy_light_aura_potency": "holy light aura potency", "human_damage": "human damage", "hunger_after_you_cast_a_basic_skill_chance_for_kill_to_your_kill_streak": "hunger after you cast a basic skill, chance for kill to your kill streak", "hunger_after_you_cast_a_cooldown_kill_to_your_kill_streak": "hunger after you cast a cooldown, kill to your kill streak", "hunger_after_you_kill_an_enemy_chance_for_kill_to_your_kill_streak": "hunger after you kill an enemy, chance for kill to your kill streak", "hunger_every_resource_chance_for_kill_to_your_kill_streak": "hunger every resource, chance for kill to your kill streak", "hunger_increased_chance_for_additional_gold_during_kill_streaks": "hunger increased chance for additional gold during kill streaks", "hunger_increased_chance_for_additional_salvage_materials_during_your_kill_streaks": "hunger increased chance for additional salvage materials during your kill streaks", "hunger_increased_chance_for_feast_items_during_your_kill_streaks": "hunger increased chance for feast items during your kill streaks", "hunger_increased_chance_for_hunger_items_during_kill_streaks": "hunger increased chance for hunger items during kill streaks", "hunger_increased_chance_for_rampage_items_during_kill_streaks": "hunger increased chance for rampage items during kill streaks", "hunger_increased_chance_for_runes_during_your_kill_streaks": "hunger increased chance for runes during your kill streaks", "hunger_increased_experience_from_kill_streaks": "hunger increased experience from kill streaks", "hunger_increased_reputation_from_kill_streaks": "hunger increased reputation from kill streaks", "hunger_lucky_hit_up_to_a_chance_for_kill_to_your_kill_streak": "hunger lucky hit up to a chance for kill to your kill streak", "hurricane_cooldown_reduction": "hurricane cooldown reduction", "hurricane_damage": "hurricane damage", "hydra_damage": "hydra damage", "hydra_lucky_hit_chance": "hydra lucky hit chance", "hydra_resource_cost_reduction": "hydra resource cost reduction", "ice_blades_cooldown_reduction": "ice blades cooldown reduction", "ice_blades_damage": "ice blades damage", "ice_blades_lucky_hit_chance": "ice blades lucky hit chance", "ice_spike_damage": "ice spike damage", "ice_spikes_freeze_enemies_for_seconds": "ice spikes freeze enemies for seconds", "imbued_critical_strike_damage": "imbued critical strike damage", "imbued_damage": "imbued damage", "imbuement_cooldown_reduction": "imbuement cooldown reduction", "imbuement_damage": "imbuement damage", "imbuement_potency": "imbuement potency", "immobilize_duration": "immobilize duration", "impairment_reduction": "impairment reduction", "incarnate_cooldown_reduction": "incarnate cooldown reduction", "indestructible": "indestructible", "inferno_cooldown_reduction": "inferno cooldown reduction", "inner_sight_duration": "inner sight duration", "intelligence": "intelligence", "invigorating_strike_energy_regeneration": "invigorating strike energy regeneration", "iron_maelstrom_cooldown_reduction": "iron maelstrom cooldown reduction", "iron_maiden_damage": "iron maiden damage", "iron_skin_cooldown_reduction": "iron skin cooldown reduction", "item_quality": "item quality", "jaguar_damage": "jaguar damage", "judicator_damage": "judicator damage", "juggernaut_damage": "juggernaut damage", "justice_cooldown_reduction": "justice cooldown reduction", "justice_damage": "justice damage", "kick_cooldown_reduction": "kick cooldown reduction", "kick_damage": "kick damage", "lacerate_cooldown_reduction": "lacerate cooldown reduction", "lacerate_damage": "lacerate damage", "leap_cooldown_reduction": "leap cooldown reduction", "leap_damage": "leap damage", "life_on_hit": "life on hit", "life_on_kill": "life on kill", "life_per_seconds": "life per seconds", "life_regeneration": "life regeneration", "life_steal": "life steal", "lightning_bolt_damage": "lightning bolt damage", "lightning_critical_strike_damage": "lightning critical strike damage", "lightning_damage": "lightning damage", "lightning_damage_multiplier": "lightning damage multiplier", "lightning_resistance": "lightning resistance", "lightning_spear_cooldown_reduction": "lightning spear cooldown reduction", "lightning_spear_damage": "lightning spear damage", "lightning_spear_lucky_hit_chance": "lightning spear lucky hit chance", "lucky_hit_chance": "lucky hit chance", "lucky_hit_chance_while_you_have_a_barrier": "lucky hit chance while you have a barrier", "lucky_hit_critical_strikes_have_up_to_a_chance_to_daze_for_seconds": "lucky hit critical strikes have up to a chance to daze for seconds", "lucky_hit_critical_strikes_have_up_to_a_chance_to_immobilize_for_seconds": "lucky hit critical strikes have up to a chance to immobilize for seconds", "lucky_hit_critical_strikes_have_up_to_a_chance_to_slow_for_seconds": "lucky hit critical strikes have up to a chance to slow for seconds", "lucky_hit_critical_strikes_have_up_to_a_chance_to_stun_for_seconds": "lucky hit critical strikes have up to a chance to stun for seconds", "lucky_hit_up_to_a_bleeding_damage_over_seconds": "lucky hit up to a bleeding damage over seconds", "lucky_hit_up_to_a_chance_to_apply_a_random_crowd_control_effect_for_seconds": "lucky hit up to a chance to apply a random crowd control effect for seconds", "lucky_hit_up_to_a_chance_to_become_berserking": "lucky hit up to a chance to become berserking", "lucky_hit_up_to_a_chance_to_chill_for_seconds": "lucky hit up to a chance to chill for seconds", "lucky_hit_up_to_a_chance_to_daze_for_seconds": "lucky hit up to a chance to daze for seconds", "lucky_hit_up_to_a_chance_to_deal_cold_damage": "lucky hit up to a chance to deal cold damage", "lucky_hit_up_to_a_chance_to_deal_fire_damage": "lucky hit up to a chance to deal fire damage", "lucky_hit_up_to_a_chance_to_deal_holy_damage": "lucky hit up to a chance to deal holy damage", "lucky_hit_up_to_a_chance_to_deal_lightning_damage": "lucky hit up to a chance to deal lightning damage", "lucky_hit_up_to_a_chance_to_deal_physical_damage": "lucky hit up to a chance to deal physical damage", "lucky_hit_up_to_a_chance_to_deal_poison_damage": "lucky hit up to a chance to deal poison damage", "lucky_hit_up_to_a_chance_to_deal_shadow_damage": "lucky hit up to a chance to deal shadow damage", "lucky_hit_up_to_a_chance_to_execute_injured_nonelites": "lucky hit up to a chance to execute injured nonelites", "lucky_hit_up_to_a_chance_to_fear_for_seconds": "lucky hit up to a chance to fear for seconds", "lucky_hit_up_to_a_chance_to_freeze_for_seconds": "lucky hit up to a chance to freeze for seconds", "lucky_hit_up_to_a_chance_to_gain_a_stack_of_frenzy": "lucky hit up to a chance to gain a stack of frenzy", "lucky_hit_up_to_a_chance_to_gain_damage_for_seconds": "lucky hit up to a chance to gain damage for seconds", "lucky_hit_up_to_a_chance_to_heal_life": "lucky hit up to a chance to heal life", "lucky_hit_up_to_a_chance_to_immobilize_for_seconds": "lucky hit up to a chance to immobilize for seconds", "lucky_hit_up_to_a_chance_to_knockback_for_seconds": "lucky hit up to a chance to knockback for seconds", "lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds": "lucky hit up to a chance to make enemies vulnerable for seconds", "lucky_hit_up_to_a_chance_to_restore_primary_resource": "lucky hit up to a chance to restore primary resource", "lucky_hit_up_to_a_chance_to_slow_for_seconds": "lucky hit up to a chance to slow for seconds", "lucky_hit_up_to_a_chance_to_stun_for_seconds": "lucky hit up to a chance to stun for seconds", "lucky_hit_up_to_a_chance_to_taunt_for_seconds": "lucky hit up to a chance to taunt for seconds", "lucky_hit_up_to_a_chance_to_weaken_for_seconds": "lucky hit up to a chance to weaken for seconds", "lucky_hit_up_to_a_damage_for_seconds": "lucky hit up to a damage for seconds", "lunging_strike_healing": "lunging strike healing", "macabre_damage": "macabre damage", "main_hand_weapon_damage": "main hand weapon damage", "mana_cost_reduction": "mana cost reduction", "mana_on_kill": "mana on kill", "mana_regeneration": "mana regeneration", "marksman_attack_speed_per_precison_stack": "marksman attack speed per precison stack", "marksman_critical_strike_chance": "marksman critical strike chance", "marksman_critical_strike_damage": "marksman critical strike damage", "marksman_damage": "marksman damage", "mastery_damage": "mastery damage", "maximum_energy": "maximum energy", "maximum_essence": "maximum essence", "maximum_evade_charges": "maximum evade charges", "maximum_fury": "maximum fury", "maximum_life": "maximum life", "maximum_mana": "maximum mana", "maximum_poison_traps": "maximum poison traps", "maximum_resolve_stacks": "maximum resolve stacks", "maximum_resource": "maximum resource", "maximum_spirit": "maximum spirit", "maximum_summon_life": "maximum summon life", "maximum_vigor": "maximum vigor", "meteor_size": "meteor size", "minions_inherit_of_your_thorns": "minions inherit of your thorns", "mobility_cooldown_reduction": "mobility cooldown reduction", "mobility_damage": "mobility damage", "mobility_skills_grant_movement_speed_for_seconds": "mobility skills grant movement speed for seconds", "movement_speed": "movement speed", "movement_speed_for_seconds_after_killing_an_elite": "movement speed for seconds after killing an elite", "movement_speed_for_seconds_after_killing_an_enemy": "movement speed for seconds after killing an enemy", "movement_speed_for_seconds_after_picking_up_crackling_energy": "movement speed for seconds after picking up crackling energy", "movement_speed_while_berserking": "movement speed while berserking", "movement_speed_while_cataclysm_is_active": "movement speed while cataclysm is active", "movement_speed_while_hurricane_is_active": "movement speed while hurricane is active", "movement_speed_while_in_human_form": "movement speed while in human form", "movement_speed_while_shapeshifted_into_a_werewolf": "movement speed while shapeshifted into a werewolf", "movement_speed_while_the_inner_sight_gauge_is_full": "movement speed while the inner sight gauge is full", "mystic_circle_potency": "mystic circle potency", "nature_magic_cooldown_reduction": "nature magic cooldown reduction", "nature_magic_skill_cooldown_reduction": "nature magic skill cooldown reduction", "nonphysical_damage": "nonphysical damage", "occult_damage": "occult damage", "overpower_chance": "overpower chance", "overpower_critical_damage": "overpower critical damage", "pestilent_swarm_damage": "pestilent swarm damage", "petrify_cooldown_reduction": "petrify cooldown reduction", "physical_critical_strike_chance_against_elites": "physical critical strike chance against elites", "physical_damage": "physical damage", "physical_damage_multiplier": "physical damage multiplier", "physical_resistance": "physical resistance", "pickup_radius": "pickup radius", "poison_creeper_cooldown_reduction": "poison creeper cooldown reduction", "poison_creeper_damage": "poison creeper damage", "poison_damage": "poison damage", "poison_damage_multiplier": "poison damage multiplier", "poison_damage_over_time_duration": "poison damage over time duration", "poison_resistance": "poison resistance", "poison_trap_cooldown_reduction": "poison trap cooldown reduction", "poisoning_damage": "poisoning damage", "potency_cooldown_reduction": "potency cooldown reduction", "potency_damage": "potency damage", "potion_capacity": "potion capacity", "potion_drop_rate": "potion drop rate", "potion_healing": "potion healing", "primary_centipede_spirit_hall_damage": "primary centipede spirit hall damage", "primary_eagle_spirit_hall_damage": "primary eagle spirit hall damage", "primary_gorilla_spirit_hall_damage": "primary gorilla spirit hall damage", "primary_jaguar_spirit_hall_damage": "primary jaguar spirit hall damage", "puncture_resource_generation": "puncture resource generation", "purify_cooldown_reduction": "purify cooldown reduction", "pyromancy_attack_speed": "pyromancy attack speed", "pyromancy_critical_strike_damage": "pyromancy critical strike damage", "pyromancy_damage": "pyromancy damage", "rabies_cooldown_reduction": "rabies cooldown reduction", "rabies_damage": "rabies damage", "rain_of_arrows_cooldown_reduction": "rain of arrows cooldown reduction", "rain_of_arrows_damage": "rain of arrows damage", "rain_of_arrows_skill_cooldown_reduction": "rain of arrows skill cooldown reduction", "rampage_attack_speed_per_kill_streak_tier": "rampage attack speed per kill streak tier", "rampage_cooldown_reduction_per_kill_streak_tier": "rampage cooldown reduction per kill streak tier", "rampage_critical_strike_chance_per_kill_streak_tier": "rampage critical strike chance per kill streak tier", "rampage_dexterity_per_kill_streak_tier": "rampage dexterity per kill streak tier", "rampage_intelligence_per_kill_streak_tier": "rampage intelligence per kill streak tier", "rampage_life_on_hit_per_kill_streak_tier": "rampage life on hit per kill streak tier", "rampage_lucky_hit_chance_per_kill_streak_tier": "rampage lucky hit chance per kill streak tier", "rampage_maximum_life_per_kill_streak_tier": "rampage maximum life per kill streak tier", "rampage_movement_speed_per_kill_streak_tier": "rampage movement speed per kill streak tier", "rampage_resource_cost_reduction_per_kill_streak_tier": "rampage resource cost reduction per kill streak tier", "rampage_strength_per_kill_streak_tier": "rampage strength per kill streak tier", "rampage_willpower_per_kill_streak_tier": "rampage willpower per kill streak tier", "rank_of_all_agility_skills": "rank of all agility skills", "ranks_of_the_aggressive_resistance_passive": "ranks of the aggressive resistance passive", "ranks_of_the_concussive_passive": "ranks of the concussive passive", "ranks_of_the_heightened_senses_passive": "ranks of the heightened senses passive", "ranks_of_the_hewed_flesh_passive": "ranks of the hewed flesh passive", "ravager_on_kill_duration_extension": "ravager on kill duration extension", "ravens_attack_speed": "ravens attack speed", "ravens_cooldown_reduction": "ravens cooldown reduction", "ravens_damage": "ravens damage", "razor_wings_charges": "razor wings charges", "resistance_to_all_elements": "resistance to all elements", "resolve_generated": "resolve generated", "resource_cost_reduction": "resource cost reduction", "resource_generation": "resource generation", "resource_generation_and_maximum": "resource generation and maximum", "resource_generation_while_wielding_a_scythe": "resource generation while wielding a scythe", "resource_generation_while_wielding_a_shield": "resource generation while wielding a shield", "resource_generation_with_dualwielded_weapons": "resource generation with dualwielded weapons", "resource_generation_with_polearms": "resource generation with polearms", "resource_generation_with_twohanded_bludgeoning_weapons": "resource generation with twohanded bludgeoning weapons", "resource_generation_with_twohanded_slashing_weapons": "resource generation with twohanded slashing weapons", "resource_generation_with_twohanded_weapons": "resource generation with twohanded weapons", "resource_on_hit": "resource on hit", "resource_regeneration": "resource regeneration", "rock_splitter_resource_generation": "rock splitter resource generation", "rupture_cooldown_reduction": "rupture cooldown reduction", "rupture_damage": "rupture damage", "rushing_claw_charges": "rushing claw charges", "scourge_poisoning_duration": "scourge poisoning duration", "sever_size": "sever size", "shade_damage": "shade damage", "shadow_clone_cooldown_reduction": "shadow clone cooldown reduction", "shadow_clone_damage": "shadow clone damage", "shadow_clones_execute_injured_nonelite_enemies": "shadow clones execute injured nonelite enemies", "shadow_damage": "shadow damage", "shadow_damage_multiplier": "shadow damage multiplier", "shadow_lucky_hit_chance": "shadow lucky hit chance", "shadow_resistance": "shadow resistance", "shadow_step_cooldown_reduction": "shadow step cooldown reduction", "shadow_step_damage": "shadow step damage", "shapeshifting_attack_speed": "shapeshifting attack speed", "shield_charge_cooldown_reduction": "shield charge cooldown reduction", "shock_critical_strike_chance": "shock critical strike chance", "shock_critical_strike_damage": "shock critical strike damage", "shock_damage": "shock damage", "shout_cooldown_reduction": "shout cooldown reduction", "shred_critical_strike_chance": "shred critical strike chance", "shrine_buff_duration": "shrine buff duration", "sigil_damage": "sigil damage", "sigil_duration": "sigil duration", "skeletal_mages_inherit_of_your_thorns": "skeletal mages inherit of your thorns", "skeletal_warriors_inherit_of_your_thorns": "skeletal warriors inherit of your thorns", "skeleton_mage_damage": "skeleton mage damage", "slow_duration_reduction": "slow duration reduction", "smoke_grenade_cooldown_reduction": "smoke grenade cooldown reduction", "smoke_grenade_damage": "smoke grenade damage", "soar_cooldown_reduction": "soar cooldown reduction", "soar_deals_up_to_damage_based_on_distance_traveled": "soar deals up to damage based on distance traveled", "soar_grants_maximum_life_as_barrier_for_seconds": "soar grants maximum life as barrier for seconds", "spirit_cost_reduction": "spirit cost reduction", "spirit_on_kill": "spirit on kill", "spirit_regeneration": "spirit regeneration", "steel_grasp_cooldown_reduction": "steel grasp cooldown reduction", "steel_grasp_damage": "steel grasp damage", "steel_grasp_stuns_for_seconds": "steel grasp stuns for seconds", "storm_cooldown_reduction": "storm cooldown reduction", "storm_critical_strike_chance": "storm critical strike chance", "storm_damage": "storm damage", "storm_feather_potency": "storm feather potency", "storm_strike_chains_to_targets": "storm strike chains to targets", "strength": "strength", "stun_duration": "stun duration", "stun_grenade_damage": "stun grenade damage", "stun_grenade_size": "stun grenade size", "subterfuge_cooldown_reduction": "subterfuge cooldown reduction", "summon_attack_speed": "summon attack speed", "summon_damage": "summon damage", "summon_movement_speed": "summon movement speed", "teleport_cooldown_reduction": "teleport cooldown reduction", "teleport_damage": "teleport damage", "the_devourer_cooldown_reduction": "the devourer cooldown reduction", "the_hunter_cooldown_reduction": "the hunter cooldown reduction", "the_protector_cooldown_reduction": "the protector cooldown reduction", "the_seeker_charges": "the seeker charges", "the_seeker_cooldown_reduction": "the seeker cooldown reduction", "thorns": "thorns", "thorns_while_fortified": "thorns while fortified", "thrash_resource_generation": "thrash resource generation", "thunderspike_resource_generation": "thunderspike resource generation", "to_abyss_skills": "to abyss skills", "to_advance": "to advance", "to_aegis": "to aegis", "to_agility_skills": "to agility skills", "to_all_skills": "to all skills", "to_ancient_skills": "to ancient skills", "to_arc_lash": "to arc lash", "to_archfiend_skills": "to archfiend skills", "to_armored_hide": "to armored hide", "to_arrow_storm_skills": "to arrow storm skills", "to_aura_skills": "to aura skills", "to_ball_lightning": "to ball lightning", "to_barrage": "to barrage", "to_bash": "to bash", "to_basic_skills": "to basic skills", "to_blade_shift": "to blade shift", "to_blazing_scream": "to blazing scream", "to_blessed_hammer": "to blessed hammer", "to_blessed_shield": "to blessed shield", "to_blight": "to blight", "to_blizzard": "to blizzard", "to_blood_howl": "to blood howl", "to_blood_lance": "to blood lance", "to_blood_mist": "to blood mist", "to_blood_skills": "to blood skills", "to_blood_surge": "to blood surge", "to_bombardment": "to bombardment", "to_bone_prison": "to bone prison", "to_bone_skills": "to bone skills", "to_bone_spear": "to bone spear", "to_bone_spirit": "to bone spirit", "to_bone_splinters": "to bone splinters", "to_boulder": "to boulder", "to_brandish": "to brandish", "to_brawling_skills": "to brawling skills", "to_caltrops": "to caltrops", "to_centipede_skills": "to centipede skills", "to_chain_lightning": "to chain lightning", "to_challenging_shout": "to challenging shout", "to_charge": "to charge", "to_charged_bolts": "to charged bolts", "to_clash": "to clash", "to_claw": "to claw", "to_cold_imbuement": "to cold imbuement", "to_combat_skills": "to combat skills", "to_command_abodian": "to command abodian", "to_command_aegrom": "to command aegrom", "to_command_fallen": "to command fallen", "to_command_laalish": "to command laalish", "to_command_valloch": "to command valloch", "to_companion_skills": "to companion skills", "to_concealment": "to concealment", "to_concussive_stomp": "to concussive stomp", "to_condemn": "to condemn", "to_conjuration_skills": "to conjuration skills", "to_consecration": "to consecration", "to_core_skills": "to core skills", "to_corpse_explosion": "to corpse explosion", "to_corpse_skills": "to corpse skills", "to_corpse_tendrils": "to corpse tendrils", "to_counterattack": "to counterattack", "to_crushing_hand": "to crushing hand", "to_curse_skills": "to curse skills", "to_cutthroat_skills": "to cutthroat skills", "to_cyclone_armor": "to cyclone armor", "to_dance_of_knives": "to dance of knives", "to_dark_prison": "to dark prison", "to_dark_shroud": "to dark shroud", "to_darkness_skills": "to darkness skills", "to_dash": "to dash", "to_death_blow": "to death blow", "to_deaths_reach": "to deaths reach", "to_debilitating_roar": "to debilitating roar", "to_decompose": "to decompose", "to_decrepify": "to decrepify", "to_defensive_skills": "to defensive skills", "to_defiance_aura": "to defiance aura", "to_demonology_skills": "to demonology skills", "to_disciple_skills": "to disciple skills", "to_divine_lance": "to divine lance", "to_doom": "to doom", "to_double_swing": "to double swing", "to_dread_claws": "to dread claws", "to_dust_devil_skills": "to dust devil skills", "to_eagle_skills": "to eagle skills", "to_earth_skills": "to earth skills", "to_earth_spike": "to earth spike", "to_earthen_bulwark": "to earthen bulwark", "to_earthquake_skills": "to earthquake skills", "to_falling_star": "to falling star", "to_familiar": "to familiar", "to_fanaticism_aura": "to fanaticism aura", "to_fire_bolt": "to fire bolt", "to_fireball": "to fireball", "to_firewall": "to firewall", "to_flame_shield": "to flame shield", "to_flay": "to flay", "to_flurry": "to flurry", "to_focus_skills": "to focus skills", "to_forceful_arrow": "to forceful arrow", "to_frenzy": "to frenzy", "to_frost_bolt": "to frost bolt", "to_frost_nova": "to frost nova", "to_frost_skills": "to frost skills", "to_frozen_orb": "to frozen orb", "to_golem": "to golem", "to_gorilla_skills": "to gorilla skills", "to_grenade_skills": "to grenade skills", "to_ground_stomp": "to ground stomp", "to_hammer_of_the_ancients": "to hammer of the ancients", "to_heartseeker": "to heartseeker", "to_hell_fracture": "to hell fracture", "to_hellfire_skills": "to hellfire skills", "to_hellion_sting": "to hellion sting", "to_hemorrhage": "to hemorrhage", "to_holy_bolt": "to holy bolt", "to_holy_light_aura": "to holy light aura", "to_human_skills": "to human skills", "to_hurricane": "to hurricane", "to_hydra": "to hydra", "to_ice_armor": "to ice armor", "to_ice_blades": "to ice blades", "to_ice_shards": "to ice shards", "to_imbuement_skills": "to imbuement skills", "to_incinerate": "to incinerate", "to_infernal_breath": "to infernal breath", "to_invigorating_strike": "to invigorating strike", "to_iron_maiden": "to iron maiden", "to_iron_shrapnel_skills": "to iron shrapnel skills", "to_iron_skin": "to iron skin", "to_jaguar_skills": "to jaguar skills", "to_judicator_skills": "to judicator skills", "to_juggernaut_skills": "to juggernaut skills", "to_justice_skills": "to justice skills", "to_kick": "to kick", "to_landslide": "to landslide", "to_leap": "to leap", "to_lightning_spear": "to lightning spear", "to_lightning_storm": "to lightning storm", "to_lunging_strike": "to lunging strike", "to_macabre_skills": "to macabre skills", "to_marksman_and_cutthroat_skills": "to marksman and cutthroat skills", "to_marksman_skills": "to marksman skills", "to_martial_skills": "to martial skills", "to_mastery_skills": "to mastery skills", "to_maul": "to maul", "to_meteor": "to meteor", "to_mighty_throw": "to mighty throw", "to_minion_skills": "to minion skills", "to_mobility_skills": "to mobility skills", "to_molten_bomb": "to molten bomb", "to_nature_magic_skills": "to nature magic skills", "to_nether_step": "to nether step", "to_occult_skills": "to occult skills", "to_payback": "to payback", "to_penetrating_shot": "to penetrating shot", "to_poison_creeper": "to poison creeper", "to_poison_imbuement": "to poison imbuement", "to_poison_trap": "to poison trap", "to_potency_skills": "to potency skills", "to_prime_bone_storms_damage_reduction": "to prime bone storms damage reduction", "to_profane_sentinel": "to profane sentinel", "to_pulverize": "to pulverize", "to_puncture": "to puncture", "to_purify": "to purify", "to_pyromancy_skills": "to pyromancy skills", "to_quill_volley": "to quill volley", "to_rabies": "to rabies", "to_rake": "to rake", "to_rally": "to rally", "to_rallying_cry": "to rallying cry", "to_rampage": "to rampage", "to_rapid_fire": "to rapid fire", "to_ravager": "to ravager", "to_ravens": "to ravens", "to_razor_wings": "to razor wings", "to_reap": "to reap", "to_rend": "to rend", "to_rock_splitter": "to rock splitter", "to_rupture": "to rupture", "to_rushing_claw": "to rushing claw", "to_scourge": "to scourge", "to_sever": "to sever", "to_shade_skills": "to shade skills", "to_shadow_imbuement": "to shadow imbuement", "to_shadow_step": "to shadow step", "to_shapeshifting_skills": "to shapeshifting skills", "to_shield_bash": "to shield bash", "to_shield_charge": "to shield charge", "to_shock_skills": "to shock skills", "to_shred": "to shred", "to_sigil_of_chaos": "to sigil of chaos", "to_sigil_of_subversion": "to sigil of subversion", "to_sigil_of_summons": "to sigil of summons", "to_sigil_skills": "to sigil skills", "to_skeletal_mage_mastery": "to skeletal mage mastery", "to_skeleton_mage": "to skeleton mage", "to_skeleton_warrior": "to skeleton warrior", "to_slashing_skills": "to slashing skills", "to_smoke_grenade": "to smoke grenade", "to_soar": "to soar", "to_soul_shard_skills": "to soul shard skills", "to_soulrift": "to soulrift", "to_spark": "to spark", "to_spear_of_the_heavens": "to spear of the heavens", "to_steel_grasp": "to steel grasp", "to_steel_grasp_cold_imbuement_frost_hurricane_or_skeletal_mage_mastery": "to steel grasp, cold imbuement, frost, hurricane, or skeletal mage mastery", "to_stinger": "to stinger", "to_stone_burst": "to stone burst", "to_storm_skills": "to storm skills", "to_storm_strike": "to storm strike", "to_subterfuge_skills": "to subterfuge skills", "to_teleport": "to teleport", "to_the_pack_leader_spirit_boons_lucky_hit_chance": "to the pack leader spirit boons lucky hit chance", "to_thrash": "to thrash", "to_thunderspike": "to thunderspike", "to_tornado": "to tornado", "to_tortured_wretch": "to tortured wretch", "to_touch_of_death": "to touch of death", "to_toxic_skin": "to toxic skin", "to_trample": "to trample", "to_trap_skills": "to trap skills", "to_twisting_blades": "to twisting blades", "to_tyrants_grasp": "to tyrants grasp", "to_ultimate_skills": "to ultimate skills", "to_umbral_chains": "to umbral chains", "to_upheaval": "to upheaval", "to_upheaval_cutthroat_pyromancy_earth_or_blood": "to upheaval, cutthroat, pyromancy, earth, or blood", "to_valor_skills": "to valor skills", "to_versatile_skills": "to versatile skills", "to_vortex": "to vortex", "to_wall_of_agony": "to wall of agony", "to_war_cry": "to war cry", "to_weapon_mastery_skills": "to weapon mastery skills", "to_werebear_skills": "to werebear skills", "to_werewolf_skills": "to werewolf skills", "to_whirlwind": "to whirlwind", "to_wind_shear": "to wind shear", "to_withering_fist": "to withering fist", "to_wolves": "to wolves", "to_wrath_skills": "to wrath skills", "to_zeal": "to zeal", "to_zealot_skills": "to zealot skills", "total_armor": "total armor", "total_armor_while_in_werebear_form": "total armor while in werebear form", "total_armor_while_in_werewolf_form": "total armor while in werewolf form", "total_bonus_experience": "total bonus experience", "trample_cooldown_reduction": "trample cooldown reduction", "trample_damage": "trample damage", "trap_cooldown_reduction": "trap cooldown reduction", "trap_damage": "trap damage", "traps_arm_seconds_faster": "traps arm seconds faster", "twisting_blades_returns_faster": "twisting blades returns faster", "ultimate_cooldown_reduction": "ultimate cooldown reduction", "ultimate_damage": "ultimate damage", "unstable_currents_cooldown_reduction": "unstable currents cooldown reduction", "upheaval_overpowers_stun_for_seconds": "upheaval overpowers stun for seconds", "valor_cooldown_reduction": "valor cooldown reduction", "versatile_damage": "versatile damage", "vigor_cost_reduction": "vigor cost reduction", "vigor_on_kill": "vigor on kill", "vigor_regeneration": "vigor regeneration", "vigor_when_resolve_is_lost": "vigor when resolve is lost", "vulnerable_damage": "vulnerable damage", "vulnerable_damage_multiplier": "vulnerable damage multiplier", "war_cry_cooldown_reduction": "war cry cooldown reduction", "weapon_damage": "weapon damage", "weapon_mastery_attack_speed": "weapon mastery attack speed", "weapon_mastery_cooldown_reduction": "weapon mastery cooldown reduction", "weapon_mastery_damage": "weapon mastery damage", "werebear_damage": "werebear damage", "werewolf_attack_speed": "werewolf attack speed", "werewolf_critical_strike_chance": "werewolf critical strike chance", "werewolf_critical_strike_damage": "werewolf critical strike damage", "werewolf_damage": "werewolf damage", "while_injured_your_potion_also_grants_maximum_life_as_barrier": "while injured, your potion also grants maximum life as barrier", "while_injured_your_potion_also_grants_movement_speed_for_seconds": "while injured, your potion also grants movement speed for seconds", "while_injured_your_potion_also_restores_resource": "while injured, your potion also restores resource", "willpower": "willpower", "wing_strike_damage": "wing strike damage", "withering_fist_resource_generation": "withering fist resource generation", "wolves_attack_speed": "wolves attack speed", "wolves_cooldown_reduction": "wolves cooldown reduction", "wolves_damage": "wolves damage", "wrath_every_kills": "wrath every kills", "wrath_of_the_berserker_cooldown_reduction": "wrath of the berserker cooldown reduction", "wrath_regeneration": "wrath regeneration", "your_trap_skills_are_also_considered_core_skills": "your trap skills are also considered core skills", "zealot_critical_strike_chance": "zealot critical strike chance", "zealot_critical_strike_damage": "zealot critical strike damage", "zealot_damage": "zealot damage", "zenith_cooldown_reduction": "zenith cooldown reduction" } ================================================ FILE: assets/lang/enUS/aspects.json ================================================ [ "accelerating", "aggressive", "agile", "aphotic", "apostles", "archdruids", "assimilation", "balanced", "ballistic", "bane-link", "battle-mad", "battle-mad", "battle_casters", "battle_casters", "battle_fervors", "bear_clan_berserkers", "bladedancers", "blast-trappers", "blast-trappers", "blasting", "blood_boiling", "blood_boiling", "blood_getters", "bold_chieftains", "bone_breakers", "brawlers", "breakneck_bandits", "bristleback", "bruisers", "brutal", "bulwarks", "bulwarks", "cadaverous", "charged", "cheats", "clandestine", "coldbringers", "coldclip", "conceited", "conjuration_masters", "crashstone", "craven", "cremators", "crushing", "cut_to_the_bone", "deadeyes", "death_wish", "demonic", "devilish", "duelists", "duelists", "dust_devils", "earthstrikers", "earthstrikers", "edgemasters", "elementalists", "eluding", "embattled", "encased", "encased", "energizing", "enfeebling", "enshrouding", "envenomed", "escape_artists", "everliving", "everliving", "executioners", "exploiters", "fastblood", "fell_soothsayers", "ferocious", "firestarter", "flamethrowers", "flamewalkers", "flash_fire", "frostbitten", "frostblitz", "galvanic", "galvanized_slashers", "ghostwalker", "glacial", "gorefeast", "gravitational", "great_storm", "grenadiers", "heavy_hitting", "hectic", "hellbent_commander", "high_velocity", "high_velocity", "hulking", "icy_alchemists", "icy_alchemists", "impairing", "incendiary", "infiltrators", "insatiable", "insidious", "inspiring_leader", "iron_blood", "irrepressible", "jolting", "joltkeepers", "juggernauts", "lightning_dancers", "lightning_rod", "lightning_rod", "lingering", "lord_of_bloods", "lord_of_bloods", "luckbringer", "mage-lords", "mage-lords", "malicious", "mangled", "manglers", "menacing", "methodical", "mighty_storms", "mired_sharpshooters", "misanthropic", "moonrage", "natures_reach", "necrotic_carapace", "needleflare", "nefarious", "neurotoxic", "nightstalkers", "obstinate", "of_abundant_energy", "of_accursed_touch", "of_adaptability", "of_aftermath", "of_akarats_blessing", "of_alacrity", "of_alchemical_advantage", "of_amplified_damage", "of_ancestral_charge", "of_ancestral_echoes", "of_ancestral_force", "of_ancient_flame", "of_ancient_flame", "of_anemia", "of_angelic_masterwork", "of_anger_management", "of_anger_management", "of_anticline_burst", "of_apogeic_furor", "of_apogeic_furor", "of_apprehension", "of_arcane_ward", "of_armageddon", "of_armageddon", "of_arrogance", "of_arrow_storms", "of_artful_initiative", "of_ascension", "of_assistance", "of_audacity", "of_authority", "of_avoidance", "of_barbed_roses", "of_berserk_fury", "of_berserk_ripping", "of_binding_embers", "of_binding_morass", "of_biting_cold", "of_biting_cold", "of_bitter_infection", "of_booming_voice", "of_bristling_vengeance", "of_bul-kathos", "of_burning_rage", "of_bursting_bones", "of_bursting_venoms", "of_calamity", "of_cauterization", "of_celestial_strife", "of_channeling", "of_charged_flash", "of_chastisement", "of_coagulation", "of_coalesced_blood", "of_cold_judgement", "of_combined_strikes", "of_combustion", "of_compound_fracture", "of_concentration", "of_concussive_blend", "of_concussive_strikes", "of_conflagration", "of_contamination", "of_contemplation", "of_corruption", "of_creeping_cadaver", "of_creeping_death", "of_crippling_darkness", "of_dazzling_light", "of_death_chill", "of_deaths_defense", "of_debilitating_darkness", "of_debilitating_darkness", "of_debilitating_toxins", "of_decay", "of_dedication", "of_deeper_shadows", "of_deflection", "of_delayed_extinction", "of_deluge", "of_diabolical_armor", "of_disobedience", "of_dominance", "of_dominance", "of_earthquakes", "of_efficiency", "of_electrified_claws", "of_elemental_acuity", "of_elemental_attunement", "of_elemental_constellation", "of_elemental_constellation", "of_elemental_fate", "of_elusive_menace", "of_elusive_menace", "of_empowered_feathers", "of_encircling_blades", "of_encroaching_wrath", "of_endless_fury", "of_endless_talons", "of_endurance", "of_engulfing_flames", "of_entrapment", "of_excellence", "of_excellence", "of_exhilaration", "of_exorcism", "of_explosive_verve", "of_explosive_verve", "of_exposed_flesh", "of_falling_feathers", "of_falling_feathers", "of_fathomless_dark", "of_fevered_mauling", "of_fiendish_oppression", "of_fierce_winds", "of_finality", "of_firm_decree", "of_fleet_wings", "of_forest_power", "of_fortune", "of_forward_momentum", "of_frenzied_onslaught", "of_frosty_strides", "of_frozen_memories", "of_frozen_memories", "of_frozen_orbit", "of_furious_impulse", "of_giant_strides", "of_gloom", "of_glynns_anvil", "of_gore_quills", "of_grasping_whirlwind", "of_grim_prognosis", "of_guttural_yell", "of_hales_salve", "of_hardened_bones", "of_haste", "of_heavenly_strength", "of_herculean_spectacle", "of_heresy", "of_hewed_flesh", "of_hewed_flesh", "of_hit_and_run", "of_holy_cadence", "of_holy_punishment", "of_human_ingenuity", "of_ignition", "of_imitated_imbuement", "of_immolation", "of_impending_deluge", "of_impetus", "of_incendiary_fissures", "of_inevitable_fate", "of_infestation", "of_inner_calm", "of_innervation", "of_interdiction", "of_interdiction", "of_intricacy", "of_invigorating_will", "of_iron_rain", "of_iron_rain", "of_jacques_fervor", "of_kinetic_suppression", "of_lageras_sovereignty", "of_lapas_scripture", "of_lava", "of_layered_wards", "of_lethal_dusk", "of_limitless_rage", "of_loyalty", "of_malevolence", "of_malice", "of_mending_obscurity", "of_mending_stone", "of_merciless_cold", "of_metamorphosis", "of_might", "of_militance", "of_minds_awakening", "of_misfortune", "of_momentum", "of_mutilation", "of_natural_balance", "of_natural_defenses", "of_natural_instincts", "of_natural_selection", "of_nebulous_brews", "of_noxious_ice", "of_numbing_wrath", "of_overheating", "of_overwhelming_currents", "of_overwhelming_currents", "of_peril", "of_perpetual_stomping", "of_pestilence", "of_pestilent_points", "of_piercing_cold", "of_piercing_cold", "of_piercing_static", "of_piercing_static", "of_pilgrims_progress", "of_plains_power", "of_poisonous_clouds", "of_poisonous_clouds", "of_potent_blood", "of_potent_exchange", "of_prolific_fury", "of_proselytizing", "of_putrefaction", "of_quickening_fog", "of_quicksand", "of_rallying_reversal", "of_rapid_ossification", "of_rathmas_chosen", "of_reactive_armor", "of_reanimation", "of_recalling_feathers", "of_recalling_feathers", "of_redirected_force", "of_refutation", "of_retaliation", "of_retribution", "of_retribution", "of_righteous_rage", "of_ritual_synthesis", "of_salvation", "of_scorching_heat", "of_scorn", "of_searing_impact", "of_searing_wards", "of_serration", "of_shared_misery", "of_shattered_stars", "of_shattering_steel", "of_shelter", "of_shielding_bones", "of_shielding_bones", "of_shredding_blades", "of_shredding_blades", "of_simple_reprisal", "of_singed_extremities", "of_siphoned_victuals", "of_siphoning_strikes", "of_sky_power", "of_slaughter", "of_sly_steps", "of_soil_power", "of_spiked_armor", "of_splintering_energy", "of_splintering_energy", "of_splintering_shards", "of_stolen_vigor", "of_sundered_ground", "of_supremacy", "of_surprise", "of_swift_spirit", "of_synergy", "of_synergy", "of_target_practice", "of_tempering_blows", "of_temporal_incisions", "of_tenacity", "of_tenuous_agility", "of_tenuous_survival", "of_terror", "of_the_agile_wolf", "of_the_arbiters_zephyr", "of_the_bounding_conduit", "of_the_calm_breeze", "of_the_calm_breeze", "of_the_changelings_debt", "of_the_crowded_sage", "of_the_cursed_aura", "of_the_damned", "of_the_dark_dance", "of_the_dark_howl", "of_the_deflecting_barrier", "of_the_dire_whirlwind", "of_the_disciple", "of_the_disciple", "of_the_embalmer", "of_the_embalmer", "of_the_enchanter", "of_the_expectant", "of_the_firebird", "of_the_flaming_rampage", "of_the_followed_path", "of_the_fortress", "of_the_frozen_tundra", "of_the_frozen_wake", "of_the_frozen_wake", "of_the_golden_hour", "of_the_great_feast", "of_the_indomitable", "of_the_iron_warrior", "of_the_judicator", "of_the_juggernauts_covenant", "of_the_lights_mending", "of_the_lights_mending", "of_the_long_shadow", "of_the_moonrise", "of_the_northern_guard", "of_the_orange_herald", "of_the_orange_herald", "of_the_pack_alpha", "of_the_protector", "of_the_prudent_heart", "of_the_rabid_beast", "of_the_relentless_armsmaster", "of_the_rushing_wilds", "of_the_shapeshifter", "of_the_solitary_shadow", "of_the_stampede", "of_the_umbral", "of_the_unbroken_tether", "of_the_unholy_confederate", "of_the_unleashed_beast", "of_the_unsatiated", "of_the_untarnished_blaze", "of_the_unwavering", "of_the_ursine_horror", "of_the_valintyr", "of_the_void", "of_the_warpath", "of_the_wildrage", "of_the_wildrage", "of_the_zealots_covenant", "of_thickened_blood", "of_torment", "of_transfusion", "of_true_sight", "of_turbulence", "of_tyraels_jurisdiction", "of_ultimate_shadow", "of_uncanny_treachery", "of_unnatural_movement", "of_unstable_imbuements", "of_unstoppable_force", "of_untimely_death", "of_unyielding_hits", "of_utmost_glory", "of_valiance", "of_verdant_restoration", "of_vocalized_empowerment", "of_volatile_shadows", "of_voracious_rage", "of_walloping", "of_warmth", "of_watkins_law", "of_wild_claws", "of_wolfs_rain", "ominous", "opportunists", "osseous_gale", "overcharged", "overcharged", "overheating", "overwhelming", "perforators", "powershifting", "prepared_assailants", "prodigys", "progenitors", "protecting", "pyroclastic", "raid_leaders", "raiders", "rangers", "rapid", "ravenous", "raw_might", "raw_might", "reapers", "rebounding", "recharging", "recharging", "rejuvenating", "relentless_berserkers", "remorseless", "requiem", "resistant_assailants", "revelators", "rip_and_tear", "rotting", "ruthless", "sacrificial", "sadistic", "sapping", "scornful", "seismic-shift", "serpentine", "shadow-soaked", "shadowslicer", "shard_of_dawn", "shattered", "shattered", "shepherds", "shifters", "shivering", "sickfoots", "skinwalkers", "skullbreakers", "slaking", "smiting", "snap_frozen", "snowguards", "snowveiled", "snowveiled", "spirit_bond", "splintering", "squires", "stable", "starlight", "starving_ravagers", "steadfast_berserkers", "steadfast_berserkers", "sticker-thought", "stoneworkers", "storm_splitters", "storm_swell", "stormchasers", "stormcrows", "subterranean", "sunderfrost", "the_penitents", "tidal", "tides_of_blood", "tormentors", "toxic_alchemists", "trappers", "tricksters", "tricksters", "umbrous", "undying", "unrelenting", "unyielding_commanders", "vanguards", "vanquishing", "vehement_brawlers", "vengeful", "veteran_brawlers", "vigorous", "virtuous", "virulent", "vulpines", "wanton_rupture", "weapon_masters", "wildbolt", "wind_striker", "windlasher", "winter_touch", "writhing", "wywards" ] ================================================ FILE: assets/lang/enUS/corrections.json ================================================ { "bad_tts_uniques": { "bane_ofahjad-den": "bane_of_ahjad-den", "galvanicazurite": "galvanic_azurite", "grandfather": "the_grandfather", "kilt_ofblackwing": "kilt_of_blackwing", "mjᅢヨlnic_ryng": "mjölnic_ryng", "sunstainedwar-crozier": "sunstained_war-crozier" }, "error_map": { " arbarian": " barbarian", "(arbarian": "(barbarian", "@arbarian": "(barbarian", "garbarian": "barbarian", "gorcerer": "sorcerer", "mruid": "(druid", "omuid": "(druid", "seythe": "scythe", "thoms": "thorns", "tier s": "tier 5", "tier1": "tier 1", "tier2": "tier 2", "tier3": "tier 3", "tier4": "tier 4", "tier5": "tier 5", "tier6": "tier 6", "tier7": "tier 7", "tier8": "tier 8", "tier9": "tier 9", "tmlelligence": "intelligence", "tomado": "tornado", "ttem": "item", "two- handed": "two-handed", "two-handed!": "two-handed", "two-handed.": "two-handed" }, "filter_after_keyword": [ " cts ", "account", "compare", "dearest will", "empty socket", "granted", "pacts", "requires lev", "requires level", "requires world", "scroll down", "scroll up", "sell value", "when equipped" ], "filter_words": [ "account bound", "barbarian", "by your clas", "by your class", "druid", "dungeon affixe", "imprinted", "monster level", "necromancer", "not useable", "only)", "operties lost", "properties lost", "requires world tier 3", "requires world tier 4", "revives allowed", "rogue", "sorcerer", "sorceress" ] } ================================================ FILE: assets/lang/enUS/item_types.json ================================================ { "Amulet": "amulet", "Axe": "axe", "Axe2H": "two-handed axe", "Boots": "boots", "Bow": "bow", "ChestArmor": "chest armor", "Crossbow2H": "crossbow", "Dagger": "dagger", "Elixir": "elixir", "Flail": "flail", "Focus": "focus", "Glaive": "glaive", "Gloves": "gloves", "Helm": "helm", "Incense": "custom type incense", "Legs": "pants", "Mace": "mace", "Mace2H": "two-handed mace", "Material": "custom type material", "OffHandTotem": "totem", "Polearm": "polearm", "Quarterstaff": "quarterstaff", "Ring": "ring", "Scythe": "scythe", "Scythe2H": "two-handed scythe", "Shield": "shield", "Sigil": "custom type sigil", "Staff": "staff", "Sword": "sword", "Sword2H": "two-handed sword", "TemperManual": "temper manual", "Tome": "tome", "Wand": "wand" } ================================================ FILE: assets/lang/enUS/paragon_maxroll_ids.json ================================================ { "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" } } ================================================ FILE: assets/lang/enUS/sigils.json ================================================ { "dungeons": { "abandoned_mineworks": "abandoned mineworks", "akkhans_grasp": "akkhans grasp", "aldurwood": "aldurwood", "ancient_reservoir": "ancient reservoir", "ancients_lament": "ancients lament", "anicas_claim": "anicas claim", "basaltic_ascent": "basaltic ascent", "bastion_of_faith": "bastion of faith", "beast_graveyard": "beast graveyard", "belfry_zakara": "belfry zakara", "betrayed_tomb": "betrayed tomb", "betrayers_row": "betrayers row", "bewitched_grotto": "bewitched grotto", "black_asylum": "black asylum", "blind_burrows": "blind burrows", "bloodsoaked_crag": "bloodsoaked crag", "broken_bulwark": "broken bulwark", "buried_halls": "buried halls", "caldera_gate": "caldera gate", "calibels_mine": "calibels mine", "carrion_fields": "carrion fields", "cataclysm": "cataclysm", "cavern_of_the_sea_hag": "cavern of the sea hag", "caves_of_kutokue": "caves of kutokue", "champions_demise": "champions demise", "charnel_house": "charnel house", "collapsed_vault": "collapsed vault", "conclave": "conclave", "corrupted_grotto": "corrupted grotto", "crumbling_hekma": "crumbling hekma", "crusaders_cathedral": "crusaders cathedral", "cultist_refuge": "cultist refuge", "dark_ravine": "dark ravine", "dark_refuge": "dark refuge", "dead_mans_dredge": "dead mans dredge", "defiled_catacomb": "defiled catacomb", "demons_wake": "demons wake", "derelict_lodge": "derelict lodge", "deserted_underpass": "deserted underpass", "domhainne_tunnels": "domhainne tunnels", "earthen_wound": "earthen wound", "endless_gates": "endless gates", "faceless_shrine": "faceless shrine", "fading_echo": "fading echo", "farai_cliffs": "farai cliffs", "feeding_grounds": "feeding grounds", "ferals_den": "ferals den", "fetid_mausoleum": "fetid mausoleum", "flooded_depths": "flooded depths", "forbidden_city": "forbidden city", "forge_of_malice": "forge of malice", "forgotten_depths": "forgotten depths", "forgotten_remains": "forgotten remains", "forgotten_ruins": "forgotten ruins", "forsaken_quarry": "forsaken quarry", "garan_hold": "garan hold", "ghoa_ruins": "ghoa ruins", "grim_haven": "grim haven", "grinning_labyrinth": "grinning labyrinth", "guulrahn_canals": "guulrahn canals", "guulrahn_slums": "guulrahn slums", "hakans_refuge": "hakans refuge", "hallowed_ossuary": "hallowed ossuary", "hallowed_stones": "hallowed stones", "halls_of_the_damned": "halls of the damned", "haunted_refuge": "haunted refuge", "heart_of_the_mountain": "heart of the mountain", "heathens_keep": "heathens keep", "heretics_asylum": "heretics asylum", "hidden_firstborn_ruins": "hidden firstborn ruins", "hierophant_pyre": "hierophant pyre", "hive": "hive", "hoarfrost_demise": "hoarfrost demise", "howling_warren": "howling warren", "immortal_emanation": "immortal emanation", "inferno": "inferno", "iron_cenotaph": "iron cenotaph", "iron_hold": "iron hold", "jalals_vigil": "jalals vigil", "komdor_temple": "komdor temple", "kor_dragan_barracks": "kor dragan barracks", "kor_valar_ramparts": "kor valar ramparts", "leviathans_maw": "leviathans maw", "lights_refuge": "lights refuge", "lights_watch": "lights watch", "lost_archives": "lost archives", "lost_keep": "lost keep", "lubans_rest": "lubans rest", "maddux_watch": "maddux watch", "mariners_refuge": "mariners refuge", "maugans_works": "maugans works", "maulwood": "maulwood", "mercys_reach": "mercys reach", "mournfield": "mournfield", "murmuring_spiral": "murmuring spiral", "nostrava_deepwood": "nostrava deepwood", "oblivion": "oblivion", "oldstones": "oldstones", "onyx_hold": "onyx hold", "pallid_delve": "pallid delve", "path_of_the_blind": "path of the blind", "penitent_cairns": "penitent cairns", "prison_of_caldeum": "prison of caldeum", "putrescent_larder": "putrescent larder", "putrid_aquifer": "putrid aquifer", "raethwind_wilds": "raethwind wilds", "razaks_descent": "razaks descent", "refuge_of_the_lost": "refuge of the lost", "remnants_of_rage": "remnants of rage", "renegades_retreat": "renegades retreat", "rimescar_cavern": "rimescar cavern", "ruined_wild": "ruined wild", "ruins_of_eridu": "ruins of eridu", "sanguine_chapel": "sanguine chapel", "sarats_lair": "sarats lair", "scorched_tunnels": "scorched tunnels", "scoriaceous_path": "scoriaceous path", "sealed_archives": "sealed archives", "seaside_descent": "seaside descent", "seers_reach": "seers reach", "seething_underpass": "seething underpass", "sepulcher_of_the_forsworn": "sepulcher of the forsworn", "serpents_lair": "serpents lair", "shadowed_plunge": "shadowed plunge", "shifting_city": "shifting city", "shivta_ruins": "shivta ruins", "sirocco_caverns": "sirocco caverns", "skatsimi_fane": "skatsimi fane", "sleepless_hollow": "sleepless hollow", "steadfast_barracks": "steadfast barracks", "stockades": "stockades", "submerged_ruins": "submerged ruins", "sunken_library": "sunken library", "sunken_ruins": "sunken ruins", "the_aegoye": "the aegoye", "the_hinterland": "the hinterland", "the_swallowed_temple": "the swallowed temple", "tomb_of_hallows": "tomb of hallows", "tomb_of_the_saints": "tomb of the saints", "tormented_forest": "tormented forest", "tormented_ruins": "tormented ruins", "twisted_hollow": "twisted hollow", "ularian_sepulcher": "ularian sepulcher", "uldurs_cave": "uldurs cave", "underroot": "underroot", "vault_of_the_forsaken": "vault of the forsaken", "vile_hive": "vile hive", "whispering_pines": "whispering pines", "whispering_vault": "whispering vault", "witchwater": "witchwater", "wretched_delve": "wretched delve", "yshari_sanctum": "yshari sanctum", "zenith": "zenith" }, "major": { "anguished_souls_elites": "anguished souls elites elite monsters have the \"anguished souls\" affix and deal more damage.", "astaroths_armageddon": "astaroths armageddon meteors will constantly rain from the sky.", "astaroths_loyal_steed": "astaroths loyal steed astaroth is immune to damage upon reaching each health threshold until the amalgam of rage is killed.", "avengers": "avengers killing a monster enrages monsters near it after a short delay, making them deal more damage.", "berserker_elites": "berserker elites elite monsters have the \"berserker\" affix and deal more damage.", "blood_blisters": "blood blisters killing a monster has a chance to spawn a blood blister. after a short delay, it explodes, dealing heavy area damage.", "cannibal_horde": "cannibal horde foul cannibals have defiled this place and additionally gain extra life.", "cultist_horde": "cultist horde corrupted fanatics worship within this place and additionally gain extra life.", "dark_omen": "dark omen this place is not right. someone, or something, stalks you here...", "deathly_shadows": "deathly shadows killing a monster has a chance to unleash a volatile pulse after a short delay, dealing heavy area damage.", "demon_horde": "demon horde vile hellspawn have conquered this place and additionally gain extra life.", "drifting_shades": "drifting shades drifting shades chase players. on contact, they explode for heavy damage and create a nightmare field that dazes victims.", "elemental_totems": "elemental totems while in combat, random elemental totems appear that buff nearby enemies until destroyed.", "empowered_elites_cold_enchanted": "empowered elites cold enchanted elites always have the \"cold enchanted\" affix.", "empowered_elites_shadow_enchanted": "empowered elites shadow enchanted elites always have the \"shadow enchanted\" affix.", "empowered_elites_teleporter": "empowered elites teleporter elites always have the \"teleporter\" affix.", "executioner_elites": "executioner elites elite monsters have the \"executioner\" affix and deal more damage.", "explosive_elites": "explosive elites elite monsters have the \"explosive\" affix and deal more damage.", "frenzied_elites": "frenzied elites elite monsters have the \"frenzied\" affix and deal more damage.", "hellfire": "hellfire monsters are afflicted with hellfire, which transfers to players when killed. hellfire increases damage, but deals damage overtime for each stack. using a potion helps alleviate hellfire with a fiery explosion.", "illusory_elites": "illusory elites elite monsters have the \"illusory\" affix and deal more damage.", "infested_elites": "infested elites elite monsters have the \"infested\" affix and deal more damage.", "insect_horde": "insect horde pestilent bugs have infested this place and additionally gain extra life.", "khazra_horde": "khazra horde braying goatmen have inhabited this place and additionally gain extra life.", "lambs_to_slaughter": "lambs to slaughter lure captured enemies to the shrine to sacrifice them for additional rewards.", "leaping_amalgam": "leaping amalgam the amalgam of rage will now continue its pounce attacks after astaroth dismounts.", "lightning_storm": "lightning storm lightning gathers above the player. get into the protection dome to avoid severe outcomes.", "meteor_storm": "meteor storm astaroth now has more meteor attacks.", "nightmare_portal": "nightmare portal while in combat, nightmare portals open randomly near players, pouring out dangerous monsters.", "poison_enchanted_elites": "poison enchanted elites elite monsters have the \"poison enchanted\" affix and deal more damage.", "profane_aegis": "profane aegis monsters gain of their maximum life as a barrier.", "rage_of_the_pack": "rage of the pack the amalgam of rage now summons elite werewolves.", "raging": "raging monsters here are reckless, receiving and dealing more damage.", "saw_blades_elites": "saw blades elites elite monsters have the \"saw blades\" affix and deal more damage.", "shadow_enchanted_elites": "shadow enchanted elites elite monsters have the \"shadow enchanted\" affix and deal more damage.", "shock_lance_elites": "shock lance elites elite monsters have the \"shock lance\" affix and deal more damage.", "sinful_torment_elites": "sinful torment elites elite monsters have the \"sinful torment\" affix and deal more damage.", "splitter_elites": "splitter elites elite monsters have the \"splitter\" affix and deal more damage.", "stormbanes_wrath": "stormbanes wrath a dark monolith chases players, pulsing for heavy damage when nearby.", "suppressor_elites": "suppressor elites elite monsters have the \"suppressor\" affix and deal more damage.", "teleport_residue": "teleport residue astaroth now leaves behind a pool of persistent damage when teleporting.", "the_beast_in_ice": "the beast in ice face a more challenging beast in ice at the end of this frozen cave.", "undead_horde": "undead horde desecrated undead have risen within this place and additionally gain extra life.", "vampiric_elites": "vampiric elites elite monsters have the \"vampiric\" affix and deal more damage.", "veiled_elites": "veiled elites elite monsters have the \"veiled\" affix and deal more damage.", "volcanic": "volcanic while in combat, gouts of flame periodically erupt near players.", "waller_elites": "waller elites elite monsters have the \"waller\" affix and deal more damage." }, "minor": { "armor_breakers": "armor breakers monster attacks from a distance reduce your armor by for seconds, stacking up to .", "astaroth_blood_blisters": "astaroth blood blisters astaroth summons blood blisters that explode if not destroyed.", "astaroth_deadly_pulse": "astaroth deadly pulse astaroth occasionally releases a deadly shadow pulse.", "astaroth_life_steal": "astaroth life steal astaroth gains life steal.", "backstabbers": "backstabbers close monster attacks from behind cause you to become vulnerable.", "barrier_breakers": "barrier breakers monsters deal more damage to barriers.", "bleeding_strikes": "bleeding strikes monsters deal an additional of their physical damage dealt as bleeding damage over seconds.", "burning_strikes": "burning strikes monsters deal an additional of their physical damage dealt as burning damage over seconds.", "chilling_wind_elites": "chilling wind elites elite monsters have the \"chilling wind\" affix and deal more damage.", "cold_strikes": "cold strikes monsters deal an additional of their physical damage dealt as cold damage.", "corrupting_strikes": "corrupting strikes monsters deal an additional of their physical damage dealt as corrupting damage over seconds.", "dodge_breakers": "dodge breakers monster attacks from a distance reduce dodge chance by for seconds, stacking up to .", "fire_strikes": "fire strikes monsters deal an additional of their physical damage dealt as fire damage.", "fire_traps": "fire traps additional fire traps appear in this dungeon.", "frostbiting_strikes": "frostbiting strikes monsters deal an additional of their physical damage dealt as frostbiting damage over seconds.", "hellbound_elites": "hellbound elites elite monsters have the \"hellbound\" affix and deal more damage.", "hunters": "hunters monsters have unnatural speed here, moving and attacking faster.", "lesser_aegis": "lesser aegis some monsters here gain of their maximum life as a barrier.", "lightning_strikes": "lightning strikes monsters deal an additional of their physical damage dealt as lightning damage.", "melee_defenders": "melee defenders monsters take less damage from close targets", "monster_barrier": "monster barrier monsters gain of their maximum life as a barrier.", "monster_bleed_resist": "monster bleed resist monsters take less bleeding damage.", "monster_burning_resist": "monster burning resist monsters take less burning damage.", "monster_cold_resist": "monster cold resist monsters take less cold damage.", "monster_critical_resist": "monster critical resist monster attacks reduce the damage of your critical strikes for seconds by , stacking up to .", "monster_crowd_control_resist": "monster crowd control resist crowd control duration vs monsters is reduced by .", "monster_fire_resist": "monster fire resist monsters take less fire damage.", "monster_life": "monster life monsters gain extra life.", "monster_life_steal": "monster life steal nonboss monsters gain life steal.", "monster_overpower_resist": "monster overpower resist monster attacks reduce the damage of your next overpower attack by . stacking up to .", "monster_physical_resist": "monster physical resist monsters take less physical damage.", "monster_poison_resist": "monster poison resist monsters take less poison damage.", "monster_regen": "monster regen nonboss monsters regen maximum life per second.", "monster_shadow_damage_over_time_resist": "monster shadow damage over time resist monsters take reduced damage from your shadow damage over time effects.", "monster_shadow_resist": "monster shadow resist monsters take less shadow damage.", "monster_thorns": "monster thorns monsters reflect of damage.", "monster_vulnerable_resist": "monster vulnerable resist duration of vulnerable effects vs monsters is reduced by .", "monsters_lightning_resist": "monsters lightning resist monsters take less lightning damage.", "poison_strikes": "poison strikes monsters deal an additional of their physical damage dealt as poison damage.", "poisoning_strikes": "poisoning strikes monsters deal an additional of their physical damage dealt as poisoning damage over seconds.", "potion_breakers": "potion breakers monster attacks from a distance increase the cooldown of your next potion by seconds, up to seconds.", "ranged_defenders": "ranged defenders monsters take less damage from distant targets.", "resistance_breakers": "resistance breakers monster attacks from a distance reduce resistance to all elements by for seconds, stacking up to .", "resource_burn": "resource burn after you spend resource, you burn an additional of the resource spent for every time you were hit by a distant enemy.", "shadow_strikes": "shadow strikes monsters deal an additional of their physical damage dealt as shadow damage.", "slowing_projectiles": "slowing projectiles monster attacks from a distance have a chance to slow targets.", "sparking_strikes": "sparking strikes monsters deal an additional of their physical damage dealt as sparking damage over seconds.", "summoner_elites": "summoner elites elite monsters have the \"summoner\" affix and deal more damage.", "suppressor_elites": "suppressor elites elite monsters have the \"suppressor\" affix and deal more damage.", "unstable_experiments": "unstable experiments monsters may explode on death, dealing heavy area damage around themselves after a delay.", "unstoppable_monsters": "unstoppable monsters monsters become unstoppable when life drops below ." }, "positive": { "amethyst_reserve": "amethyst reserve many amethyst chests have been stashed here.", "ancestors_favor": "ancestors favor extra lunar shrines and more glyph experience earned at the end of this dungeon.", "ancestral_awakening": "ancestral awakening you earn more glyph experience at the end of this dungeon.", "andariels_offering": "andariels offering astaroths rewards include pincushioned dolls, required to open andariels hoard.", "artillery_shrines": "artillery shrines artillery shrines will appear throughout this place.", "astral_prophecy": "astral prophecy astaroths rewards include a valuable horadric jewel.", "battle_hardened": "battle hardened gain damage reduction for every health you are missing.", "belials_apparitions": "belials apparitions elite apparitions have invaded this dungeon.", "belials_offering": "belials offering astaroths rewards include betrayers husks, required to open belials hoard.", "blast_wave_shrines": "blast wave shrines blast wave shrines will appear throughout this place.", "bonus_chests_armor": "bonus chests armor armor chests spawn at an extremely high rate.", "bonus_chests_boss_materials": "bonus chests boss materials summoning material chests spawn at an extremely high rate.", "bonus_chests_elixirs": "bonus chests elixirs elixir chests spawn at an extremely high rate.", "bonus_chests_herbs": "bonus chests herbs herb chests spawn at an extremely high rate.", "bonus_chests_jewelry": "bonus chests jewelry jewelry chests spawn at an extremely high rate.", "bonus_chests_runes": "bonus chests runes rune chests spawn at an extremely high rate.", "bonus_chests_sigil_powder": "bonus chests sigil powder sigil powder chests spawn at an extremely high rate.", "bonus_chests_weapons": "bonus chests weapons weapon chests spawn at an extremely high rate.", "channeling_shrines": "channeling shrines channeling shrines will appear throughout this place.", "chaos_rifts": "chaos rifts have opened in this place.", "conduit_shrines": "conduit shrines conduit shrines will appear throughout this place.", "control_impaired_explosions": "control impaired explosions being hit by control impairing effects creates an explosion around you.", "diamond_reserve": "diamond reserve many diamond chests have been stashed here.", "dungeon_delve": "dungeon delve completing the dungeon grants extra experience and rewards.", "duriels_offering": "duriels offering astaroths rewards include shards of agony, required to open duriels hoard.", "emerald_reserve": "emerald reserve many emerald chests have been stashed here.", "equipment_delve": "equipment delve find horadric artifacts to earn an increasingly higher quality cache of gear.", "extra_resplendent_chests": "extra resplendent chests extra resplendent chests will appear in the dungeon.", "extra_shrines": "extra shrines extra shrines will appear in the dungeon.", "fire_damage": "fire damage you deal more fire damage.", "forgotten_altar": "forgotten altar this world will always spawn a forgotten altar.", "forgotten_wisdom": "forgotten wisdom enemies hoard knowledge here, granting extra experience.", "frost_damage": "frost damage you deal more frost damage.", "gem_reserve": "gem reserve many gem fragment chests have been stashed here.", "gold_find": "gold find you find more gold.", "gold_reserve": "gold reserve many gold chests have been stashed here.", "greater_lair_keys": "greater lair keys astaroths rewards include greater lair keys.", "grigoires_offering": "grigoires offering astaroths rewards include living steel, required to open grigoires hoard.", "grim_secrets": "grim secrets killing astaroth grants a large amount of horadric knowledge.", "guaranteed_treasure_goblins": "guaranteed treasure goblins treasure goblins will appear in the dungeon.", "harbinger_of_hatreds_offering": "harbinger of hatreds offering astaroths rewards include abhorrent hearts, required to open the harbinger of hatreds hoard.", "hell_touched": "hell touched extra infernal shrines and more glyph experience earned at the end of this dungeon.", "hidden_armory": "hidden armory exceptional items are kept here, granting elite monsters a powerful loot affix.", "hidden_legendary_vendor": "hidden legendary vendor a secret vendor appears somewhere in the dungeon...", "horadric_phials": "horadric phials monsters in this dungeon drop more horadric phials.", "horadric_strongroom": "horadric strongroom this place will always contain a horadric strongroom.", "increased_critical_strike": "increased critical strike your critical strike chance is increased by .", "increased_healing": "increased healing your healing received is increased by .", "infernal_warp_reserve": "infernal warp reserve many infernal warp chests have been stashed here.", "lair_keys": "lair keys astaroths rewards include lair keys.", "legendary_spoils": "legendary spoils astaroths rewards include a guaranteed ancestral item.", "lethal_shrines": "lethal shrines lethal shrines will appear throughout this place.", "lightning_caller": "lightning caller you occasionally call down lightning strikes that damage nearby enemies.", "lightning_damage": "lightning damage you deal more lightning damage.", "lord_zirs_offering": "lord zirs offering astaroths rewards include exquisite blood, required to open lord zirs hoard.", "magic_find": "magic find you find more items from enemies.", "materials_reserve": "materials reserve many materials chests have been stashed here.", "mythic_prankster": "mythic prankster the resplendent treasures in this place have attracted a fancy, yet familiar nemesis...", "nudging_evade": "nudging evade using evade pushes enemies back.", "obducite_mine": "obducite mine monsters drop additional obducite in this place.", "obols_reserve": "obols reserve many obols chests have been stashed here.", "physical_damage": "physical damage you deal more physical damage.", "poison_damage": "poison damage you deal more poison damage", "poisonous_evade": "poisonous evade using evade leaves a damaging pool of poison under the first enemy you evade through or at your destination.", "protection_shrines": "protection shrines protection shrines will appear throughout this place.", "quick_killer": "quick killer killing a monster grants attack and move speed. stacking up to .", "reduce_cooldowns_on_kill": "reduce cooldowns on kill killing a monster reduces your cooldowns by . seconds.", "revered_site": "revered site monsters grant additional divine favor in this place.", "riches_of_gold": "riches of gold astaroths rewards include a large amount of gold.", "riches_of_keys": "riches of keys astaroths rewards include valuable dungeon keys.", "riches_of_materials": "riches of materials astaroths rewards include a large amount of crafting materials.", "riches_of_phials": "riches of phials astaroths rewards include a large amount of horadric phials.", "ruby_reserve": "ruby reserve many ruby chests have been stashed here.", "sapphire_reserve": "sapphire reserve many sapphire chests have been stashed here.", "shadow_damage": "shadow damage you deal more shadow damage.", "skull_reserve": "skull reserve many skull chests have been stashed here.", "thorns": "thorns after attacking an enemy, your thorns are increased by for seconds, up to at max stacks.", "topaz_reserve": "topaz reserve many topaz chests have been stashed here.", "treasure_breach": "treasure breach a puzzling number of treasure goblins have overrun this place.", "unique_spoils": "unique spoils astaroths rewards include more unique items.", "urivars_offering": "urivars offering astaroths rewards include judicators masks, required to open urivars hoard.", "varshans_offering": "varshans offering astaroths rewards include malignant hearts, required to open varshans hoard.", "vile_splendor": "vile splendor opulent excess is enjoyed here, granting elite monsters the \"gilded\" affix." } } ================================================ FILE: assets/lang/enUS/tooltips.json ================================================ { "ItemPower": "item power" } ================================================ FILE: assets/lang/enUS/tributes.json ================================================ { "ancestral_tribute_of_armaments": "ancestral tribute of armaments", "greater_tribute_of_armaments": "greater tribute of armaments", "greater_tribute_of_harmony": "greater tribute of harmony", "greater_tribute_of_ingenuity": "greater tribute of ingenuity", "greater_tribute_of_refinement": "greater tribute of refinement", "greater_tribute_of_the_horadrim": "greater tribute of the horadrim", "lesser_tribute": "lesser tribute", "lesser_tribute_of_harmony": "lesser tribute of harmony", "lesser_tribute_of_ingenuity": "lesser tribute of ingenuity", "lesser_tribute_of_the_horadrim": "lesser tribute of the horadrim", "major_tribute_of_andariel": "major tribute of andariel", "minor_tribute_of_andariel": "minor tribute of andariel", "mythic_tribute_of_armaments": "mythic tribute of armaments", "tribute_of_andariel": "tribute of andariel", "tribute_of_armaments": "tribute of armaments", "tribute_of_ascendance_resolute": "tribute of ascendance (resolute)", "tribute_of_growth": "tribute of growth", "tribute_of_harmony": "tribute of harmony", "tribute_of_heritage": "tribute of heritage", "tribute_of_ingenuity": "tribute of ingenuity", "tribute_of_radiance_resolute": "tribute of radiance (resolute)", "tribute_of_refinement": "tribute of refinement", "tribute_of_the_horadrim": "tribute of the horadrim", "tribute_of_titans": "tribute of titans" } ================================================ FILE: assets/lang/enUS/uniques.json ================================================ { "100000_steps": { "num_inherents": 0 }, "accord_of_the_wilds": { "num_inherents": 0 }, "aegroms_schism": { "num_inherents": 0 }, "ahavarion_spear_of_lycander": { "num_inherents": 0 }, "airidahs_inexorable_will": { "num_inherents": 0 }, "anathema_of_the_primes": { "num_inherents": 0 }, "ancients_oath": { "num_inherents": 0 }, "andariels_visage": { "num_inherents": 0 }, "arcadia": { "num_inherents": 0 }, "argent_veil": { "num_inherents": 0 }, "arreats_bearing": { "num_inherents": 0 }, "ashearas_khanjar": { "num_inherents": 0 }, "assassins_stride": { "num_inherents": 0 }, "autumnal_crown": { "num_inherents": 0 }, "axial_conduit": { "num_inherents": 0 }, "azurewrath": { "num_inherents": 0 }, "balazans_maxtlatl": { "num_inherents": 0 }, "band_of_first_breath": { "num_inherents": 0 }, "bands_of_ichorous_rose": { "num_inherents": 0 }, "bane_of_ahjad-den": { "num_inherents": 0 }, "banished_lords_talisman": { "num_inherents": 0 }, "bastion_of_sir_matthias": { "num_inherents": 2 }, "battle_trance": { "num_inherents": 0 }, "beastfall_boots": { "num_inherents": 0 }, "bindings_of_attrition": { "num_inherents": 0 }, "black_river": { "num_inherents": 0 }, "blood-mad_idol": { "num_inherents": 0 }, "blood_artisans_cuirass": { "num_inherents": 0 }, "blood_moon_breeches": { "num_inherents": 0 }, "blood_wake": { "num_inherents": 0 }, "bloodless_scream": { "num_inherents": 0 }, "blue_rose": { "num_inherents": 0 }, "bridle_of_torbaalos": { "num_inherents": 0 }, "cage_of_madness": { "num_inherents": 0 }, "cassias_grace": { "num_inherents": 0 }, "cathedrals_song": { "num_inherents": 2 }, "chainscourged_mail": { "num_inherents": 0 }, "cluckeye": { "num_inherents": 0 }, "cluckonomicon": { "num_inherents": 0 }, "condemnation": { "num_inherents": 0 }, "coop_de_grâce": { "num_inherents": 0 }, "cowl_of_malefic_torment": { "num_inherents": 0 }, "cowl_of_the_nameless": { "num_inherents": 0 }, "craze_of_the_dead_god": { "num_inherents": 0 }, "crown_of_lucion": { "num_inherents": 0 }, "cruors_embrace": { "num_inherents": 0 }, "dark_howl": { "num_inherents": 0 }, "dark_stalkers_medallion": { "num_inherents": 0 }, "dawnfire": { "num_inherents": 0 }, "deathgrip": { "num_inherents": 0 }, "deathless_visage": { "num_inherents": 0 }, "deathmask_of_nirmitruq": { "num_inherents": 0 }, "deaths_pavane": { "num_inherents": 0 }, "deathspeakers_pendant": { "num_inherents": 0 }, "desperate_march": { "num_inherents": 0 }, "dirge_of_airidah": { "num_inherents": 0 }, "dirge_of_odium": { "num_inherents": 0 }, "dolmen_stone": { "num_inherents": 0 }, "doombringer": { "num_inherents": 0 }, "drognans_anguish": { "num_inherents": 0 }, "eaglehorn": { "num_inherents": 0 }, "earthbreaker": { "num_inherents": 0 }, "ebonpiercer": { "num_inherents": 0 }, "echo_of_kwatli": { "num_inherents": 0 }, "eggcecutioner": { "num_inherents": 0 }, "eggis": { "num_inherents": 2 }, "eldruin_sword_of_justice": { "num_inherents": 1 }, "elegy": { "num_inherents": 0 }, "emberfury": { "num_inherents": 0 }, "emblem_of_staalbreak": { "num_inherents": 0 }, "endurant_faith": { "num_inherents": 0 }, "esadoras_overflowing_cameo": { "num_inherents": 0 }, "esus_heirloom": { "num_inherents": 0 }, "etnas_lost_dagger": { "num_inherents": 0 }, "eye_of_baal": { "num_inherents": 0 }, "eyes_in_the_dark": { "num_inherents": 0 }, "fang_of_the_vipermagi": { "num_inherents": 0 }, "fields_of_crimson": { "num_inherents": 0 }, "fist_of_the_iron_rose": { "num_inherents": 0 }, "fists_of_fate": { "num_inherents": 0 }, "flamescar": { "num_inherents": 0 }, "flameweaver": { "num_inherents": 0 }, "fleshrender": { "num_inherents": 0 }, "fleshwrit_carapace": { "num_inherents": 0 }, "flickerstep": { "num_inherents": 0 }, "footfalls_of_the_waning_world": { "num_inherents": 0 }, "fractured_runestone": { "num_inherents": 0 }, "fractured_winterglass": { "num_inherents": 0 }, "frostburn": { "num_inherents": 0 }, "fury_of_the_wilds": { "num_inherents": 0 }, "galvanic_azurite": { "num_inherents": 0 }, "gate_of_the_red_dawn": { "num_inherents": 2 }, "gathlens_birthright": { "num_inherents": 0 }, "gauntlets_of_sheol": { "num_inherents": 0 }, "gift_of_frost": { "num_inherents": 0 }, "gladiators_triumph": { "num_inherents": 0 }, "gloves_of_the_illuminator": { "num_inherents": 0 }, "godslayer_crown": { "num_inherents": 0 }, "gohrs_devastating_grips": { "num_inherents": 0 }, "gospel_of_the_devotee": { "num_inherents": 0 }, "grasp_of_shadow": { "num_inherents": 0 }, "gravewalkers_hand": { "num_inherents": 0 }, "greatstaff_of_the_crone": { "num_inherents": 0 }, "greaves_of_the_empty_tomb": { "num_inherents": 0 }, "greenwalkers_oath": { "num_inherents": 0 }, "greenwalkers_signet": { "num_inherents": 0 }, "griswolds_opus": { "num_inherents": 0 }, "hail_of_verglas": { "num_inherents": 0 }, "hand_of_apotheosis": { "num_inherents": 0 }, "hands_of_the_worldbreaker": { "num_inherents": 0 }, "hangmans_hand": { "num_inherents": 0 }, "harlequin_crest": { "num_inherents": 0 }, "harmony_of_ebewaka": { "num_inherents": 0 }, "heart_of_azgar": { "num_inherents": 0 }, "hecaton_chasm": { "num_inherents": 0 }, "heir_of_perdition": { "num_inherents": 0 }, "hellbrand_signet": { "num_inherents": 0 }, "hellhammer": { "num_inherents": 0 }, "hellhounds_sabatons": { "num_inherents": 0 }, "herald_of_zakarum": { "num_inherents": 3 }, "heralds_morningstar": { "num_inherents": 0 }, "hesha_e_kesungi": { "num_inherents": 0 }, "hooves_of_the_mountain_god": { "num_inherents": 0 }, "howl_from_below": { "num_inherents": 0 }, "hunters_zenith": { "num_inherents": 0 }, "iceheart_brais": { "num_inherents": 0 }, "ifehs_dire_totem": { "num_inherents": 0 }, "indiras_memory": { "num_inherents": 0 }, "infernal_homunculus": { "num_inherents": 0 }, "insatiable_fury": { "num_inherents": 0 }, "jacinth_shell": { "num_inherents": 0 }, "judgment_of_auriel": { "num_inherents": 0 }, "judicants_glaivehelm": { "num_inherents": 0 }, "kabraxis_will": { "num_inherents": 0 }, "kessimes_legacy": { "num_inherents": 0 }, "khamsin_steppewalkers": { "num_inherents": 0 }, "kilt_of_blackwing": { "num_inherents": 0 }, "levin_grasp": { "num_inherents": 0 }, "lidless_wall": { "num_inherents": 2 }, "lights_rebuke": { "num_inherents": 0 }, "litany_of_sable": { "num_inherents": 0 }, "locrans_talisman": { "num_inherents": 0 }, "loyaltys_mantle": { "num_inherents": 0 }, "lurid_pact": { "num_inherents": 0 }, "mace_of_king_leoric": { "num_inherents": 0 }, "mad_wolfs_glee": { "num_inherents": 0 }, "malefic_crescent": { "num_inherents": 0 }, "mantle_of_mountains_fury": { "num_inherents": 0 }, "mantle_of_the_grey": { "num_inherents": 0 }, "march_of_the_stalwart_soul": { "num_inherents": 0 }, "mark_of_the_old_wolf": { "num_inherents": 0 }, "melted_heart_of_selig": { "num_inherents": 0 }, "might_of_qual-kehk": { "num_inherents": 0 }, "might_of_the_ursine": { "num_inherents": 0 }, "misericorde": { "num_inherents": 0 }, "mjölnic_ryng": { "num_inherents": 0 }, "molochs_beating_flame": { "num_inherents": 0 }, "molten_band": { "num_inherents": 0 }, "morlu_fleshward": { "num_inherents": 0 }, "mothers_embrace": { "num_inherents": 0 }, "mutilator_plate": { "num_inherents": 0 }, "nails_of_the_gore-crowned": { "num_inherents": 0 }, "nesekem_the_herald": { "num_inherents": 0 }, "night_terror": { "num_inherents": 0 }, "nomads_longing_heart": { "num_inherents": 0 }, "okuns_catalyst": { "num_inherents": 1 }, "omen_of_pain": { "num_inherents": 0 }, "onyx_soul": { "num_inherents": 0 }, "ophidian_iris": { "num_inherents": 0 }, "orphan_maker": { "num_inherents": 0 }, "orsivane": { "num_inherents": 0 }, "overkill": { "num_inherents": 0 }, "pact_of_bone": { "num_inherents": 0 }, "paingorgers_gauntlets": { "num_inherents": 0 }, "path_of_the_emissary": { "num_inherents": 0 }, "path_of_tragoul": { "num_inherents": 0 }, "peacemongers_signet": { "num_inherents": 0 }, "penitent_greaves": { "num_inherents": 0 }, "pitfighters_gull": { "num_inherents": 0 }, "protean_heart": { "num_inherents": 0 }, "protection_of_the_prime": { "num_inherents": 0 }, "purified_lightbringer": { "num_inherents": 0 }, "rage_of_harrogath": { "num_inherents": 0 }, "raiment_of_the_infinite": { "num_inherents": 0 }, "raiment_of_the_sea": { "num_inherents": 0 }, "rakanoths_wake": { "num_inherents": 0 }, "ramaladnis_magnum_opus": { "num_inherents": 0 }, "razorplate": { "num_inherents": 0 }, "red_blessing": { "num_inherents": 0 }, "red_sermon": { "num_inherents": 0 }, "rictus_of_terror": { "num_inherents": 0 }, "rimeblood": { "num_inherents": 0 }, "ring_of_mendeln": { "num_inherents": 0 }, "ring_of_red_furor": { "num_inherents": 0 }, "ring_of_starless_skies": { "num_inherents": 0 }, "ring_of_the_midday_hunt": { "num_inherents": 0 }, "ring_of_the_midnight_sun": { "num_inherents": 0 }, "ring_of_the_ravenous": { "num_inherents": 0 }, "ring_of_the_sacrilegious_soul": { "num_inherents": 0 }, "ring_of_writhing_moon": { "num_inherents": 0 }, "rod_of_kepeleke": { "num_inherents": 0 }, "rotting_lightbringer": { "num_inherents": 0 }, "rustbitten_dirk": { "num_inherents": 0 }, "saboteurs_signet": { "num_inherents": 0 }, "sabre_of_tsasgal": { "num_inherents": 0 }, "sanctis_of_kethamar": { "num_inherents": 0 }, "sanguivor_blade_of_zir": { "num_inherents": 0 }, "sashes_of_the_wretched": { "num_inherents": 0 }, "scepter_of_the_three": { "num_inherents": 0 }, "scorn_of_the_earth": { "num_inherents": 0 }, "scoundrels_kiss": { "num_inherents": 0 }, "scoundrels_leathers": { "num_inherents": 0 }, "scourge_of_duriel": { "num_inherents": 0 }, "sea_lords_fine_gloves": { "num_inherents": 0 }, "seal_of_the_ophanim": { "num_inherents": 0 }, "seal_of_the_second_trumpet": { "num_inherents": 0 }, "seed_of_horazon": { "num_inherents": 0 }, "sepazontec": { "num_inherents": 0 }, "shanars_resonance": { "num_inherents": 0 }, "shard_of_verathiel": { "num_inherents": 0 }, "shattered_vow": { "num_inherents": 0 }, "shroud_of_false_death": { "num_inherents": 0 }, "shroud_of_khanduras": { "num_inherents": 0 }, "shrouded_gift": { "num_inherents": 0 }, "sidhe_bindings": { "num_inherents": 0 }, "signet_of_pelghain": { "num_inherents": 0 }, "sire_of_sin": { "num_inherents": 0 }, "skyhunter": { "num_inherents": 0 }, "sliver_of_hate": { "num_inherents": 0 }, "soulbrand": { "num_inherents": 0 }, "spine_of_tathamet": { "num_inherents": 0 }, "staff_of_endless_rage": { "num_inherents": 0 }, "staff_of_lam_esen": { "num_inherents": 0 }, "staff_of_zerae": { "num_inherents": 0 }, "starfall_coronet": { "num_inherents": 0 }, "stone_of_vehemen": { "num_inherents": 0 }, "storms_companion": { "num_inherents": 0 }, "strike_of_stormhorn": { "num_inherents": 0 }, "sunbirds_gorget": { "num_inherents": 0 }, "sunbrand": { "num_inherents": 0 }, "sundered_night": { "num_inherents": 0 }, "sunstained_war-crozier": { "num_inherents": 0 }, "supplication": { "num_inherents": 0 }, "tal_rashas_iridescent_loop": { "num_inherents": 0 }, "tassets_of_the_dawning_sky": { "num_inherents": 0 }, "temerity": { "num_inherents": 0 }, "tempest_roar": { "num_inherents": 0 }, "the_basilisk": { "num_inherents": 0 }, "the_blade_of_sight_aflame": { "num_inherents": 0 }, "the_butchers_cleaver": { "num_inherents": 0 }, "the_eightfold_idol": { "num_inherents": 0 }, "the_fecund_seal": { "num_inherents": 0 }, "the_gloom_ward": { "num_inherents": 2 }, "the_grandfather": { "num_inherents": 1 }, "the_hand_of_naz": { "num_inherents": 0 }, "the_hemat_stone": { "num_inherents": 0 }, "the_maestro": { "num_inherents": 0 }, "the_mortacrux": { "num_inherents": 0 }, "the_oculus": { "num_inherents": 0 }, "the_open_eye_of_gorgorra": { "num_inherents": 0 }, "the_relentless_heart": { "num_inherents": 0 }, "the_third_blade": { "num_inherents": 0 }, "the_umbracrux": { "num_inherents": 0 }, "the_undercrown": { "num_inherents": 0 }, "the_unmaker": { "num_inherents": 0 }, "thousand-eye_reaver": { "num_inherents": 0 }, "thrice-woven_nightmare": { "num_inherents": 0 }, "thundergods_blessing": { "num_inherents": 0 }, "tibaults_will": { "num_inherents": 0 }, "tuskhelm_of_joritz_the_mighty": { "num_inherents": 0 }, "twin_strikes": { "num_inherents": 0 }, "tyraels_might": { "num_inherents": 1 }, "ugly_bastard_helm": { "num_inherents": 0 }, "unbroken_chain": { "num_inherents": 0 }, "unsung_ascetics_wraps": { "num_inherents": 0 }, "vasilys_prayer": { "num_inherents": 0 }, "vengeful_sinew": { "num_inherents": 0 }, "vision_of_the_firestorm": { "num_inherents": 0 }, "vox_omnium": { "num_inherents": 0 }, "ward_of_the_white_dove": { "num_inherents": 2 }, "waxing_gibbous": { "num_inherents": 0 }, "wendigo_brand": { "num_inherents": 0 }, "widows_web": { "num_inherents": 0 }, "wildheart_hunger": { "num_inherents": 0 }, "will_of_rathma": { "num_inherents": 0 }, "will_of_stone": { "num_inherents": 0 }, "windforce": { "num_inherents": 0 }, "word_of_hakan": { "num_inherents": 0 }, "wound_drinker": { "num_inherents": 0 }, "wreath_of_auric_laurel": { "num_inherents": 0 }, "writhing_band_of_trickery": { "num_inherents": 0 }, "wushe_nak_pa": { "num_inherents": 0 }, "wyrdskin": { "num_inherents": 0 }, "xfals_corroded_signet": { "num_inherents": 0 }, "yens_blessing": { "num_inherents": 0 } } ================================================ FILE: build.py ================================================ import os import shutil from pathlib import Path from src import __version__ EXE_NAME = "d4lf.exe" def build(release_dir: Path): installer_cmd = ( f"pyinstaller --clean --onefile --icon=assets/logo.ico --distpath {release_dir} --paths src src\\main.py" ) os.system(installer_cmd) (release_dir / "main.exe").rename(release_dir / EXE_NAME) def clean_up(): if (build_dir := Path("build")).exists(): shutil.rmtree(build_dir) for p in Path.cwd().glob("*.spec"): p.unlink() def copy_additional_resources(release_dir: Path): (release_dir / "tts").mkdir() shutil.copy("README.md", release_dir) shutil.copy("tts/saapi64.dll", release_dir) shutil.copytree("assets", release_dir / "assets") shutil.copy("tts/install_dll.cmd", release_dir) def create_batch_for_consoleonly(release_dir: Path, exe_name: str): batch_file_path = release_dir / "d4lf-consoleonly.bat" with Path(batch_file_path).open("w", encoding="utf-8") as f: f.write("@echo off\n") f.write('cd /d "%~dp0"\n') f.write(f'start "" {exe_name} --consoleonly\n') def create_batch_for_autoupdater(release_dir: Path, exe_name: str): batch_file_path = release_dir / "autoupdater.bat" Path(batch_file_path).write_text( f"""@echo off cd /d "%~dp0" echo Starting D4LF auto update preprocessing start /WAIT {exe_name} --autoupdate if %errorlevel% == 1 ( echo Process did not complete successfully, check logs for more information. ) else if %errorlevel% == 2 ( echo D4Lf is already up to date! ) else ( echo Killing all existing d4lf processes to perform update taskkill /f /im d4lf.exe timeout /t 1 /nobreak echo Updating files robocopy "./temp_update/d4lf" "." /MIR /XF "autoupdater.bat" /XD "temp_update" "logs" echo Running postprocessing to verify update and clean up files start /WAIT {exe_name} --autoupdatepost )""", encoding="utf-8", ) if __name__ == "__main__": os.chdir(Path(__file__).parent) print(f"Building version: {__version__}") release_dir = Path("d4lf") if release_dir.exists(): shutil.rmtree(release_dir.absolute()) release_dir.mkdir(exist_ok=True, parents=True) clean_up() build(release_dir=release_dir) copy_additional_resources(release_dir) create_batch_for_consoleonly(release_dir=release_dir, exe_name=EXE_NAME) create_batch_for_autoupdater(release_dir=release_dir, exe_name=EXE_NAME) clean_up() ================================================ FILE: pyproject.toml ================================================ [build-system] build-backend = "hatchling.build" requires = ["hatchling"] [dependency-groups] dev = [ "prek", "pyinstaller", "pytest", "pytest-env", "pytest-mock", "pytest-pythonpath", "pytest-xdist", "typing-extensions" ] [project] dependencies = [ "beautifultable", "colorama", "httpx", "jsonpath", "keyboard", "lxml", "mouse", "mss", "natsort", "numpy", "opencv-python", "pillow", "psutil", "pydantic", "pydantic-numpy", "pydantic-yaml", "pyqt6", "python-jsonpath", "pytweening", "pywin32; sys_platform == 'win32'", "pyyaml", "rapidfuzz", "ruamel-yaml", "ruff", "selenium", "seleniumbase", "tk", "webdriver-manager" ] dynamic = ["version"] name = "d4lf" requires-python = ">=3.14" [tool.hatch.build.targets.wheel] packages = ["src"] [tool.hatch.version] path = "src/__init__.py" [tool.ruff] indent-width = 4 line-length = 120 preview = true [tool.ruff.format] docstring-code-format = true docstring-code-line-length = "dynamic" indent-style = "space" line-ending = "auto" quote-style = "double" skip-magic-trailing-comma = true [tool.ruff.lint] ignore = [ "ANN001", # fix more in the future, this is typing "ANN002", # fix more in the future, this is typing "ANN003", # fix more in the future, this is typing "ANN201", # fix more in the future, this is typing "ANN202", # fix more in the future, this is typing "ANN204", # fix more in the future, this is typing "ANN205", # fix more in the future, this is typing "ANN401", # fix more in the future, this is typing "ARG001", # fix more in the future "ARG002", # fix more in the future "BLE001", # fix more in the future "C901", # fix more in the future "COM812", "CPY", "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "DOC201", # fix more in the future, this is doc "DOC501", # fix more in the future, this is docs "DOC502", # fix more in the future, this is docs "E501", "ERA001", # fix more in the future "FBT001", "FBT002", "FBT003", # fix more in the future "FIX002", # fix more in the future "FIX004", # fix more in the future "FURB101", # fix more in the future "FURB118", # fix more in the future "FURB171", # fix more in the future "G004", "LOG014", "N801", # fix more in the future "N802", # fix more in the future "N803", # fix more in the future "N805", # fix more in the future "N806", # fix more in the future "N812", # fix more in the future "N815", # fix more in the future "N818", # fix more in the future "NPY002", # fix more in the future "PLC0206", # fix more in the future "PLR0904", # fix more in the future "PLR0911", # fix more in the future "PLR0912", # fix more in the future "PLR0913", "PLR0914", # fix more in the future "PLR0915", # fix more in the future "PLR0916", # fix more in the future "PLR0917", "PLR1702", # fix more in the future "PLR1704", # fix more in the future "PLR2004", # fix more in the future "PLR6104", # fix more in the future "PLR6201", # fix more in the future "PLR6301", # fix more in the future, staticmethods "PLW0108", # fix more in the future "PLW0602", # fix more in the future "PLW0603", # fix more in the future "PLW1641", # fix more in the future "PTH122", # fix more in the future "RUF001", # fix more in the future "RUF005", # fix more in the future "RUF012", # fix more in the future "RUF043", # fix more in the future "RUF045", # fix more in the future "RUF059", # fix more in the future "RUF067", # fix more in the future "S101", # fix more in the future "S311", # fix more in the future "S404", "S506", # fix more in the future "S603", "S605", # fix more in the future "S606", # fix more in the future "S607", "SLF001", # fix more in the future "T201", # fix more in the future "TD002", # fix more in the future "TD003", # fix more in the future "TD004", # fix more in the future "TRY002", # fix more in the future "TRY003", # fix more in the future "TRY004", # fix more in the future "TRY400", # fix more in the future "UP047" # fix more in the future ] select = ["ALL"] [tool.ruff.lint.isort] split-on-trailing-comma = false [tool.ruff.lint.per-file-ignores] "tests/*" = [ "FBT003", "PLC2701", "PLR2004", "S101", "S311", "SLF001" ] [tool.ruff.lint.pydocstyle] convention = "google" ================================================ FILE: pytest.ini ================================================ [pytest] addopts = --strict-markers markers = requests: mark a test using requests selenium: mark a test using selenium pythonpath = src testpaths = tests ================================================ FILE: src/__init__.py ================================================ import concurrent.futures TP = concurrent.futures.ThreadPoolExecutor() __version__ = "9.1.3" ================================================ FILE: src/autoupdater.py ================================================ import logging import shutil import sys import time import zipfile from pathlib import Path import requests import src.logger from src import __version__ LOGGER = logging.getLogger(__name__) # This autoupdater was almost entirely provided by iAmPilcrow class D4LFUpdater: def __init__(self): self.repo_owner = "d4lfteam" self.repo_name = "d4lf" self.api_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/releases/latest" self.changes_base_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/compare/" self.current_dir = Path.cwd() self.temp_dir = self.current_dir / "temp_update" self.version_file = self.temp_dir / "version" @staticmethod def normalize_version(version): """Ensure version has 'v' prefix.""" if version and not version.startswith("v"): return f"v{version.strip()}" return version def get_latest_release(self, silent=False): """Fetch latest release info from GitHub API.""" if not silent: LOGGER.info("Checking for latest release...") try: response = requests.get(self.api_url, timeout=10) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: LOGGER.error(f"Error fetching release info: {e}") return None def print_changes_between_releases(self, current_version, latest_version): try: url = self.changes_base_url + current_version + "..." + latest_version response = requests.get(url, timeout=10) response.raise_for_status() LOGGER.info("Changes since last update:") for commit in response.json()["commits"]: LOGGER.info(f"- {commit['commit']['message']}") except requests.exceptions.RequestException as e: LOGGER.error(f"Error fetching changes since last update: {e}") @staticmethod def download_file(url, filename): """Download file with progress indication.""" LOGGER.info(f"Downloading {filename}...") try: response = requests.get(url, stream=True, timeout=30) response.raise_for_status() total_size = int(response.headers.get("content-length", 0)) downloaded = 0 with Path(filename).open("wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) downloaded += len(chunk) if total_size > 0: percent = (downloaded / total_size) * 100 print(f"\rProgress: {percent:.1f}%", end="") print("\n") except requests.exceptions.RequestException as e: LOGGER.error(f"\nError downloading file: {e}") return False LOGGER.info("Download complete!") return True def extract_release(self, zip_path, latest_version): """Extract zip so batch process can copy files.""" LOGGER.info("Extracting files...") try: # Extract zip with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(self.temp_dir) # Also create an update file with information post processing will need # with Path(self.update_file).open("w") as f: # update_data = {"version": latest_version, "zip_path": zip_path} # json.dump(update_data, f) Path(self.version_file).write_text(latest_version, encoding="utf-8") except Exception as e: LOGGER.error(f"Error during extraction: {e}") return False LOGGER.info("Files extracted successfully!") return True @staticmethod def _get_major_version_number(version: str) -> int: return int(version.replace("v", "").split(".")[0]) def preprocess(self): """Main update process. This will: - Check if update is needed - Download new release - Extract files to a temp directory Additional updating and cleanup will be handled by the post process """ self._print_header() # Get current installed version current_version = self.normalize_version(__version__) LOGGER.info(f"Current installed version: {current_version}") # Get latest release info release_data = self.get_latest_release() if not release_data: LOGGER.warning("Unable to find latest release on github, can't automatically update.") return False latest_version = self.normalize_version(release_data.get("tag_name")) LOGGER.info(f"Latest release tag: {latest_version}") # Check if update needed if current_version == latest_version: LOGGER.info("✓ You're already on the latest version!") input("\nPress Enter to exit...") sys.exit(2) LOGGER.info(f"→ Update available: {current_version} → {latest_version}") self.print_changes_between_releases(current_version, latest_version) # Check if it's an update to a major version and warn of the consequences if self._get_major_version_number(latest_version) > self._get_major_version_number(current_version): LOGGER.warning( "You are upgrading a major version. This means your existing profiles might no longer work and will need to be reimported or recreated. Do you want to proceed?" ) proceed = input("Enter yes or y to proceed, all other inputs will cancel: ") if proceed.lower() not in ["yes", "y"]: LOGGER.info("Cancelling update.") return False # Find the d4lf zip asset assets = release_data.get("assets", []) zip_asset = None for asset in assets: if asset["name"].startswith("d4lf_") and asset["name"].endswith(".zip"): zip_asset = asset break if not zip_asset: LOGGER.error("Could not find d4lf zip file in release assets.") return False # Create temp directory self.temp_dir.mkdir(exist_ok=True) download_url = zip_asset["browser_download_url"] zip_filename = self.temp_dir / zip_asset["name"] LOGGER.info("") # Download if not self.download_file(download_url, zip_filename): return False # Extract the zip if not self.extract_release(zip_filename, latest_version): return False LOGGER.info("=" * 50) LOGGER.info("✓ Preprocessing is done, shutting down to allow update to happen. A new window will open shortly.") LOGGER.info("=" * 50) return True def postprocess(self): """Post process will handle the cleanup. It will: - Delete the temporary files that were extracted - Verify the version is truly updated """ self._print_header() with self.version_file.open("r") as f: updated_to_version = f.read().strip() if not updated_to_version: LOGGER.error( "Pre-processing update data was missing! Try to update manually by downloading the newest D4LF release." ) return False current_version = self.normalize_version(__version__) if updated_to_version != current_version: LOGGER.error( f"Current version is {current_version} but we attempted to update to {updated_to_version}. Check logs for errors and update manually." ) return False LOGGER.info("Cleaning up temporary files") if self.temp_dir.exists(): shutil.rmtree(self.temp_dir, ignore_errors=True) LOGGER.info("Temporary files are removed") LOGGER.info("=" * 50) LOGGER.info(f"✓ Successfully updated to {updated_to_version}!") LOGGER.info("=" * 50) return True @staticmethod def _print_header(): LOGGER.info("=" * 50) LOGGER.info("D4LF Auto-Updater") LOGGER.info("=" * 50) LOGGER.info("") def start_auto_update(postprocess=False): updater = D4LFUpdater() try: success = updater.postprocess() if postprocess else updater.preprocess() input("\nPress Enter to exit...") sys.exit(0 if success else 1) except KeyboardInterrupt: LOGGER.warning("\n\nUpdate cancelled by user.") sys.exit(1) except Exception as e: LOGGER.error(f"\n\nUnexpected error: {e}") input("\nPress Enter to exit...") sys.exit(1) def notify_if_update(): if not _should_check_for_update(): LOGGER.debug("Still within 4 hours of previous update check, skipping automatic update check.") return updater = D4LFUpdater() current_version = updater.normalize_version(__version__) release = updater.get_latest_release(silent=True) if not release: LOGGER.warning("Unable to find latest release of d4lf on github, skipping check for updates.") return latest_version = updater.normalize_version(release.get("tag_name")) if current_version != latest_version: LOGGER.info("=" * 50) LOGGER.info( f"An update has been detected. Run d4lf_autoupdater.exe to automatically update. Version {current_version} → {latest_version}" ) updater.print_changes_between_releases(current_version=current_version, latest_version=latest_version) LOGGER.info("=" * 50) def _should_check_for_update(check_interval_hours=4): """Check if it's time to check for updates based on a cooldown period.""" check_file = Path.cwd() / "assets" / "last_update" current_time = time.time() last_check_time = 0 # Read the last check time from file if it exists if Path.exists(check_file): with Path(check_file).open("r", encoding="utf-8") as f: last_check_time = float(f.read().strip()) # Calculate elapsed time since last check elapsed_time = current_time - last_check_time # Check if enough time has passed if elapsed_time >= (check_interval_hours * 3600): # Update the last check time Path(check_file).write_text(str(current_time), encoding="utf-8") return True return False # Main is only used for testing as files will not actually be copied if __name__ == "__main__": src.logger.setup(log_level="debug") start_auto_update() # start_auto_update(postprocess=True) ================================================ FILE: src/cam.py ================================================ import logging import threading import time import mss import mss.windows import numpy as np from src.config.ui import ResManager from src.utils.misc import convert_args_to_numpy LOGGER = logging.getLogger(__name__) mss.windows.CAPTUREBLT = 0 cached_img_lock = threading.Lock() class Cam: last_grab: int = None cached_img: np.ndarray = None window_offset_set: bool = False window_roi: dict = {"top": 0, "left": 0, "width": 0, "height": 0} monitor_x_range: tuple[int] = None monitor_y_range: tuple[int] = None res_key = "" _initialized: bool = False _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def update_window_pos(self, offset_x: int, offset_y: int, width: int, height: int): if ( self.is_offset_set() and self.window_roi["top"] == offset_y and self.window_roi["left"] == offset_x and self.window_roi["width"] == width and self.window_roi["height"] == height ): return self.res_key = f"{width}x{height}" self.res_p = f"{height}p" LOGGER.debug(f"Found Window Res: {self.res_key}") self.window_roi["top"] = offset_y self.window_roi["left"] = offset_x self.window_roi["width"] = width self.window_roi["height"] = height self.monitor_x_range = (self.window_roi["left"] + 10, self.window_roi["left"] + self.window_roi["width"] - 10) self.monitor_y_range = (self.window_roi["top"] + 10, self.window_roi["top"] + self.window_roi["height"] - 10) self.window_offset_set = True ResManager().set_resolution(self.res_key) if self.window_roi["width"] / self.window_roi["height"] < 16 / 10: LOGGER.warning("Aspect ratio is too narrow, please use a wider window. At least 16/10") def is_offset_set(self): return self.window_offset_set def grab(self, force_new: bool = False) -> np.ndarray: if ( not force_new and self.cached_img is not None and self.last_grab is not None and time.perf_counter() - self.last_grab < 0.04 ): return self.cached_img # wait for offsets to be found if not self.is_offset_set(): LOGGER.debug("Wait for window detection") while not self.window_offset_set: time.sleep(0.05) LOGGER.debug("Found window, continue grabbing") with cached_img_lock: self.last_grab = time.perf_counter() with mss.mss() as sct: img = np.array(sct.grab(self.window_roi)) with cached_img_lock: self.cached_img = img[:, :, :3] return self.cached_img # Conversions # ============================================================================ @convert_args_to_numpy def monitor_to_window(self, monitor_coord: np.ndarray) -> np.ndarray: return monitor_coord[:] - np.array([self.window_roi["left"], self.window_roi["top"]]) @convert_args_to_numpy def window_to_monitor(self, window_coord: np.ndarray) -> np.ndarray: # TODO: clip by monitor ranges return window_coord[:] + np.array([self.window_roi["left"], self.window_roi["top"]]) @convert_args_to_numpy def abs_window_to_window(self, abs_window_coord: np.ndarray) -> np.ndarray: return abs_window_coord[:] + np.array([self.window_roi["width"] // 2, self.window_roi["height"] // 2]) @convert_args_to_numpy def window_to_abs_window(self, window_coord: np.ndarray) -> np.ndarray: return window_coord[:] - np.array([self.window_roi["width"] // 2, self.window_roi["height"] // 2]) @convert_args_to_numpy def abs_window_to_monitor(self, abs_window_coord: np.ndarray) -> np.ndarray: window_coord = self.abs_window_to_window(abs_window_coord) return self.window_to_monitor(window_coord) ================================================ FILE: src/config/__init__.py ================================================ import sys from pathlib import Path def get_base_dir(bundled: bool = False) -> Path: if getattr(sys, "frozen", False) and not bundled: return Path(sys.executable).parent return Path(__file__).parent.parent.parent AFFIX_COMPARISON_CHARS = 60 BASE_DIR = get_base_dir(False) ================================================ FILE: src/config/data.py ================================================ """Everything is this file is based on UHD resolution (3840x2160).""" import logging from dataclasses import dataclass from functools import lru_cache from pathlib import Path import cv2 import numpy as np from src.config import BASE_DIR from src.config.loader import IniConfigLoader from src.config.settings_models import ColorsModel, HSVRangeModel, UiOffsetsModel, UiPosModel, UiRoiModel from src.utils.image_operations import alpha_to_mask LOGGER = logging.getLogger("d4lf") COLORS = ColorsModel( material_color=HSVRangeModel(h_s_v_min=np.array([86, 110, 190]), h_s_v_max=np.array([114, 220, 255])), unique_gold=HSVRangeModel(h_s_v_min=np.array([4, 45, 125]), h_s_v_max=np.array([26, 155, 250])), unusable_red=HSVRangeModel(h_s_v_min=np.array([0, 210, 110]), h_s_v_max=np.array([10, 255, 210])), ) TAB_SLOTS_COORDS = {6: np.array([300, 284, 800, 102]), 7: np.array([240, 292, 930, 106])} POSITIONS = ( (3840, 2160), UiOffsetsModel( find_bullet_points_width=150, find_seperator_short_offset_top=500, item_descr_line_height=50, item_descr_off_bottom_edge=104, item_descr_pad=30, item_descr_width=780, vendor_center_item_x=1232, ), UiPosModel( possible_centers=[ (2994, 244), (2994, 432), (2994, 624), (2994, 810), (2994, 992), (2994, 1196), (3722, 428), (3722, 618), (3722, 808), (3614, 1220), (3730, 1220), (3304, 1218), (3416, 1218), ], window_dimensions=(3840, 2160), ), UiRoiModel( rel_descr_search_left=np.array([-900, 0, 150, 1760]), rel_descr_search_right=np.array([60, 0, 120, 1760]), rel_fav_flag=np.array([8, 6, 16, 20]), slots_8x1=np.array([1166, 165, 125, 1462]), slots_3x11=np.array([2536, 1444, 1214, 486]), slots_5x10=np.array([92, 538, 1224, 972]), sort_icon=np.array([2440, 1332, 126, 124]), stash_menu_icon=np.array([592, 144, 218, 96]), tab_slots=TAB_SLOTS_COORDS[IniConfigLoader().general.max_stash_tabs], vendor_menu_icon=np.array([182, 757, 220, 90]), ), ) @dataclass class Template: name: str = None img_bgra: np.ndarray = None img_bgr: np.ndarray = None img_gray: np.ndarray = None alpha_mask: np.ndarray = None @lru_cache def load_templates() -> dict[str, Template]: result = {} template_paths = Path(BASE_DIR / "assets/templates").rglob("*.png") for template in template_paths: try: template_img = cv2.imread(str(template), cv2.IMREAD_UNCHANGED) except cv2.error: LOGGER.exception(f"Could not load image: {template}") continue result[template.stem.lower()] = Template( name=template.stem.lower(), img_bgra=template_img, img_bgr=cv2.cvtColor(template_img, cv2.COLOR_BGRA2BGR), img_gray=cv2.cvtColor(template_img, cv2.COLOR_BGRA2GRAY), alpha_mask=alpha_to_mask(template_img), ) return result ================================================ FILE: src/config/helper.py ================================================ import sys import threading if sys.platform != "darwin": import keyboard def check_greater_than_zero(v: int) -> int: if v < 0: msg = "must be greater than zero" raise ValueError(msg) return v def validate_percent(v: int) -> int: check_greater_than_zero(v) if v > 100: msg = "must be less than or equal to 100" raise ValueError(msg) return v def validate_hotkey(k: str) -> str: keyboard.parse_hotkey(k) return k def singleton(cls): instances = {} lock = threading.Lock() def get_instance(*args, **kwargs): with lock: if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return get_instance def str_to_int_list(s: str) -> list[int]: if not s: return [] return [int(x) for x in s.split(",")] ================================================ FILE: src/config/loader.py ================================================ """Configuration loading, validation, persistence, and live change notifications.""" from __future__ import annotations import configparser import logging import pathlib import threading from collections.abc import Callable from pathlib import Path from typing import Any from src.config.helper import singleton from src.config.settings_models import AdvancedOptionsModel, CharModel, GeneralModel LOGGER = logging.getLogger(__name__) PARAMS_INI = "params.ini" MANUAL_RESTART_SETTING_KEYS = {"general.vision_mode_type"} ConfigChangeListener = Callable[[frozenset[str]], None] @singleton class IniConfigLoader: """Load, validate, persist, and broadcast config changes.""" def __init__(self) -> None: self._advanced_options = AdvancedOptionsModel() self._char = CharModel() self._general = GeneralModel() self._parser: configparser.ConfigParser | None = None self._user_dir = pathlib.Path.home() / ".d4lf" self._user_dir.mkdir(parents=True, exist_ok=True) self._lock = threading.RLock() self._change_listeners: list[ConfigChangeListener] = [] self._last_config_signature: tuple[int, int] | None = None self._config_revision = 0 self._state_snapshot: dict[str, Any] = {} self._deferred_cleanup_log_records: list[logging.LogRecord] = [] self._defer_cleanup_log_records = True self.load(notify=False) def _config_path(self) -> Path: return self.user_dir / PARAMS_INI def _get_config_signature(self) -> tuple[int, int] | None: config_path = self._config_path() if not config_path.exists(): return None stat_result = config_path.stat() return stat_result.st_mtime_ns, stat_result.st_size def _section_models(self) -> dict[str, Any]: return {"advanced_options": self._advanced_options, "char": self._char, "general": self._general} def _model_for_section(self, section: str) -> Any | None: return self._section_models().get(section) def _capture_state_snapshot(self) -> dict[str, Any]: snapshot: dict[str, Any] = {} for section_name, model in self._section_models().items(): for key, value in model.model_dump(mode="python").items(): snapshot[f"{section_name}.{key}"] = value return snapshot def _changed_keys(self, previous_snapshot: dict[str, Any], current_snapshot: dict[str, Any]) -> set[str]: return { key for key in previous_snapshot.keys() | current_snapshot.keys() if previous_snapshot.get(key) != current_snapshot.get(key) } def _write_parser(self) -> None: if self._parser is None: msg = "Config parser has not been initialized" raise RuntimeError(msg) with self._config_path().open("w", encoding="utf-8") as config_file: self._parser.write(config_file) def _remove_defunct_model_keys(self) -> bool: if self._parser is None: msg = "Config parser has not been initialized" raise RuntimeError(msg) removed_key = False for section, model in self._section_models().items(): if section not in self._parser: continue valid_keys = type(model).model_fields for key in list(self._parser[section]): if key in valid_keys: continue self._log_defunct_model_key(section, key) self._parser.remove_option(section, key) removed_key = True return removed_key def _log_defunct_model_key(self, section: str, key: str) -> None: path_name, line_number, _, _ = LOGGER.findCaller(stacklevel=2) record = LOGGER.makeRecord( LOGGER.name, logging.WARNING, path_name, line_number, "Deprecated key=%s found in [%s]. Removing it from %s.", (key, section, PARAMS_INI), None, ) if self._defer_cleanup_log_records: self._deferred_cleanup_log_records.append(record) if LOGGER.isEnabledFor(logging.WARNING): LOGGER.handle(record) def consume_deferred_cleanup_log_records(self) -> list[logging.LogRecord]: with self._lock: records = self._deferred_cleanup_log_records.copy() self._deferred_cleanup_log_records.clear() self._defer_cleanup_log_records = False return records def _format_value_for_log(self, value: Any) -> str: if isinstance(value, bool): return "on" if value else "off" return str(value) def _log_changed_values(self, changed_keys: set[str]) -> None: if not changed_keys: return snapshot = self._state_snapshot.copy() formatted_entries = [f"{key}={self._format_value_for_log(snapshot.get(key))}" for key in sorted(changed_keys)] noun = "change" if len(formatted_entries) == 1 else "changes" LOGGER.info("Applied setting %s: %s", noun, ", ".join(formatted_entries)) if any(key in MANUAL_RESTART_SETTING_KEYS for key in changed_keys): LOGGER.warning("Please restart d4lf manually to apply vision mode changes.") def _notify_listeners(self, changed_keys: set[str]) -> None: if not changed_keys: return listeners = list(self._change_listeners) frozen_keys = frozenset(changed_keys) for listener in listeners: try: listener(frozen_keys) except Exception: LOGGER.exception("Failed to notify config listener") def register_change_listener(self, listener: ConfigChangeListener) -> None: with self._lock: if listener not in self._change_listeners: self._change_listeners.append(listener) def unregister_change_listener(self, listener: ConfigChangeListener) -> None: with self._lock: self._change_listeners = [existing for existing in self._change_listeners if existing != listener] def register_listener(self, listener: ConfigChangeListener) -> None: """Backward-compatible alias for older call sites.""" self.register_change_listener(listener) def unregister_listener(self, listener: ConfigChangeListener) -> None: """Backward-compatible alias for older call sites.""" self.unregister_change_listener(listener) def load(self, clear: bool = False, notify: bool = True) -> None: with self._lock: previous_snapshot = self._state_snapshot.copy() config_path = self._config_path() if not config_path.exists() or clear: config_path.write_text("", encoding="utf-8") self._parser = configparser.ConfigParser() self._parser.read(config_path, encoding="utf-8") defunct_keys_removed = self._remove_defunct_model_keys() if defunct_keys_removed: self._write_parser() if "advanced_options" in self._parser: self._advanced_options = AdvancedOptionsModel(**self._parser["advanced_options"]) else: self._advanced_options = AdvancedOptionsModel() if "char" in self._parser: self._char = CharModel(**self._parser["char"]) else: self._char = CharModel() if "general" in self._parser: self._general = GeneralModel(**self._parser["general"]) else: self._general = GeneralModel() self._last_config_signature = self._get_config_signature() self._config_revision += 1 self._state_snapshot = self._capture_state_snapshot() changed_keys = self._changed_keys(previous_snapshot, self._state_snapshot) if notify: self._log_changed_values(changed_keys) self._notify_listeners(changed_keys) def reload_if_changed(self) -> bool: with self._lock: current_signature = self._get_config_signature() if current_signature == self._last_config_signature: return False LOGGER.debug("Detected external params.ini change. Reloading configuration.") self.load(notify=True) return True @property def advanced_options(self) -> AdvancedOptionsModel: self.reload_if_changed() return self._advanced_options @property def char(self) -> CharModel: self.reload_if_changed() return self._char @property def general(self) -> GeneralModel: self.reload_if_changed() return self._general @property def user_dir(self) -> Path: return self._user_dir @property def config_revision(self) -> int: with self._lock: return self._config_revision def save_value(self, section: str, key: str, value: Any) -> None: changed_keys: set[str] = set() with self._lock: if self._parser is None: self.load(notify=False) previous_snapshot = self._state_snapshot.copy() model = self._model_for_section(section) if model is not None: setattr(model, key, value) if section not in self._parser.sections(): self._parser.add_section(section) new_serialized_value = str(value) old_serialized_value = self._parser.get(section, key, fallback=None) if old_serialized_value == new_serialized_value: return self._parser.set(section, key, new_serialized_value) self._write_parser() self._last_config_signature = self._get_config_signature() self._config_revision += 1 self._state_snapshot = self._capture_state_snapshot() changed_keys = self._changed_keys(previous_snapshot, self._state_snapshot) self._log_changed_values(changed_keys) self._notify_listeners(changed_keys) if __name__ == "__main__": loader = IniConfigLoader() loader.load() ================================================ FILE: src/config/profile_models.py ================================================ """New config loading and verification using pydantic. For now, both will exist in parallel hence _new.""" import enum import logging import sys from pydantic import BaseModel, ConfigDict, RootModel, field_validator, model_validator from src.config.helper import check_greater_than_zero, validate_percent from src.item.data.item_type import ItemType # noqa: TC001 from src.item.data.rarity import ItemRarity MODULE_LOGGER = logging.getLogger(__name__) def _parse_item_type_or_rarities(data: str | list[str]) -> list[str]: if isinstance(data, str): return [data] return data class AffixAspectFilterModel(BaseModel): model_config = ConfigDict(extra="forbid") name: str value: float | None = None @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | list[str | float] | dict[str, str | float]) -> dict[str, str | float]: if isinstance(data, dict): return data if isinstance(data, str): return {"name": data} if isinstance(data, list): if not data or len(data) > 2: msg = "list, cannot be empty or larger than 2 items" raise ValueError(msg) result = {} if len(data) >= 1: result["name"] = data[0] if len(data) >= 2: result["value"] = data[1] return result msg = "must be str or list" raise ValueError(msg) class AffixFilterModel(AffixAspectFilterModel): want_greater: bool = False minPercentOfAffix: int = 0 @field_validator("name") @classmethod def name_must_exist(cls, name: str) -> str: # This on module level would be a circular import, so we do it lazy for now from src.dataloader import Dataloader # noqa: PLC0415 if name not in Dataloader().affix_dict: msg = f"affix {name} does not exist" raise ValueError(msg) return name @field_validator("minPercentOfAffix") @classmethod def percent_validator(cls, v: int) -> int: return validate_percent(v) @model_validator(mode="after") def value_and_percent_are_mutually_exclusive(self) -> AffixFilterModel: if self.value and self.minPercentOfAffix: msg = "value and minPercentOfAffix cannot both be set" raise ValueError(msg) return self class AffixFilterCountModel(BaseModel): model_config = ConfigDict(extra="forbid") count: list[AffixFilterModel] = [] maxCount: int = sys.maxsize minCount: int = 0 @field_validator("minCount", "maxCount") @classmethod def count_validator(cls, v: int) -> int: return check_greater_than_zero(v) @model_validator(mode="after") def model_validator(self) -> AffixFilterCountModel: # If minCount and maxCount are not set, we assume that the lengths of the count list is the only thing that matters. # To not show up in the model.dict() we need to remove them from the model_fields_set property if "minCount" not in self.model_fields_set and "maxCount" not in self.model_fields_set: self.minCount = len(self.count) self.maxCount = len(self.count) self.model_fields_set.remove("minCount") self.model_fields_set.remove("maxCount") if self.minCount > self.maxCount: msg = "minCount must be smaller than maxCount" raise ValueError(msg) if not self.count: msg = "count must not be empty" raise ValueError(msg) return self class AspectUniqueFilterModel(AffixAspectFilterModel): minPercentOfAspect: int = 0 @field_validator("name") @classmethod def name_must_exist(cls, name: str) -> str: # This on module level would be a circular import, so we do it lazy for now from src.dataloader import Dataloader # noqa: PLC0415 # Ensure name is in format we expect name = name.lower().replace("'", "").replace(" ", "_").replace(",", "") if name not in Dataloader().aspect_unique_dict: msg = f"aspect {name} does not exist" raise ValueError(msg) return name @field_validator("minPercentOfAspect") @classmethod def percent_validator(cls, v: int) -> int: return validate_percent(v) @model_validator(mode="after") def value_and_percent_are_mutually_exclusive(self) -> AspectUniqueFilterModel: if self.value and self.minPercentOfAspect: msg = "value and minPercentOfAspect cannot both be set" raise ValueError(msg) return self class GlobalUniqueModel(BaseModel): model_config = ConfigDict(extra="forbid") profileAlias: str = "" minGreaterAffixCount: int = 0 minPercentOfAspect: int = 0 minPower: int = 0 @field_validator("minPower") @classmethod def check_min_power(cls, v: int) -> int: return check_greater_than_zero(v) @field_validator("minGreaterAffixCount") @classmethod def count_validator(cls, v: int) -> int: if not 0 <= v <= 4: msg = "must be in [0, 4]" raise ValueError(msg) return v @field_validator("minPercentOfAspect") @classmethod def percent_validator(cls, v: int) -> int: return validate_percent(v) class ItemFilterModel(BaseModel): model_config = ConfigDict(extra="forbid") affixPool: list[AffixFilterCountModel] = [] inherentPool: list[AffixFilterCountModel] = [] itemType: list[ItemType] = [] minGreaterAffixCount: int = 0 minPower: int = 0 uniqueAspect: AspectUniqueFilterModel = None @field_validator("minPower") @classmethod def check_min_power(cls, v: int) -> int: return check_greater_than_zero(v) @field_validator("minGreaterAffixCount") @classmethod def min_greater_affix_in_range(cls, v: int) -> int: if not 0 <= v <= 4: msg = "must be in [0, 4]" raise ValueError(msg) return v @field_validator("itemType", mode="before") @classmethod def parse_item_type(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) DynamicItemFilterModel = RootModel[dict[str, ItemFilterModel]] class SigilPriority(enum.StrEnum): blacklist = enum.auto() whitelist = enum.auto() class SigilConditionModel(BaseModel): model_config = ConfigDict(extra="forbid") name: str condition: list[str] = [] @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | list[str | float] | dict[str, str | float]) -> dict[str, str | float]: if isinstance(data, dict): return data if isinstance(data, str): return {"name": data} if isinstance(data, list): if not data: msg = "list cannot be empty" raise ValueError(msg) result = {} if len(data) >= 1: result["name"] = data[0] if len(data) >= 2: result["condition"] = data[1:] return result msg = "must be str or list" raise ValueError(msg) @field_validator("condition", "name") @classmethod def name_must_exist(cls, names_in: str | list[str]) -> str | list[str]: # This on module level would be a circular import, so we do it lazy for now from src.dataloader import Dataloader # noqa: PLC0415 names = [names_in] if isinstance(names_in, str) else names_in errors = [name for name in names if name not in Dataloader().affix_sigil_dict] if errors: msg = f"The following affixes/dungeons do not exist: {errors}" raise ValueError(msg) return names_in class SigilFilterModel(BaseModel): model_config = ConfigDict(extra="forbid") blacklist: list[SigilConditionModel] = [] priority: SigilPriority = SigilPriority.blacklist whitelist: list[SigilConditionModel] = [] @model_validator(mode="after") def data_integrity(self) -> SigilFilterModel: errors = [item for item in self.blacklist if item in self.whitelist] if errors: msg = f"blacklist and whitelist must not overlap: {errors}" raise ValueError(msg) return self class TributeFilterModel(BaseModel): model_config = ConfigDict(extra="forbid") name: str = None rarities: list[ItemRarity] = [] @field_validator("name") @classmethod def name_must_exist(cls, name: str) -> str: # This on module level would be a circular import, so we do it lazy for now from src.dataloader import Dataloader # noqa: PLC0415 if not name: return name tribute_dict = Dataloader().tribute_dict # Allow people to shorthand and leave off "tribute_of_" name_with_tribute = "tribute_of_" + name if name not in tribute_dict and name_with_tribute not in tribute_dict: msg = f"No tribute named {name} or {name_with_tribute} exists" raise ValueError(msg) if name_with_tribute in tribute_dict: name = name_with_tribute return name @model_validator(mode="before") @classmethod def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]: if isinstance(data, dict): return data if isinstance(data, str): if any(rarity.value.lower() == data.lower() for rarity in ItemRarity): return {"rarities": [data]} return {"name": data} if isinstance(data, list): if not data: msg = "list cannot be empty" raise ValueError(msg) return {"rarities": data} msg = "must be str or list" raise ValueError(msg) @field_validator("rarities", mode="before") @classmethod def parse_rarities(cls, data: str | list[str]) -> list[str]: return _parse_item_type_or_rarities(data) class ProfileModel(BaseModel): model_config = ConfigDict(extra="forbid") Affixes: list[DynamicItemFilterModel] = [] AspectUpgrades: list[str] = [] GlobalUniques: list[GlobalUniqueModel] = [] name: str Sigils: SigilFilterModel = SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist) Tributes: list[TributeFilterModel] = [] Paragon: dict[str, object] | list[dict[str, object]] | None = None @model_validator(mode="before") def aspects_must_exist(self) -> ProfileModel: # This on module level would be a circular import, so we do it lazy for now from src.dataloader import Dataloader # noqa: PLC0415 if "AspectUpgrades" not in self: return self all_aspects_list = Dataloader().aspect_list aspects_not_in_all_aspects = [x for x in self["AspectUpgrades"] if x not in all_aspects_list] if aspects_not_in_all_aspects: msg = f"The following aspects in AspectUpgrades do not exist in our data: {', '.join(aspects_not_in_all_aspects)}" raise ValueError(msg) return self ================================================ FILE: src/config/settings_models.py ================================================ import enum import logging from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic_numpy import np_array_pydantic_annotated_typing # noqa: TC002 from pydantic_numpy.model import NumpyModel from src.config.helper import check_greater_than_zero, validate_hotkey if TYPE_CHECKING: import numpy as np MODULE_LOGGER = logging.getLogger(__name__) HIDE_FROM_GUI_KEY = "hide_from_gui" IS_HOTKEY_KEY = "is_hotkey" LIVE_RELOAD_GROUP_KEY = "live_reload_group" class AspectFilterType(enum.StrEnum): all = enum.auto() none = enum.auto() upgrade = enum.auto() class BrowserType(enum.StrEnum): edge = enum.auto() chrome = enum.auto() firefox = enum.auto() class CosmeticFilterType(enum.StrEnum): junk = enum.auto() ignore = enum.auto() class ItemRefreshType(enum.StrEnum): force_with_filter = enum.auto() force_without_filter = enum.auto() no_refresh = enum.auto() class LogLevels(enum.StrEnum): debug = enum.auto() info = enum.auto() warning = enum.auto() error = enum.auto() critical = enum.auto() class MoveItemsType(enum.StrEnum): everything = enum.auto() favorites = enum.auto() junk = enum.auto() unmarked = enum.auto() class JunkRaresType(enum.StrEnum): disabled = "disabled" three_affixes = "3 affixes" all = "all" class ThemeType(enum.StrEnum): dark = enum.auto() light = enum.auto() class UnfilteredUniquesType(enum.StrEnum): favorite = enum.auto() ignore = enum.auto() junk = enum.auto() class VisionModeType(enum.StrEnum): highlight_matches = enum.auto() fast = enum.auto() class _IniBaseModel(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, validate_assignment=True) class AdvancedOptionsModel(_IniBaseModel): disable_tts_warning: bool = Field( default=False, 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"}) 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.", ) force_refresh_only: str = Field( default="ctrl+shift+f11", description="Hotkey to refresh the junk/favorite status of all items in your inventory/stash. A filter is not run after.", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) log_lvl: LogLevels = Field( default=LogLevels.info, description="The level at which logs are written", json_schema_extra={LIVE_RELOAD_GROUP_KEY: "log_level"}, ) move_to_chest: str = Field( default="f8", description="Hotkey to move configured items from inventory to stash", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) move_to_inv: str = Field( default="f7", description="Hotkey to move configured items from stash to inventory", json_schema_extra={IS_HOTKEY_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", ) run_filter: str = Field( default="f11", description="Hotkey to run the filter process. If the item matches no profiles, it is marked as junk.", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) run_filter_drop: str = Field( default="ctrl+f11", description="Hotkey to run the filter process. If the item matches no profiles, it is dropped.", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) run_filter_force_refresh: str = Field( default="shift+f11", description="Hotkey to run the filter process with a force refresh. The status of all junk/favorite items will be reset", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) 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.", json_schema_extra={LIVE_RELOAD_GROUP_KEY: "hotkeys"}, ) @model_validator(mode="after") 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, self.run_filter, self.run_filter_drop, self.run_filter_force_refresh, self.run_vision_mode, ] if len(set(keys)) != len(keys): msg = "hotkeys must be unique" raise ValueError(msg) return self @field_validator( "exit_key", "toggle_paragon_overlay", "force_refresh_only", "move_to_chest", "move_to_inv", "run_filter", "run_filter_drop", "run_filter_force_refresh", "run_vision_mode", ) @classmethod def key_must_exist(cls, k: str) -> str: return validate_hotkey(k) @field_validator("fast_vision_mode_coordinates", mode="before") @classmethod def convert_fast_vision_mode_coordinates(cls, v: str) -> tuple[int, int] | None: if not v: return None if isinstance(v, str): v = v.strip("()") parts = [int(part.strip()) for part in v.replace(",", " ").split()] if len(parts) != 2: msg = "Expected two integers for coordinates." raise ValueError(msg) for x in parts: check_greater_than_zero(x) return parts[0], parts[1] if isinstance(v, tuple) and len(v) == 2 and all(isinstance(x, int) for x in v): for x in v: check_greater_than_zero(x) return v[0], v[1] msg = "vision_mode_coordinates must be a tuple of two integers or blank" raise ValueError(msg) class CharModel(_IniBaseModel): inventory: str = Field( default="i", description="Hotkey in Diablo IV to open inventory", json_schema_extra={IS_HOTKEY_KEY: "True"} ) @field_validator("inventory") @classmethod def key_must_exist(cls, k: str) -> str: return validate_hotkey(k) class ColorsModel(_IniBaseModel): material_color: HSVRangeModel unique_gold: HSVRangeModel unusable_red: HSVRangeModel class GeneralModel(_IniBaseModel): auto_use_temper_manuals: bool = Field( default=True, description="When using the loot filter, should found temper manuals be automatically used? Note: Will not work with stash open.", ) browser: BrowserType = Field(default=BrowserType.chrome, description="Which browser to use to get builds") check_chest_tabs: list[int] = Field( default=[0, 1], description="Which stash tabs to check. Note: All tabs available (6 or 7) must be unlocked!" ) do_not_junk_ancestral_legendaries: bool = Field( default=False, description="Do not mark ancestral legendaries as junk for seasonal challenge" ) full_dump: bool = Field( default=False, description="When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not", ) handle_cosmetics: CosmeticFilterType = Field( default=CosmeticFilterType.ignore, description="What should be done with cosmetic upgrades that do not match any filter", ) handle_uniques: UnfilteredUniquesType = Field( default=UnfilteredUniquesType.favorite, description="What should be done with uniques that do not match any profile. Mythics are always favorited. If mark_as_favorite is unchecked then uniques that match a profile will not be favorited.", ) ignore_escalation_sigils: bool = Field( default=True, description="When filtering Sigils, should escalation sigils be ignored?" ) keep_aspects: AspectFilterType = Field( default=AspectFilterType.upgrade, description="Whether to keep aspects that didn't match a filter" ) language: str = Field( default="enUS", description="Do not change. Only English is supported at this time", json_schema_extra={HIDE_FROM_GUI_KEY: "True", LIVE_RELOAD_GROUP_KEY: "language"}, ) mark_as_favorite: bool = Field(default=True, description="Whether to favorite matched items or not") max_stash_tabs: int = Field( default=6, description="The maximum number of stash tabs you have available to you if you bought them all. If you own the Lord of Hatred expansion you should choose 7. You will need to restart the gui after changing this.", ) minimum_overlay_font_size: int = Field( default=12, description="The minimum font size for the vision overlay, specifically the green text that shows which filter(s) are matching.", ) move_to_inv_item_type: list[MoveItemsType] = Field( default=[MoveItemsType.everything], description="When doing stash/inventory transfer, what types of items should be moved", ) move_to_stash_item_type: list[MoveItemsType] = Field( default=[MoveItemsType.everything], description="When doing stash/inventory transfer, what types of items should be moved", ) profiles: list[str] = Field( default=[], description='Which filter profiles should be run. All .yaml files with "AspectUpgrades", ' '"Affixes", "Uniques", "Sigils", etc sections will be used from ' "C:/Users/USERNAME/.d4lf/profiles/*.yaml", ) run_vision_mode_on_startup: bool = Field(default=True, description="Whether to run vision mode on startup or not") theme: ThemeType = Field(default=ThemeType.dark, description="Choose between light and dark theme for the GUI") colorblind_mode: bool = Field( default=False, description="Enable a colorblind friendly palette for loot filter and paragon overlays" ) vision_mode_type: VisionModeType = Field( default=VisionModeType.highlight_matches, description="Should the vision mode use the slightly slower version that highlights matching affixes, or the immediate version that just shows text of the matches? Note: highlight_matches does not work with controllers.", json_schema_extra={LIVE_RELOAD_GROUP_KEY: "restart_app"}, ) @field_validator("check_chest_tabs", mode="before") @classmethod def check_chest_tabs_index(cls, v: str) -> list[int]: if isinstance(v, str): v = v.split(",") elif not isinstance(v, list): msg = "must be a list or a string" raise ValueError(msg) return sorted([int(x) - 1 for x in v]) @field_validator("max_stash_tabs") @classmethod def check_max_stash_tabs(cls, v: int) -> int: if not 6 <= v <= 7: msg = "must be 6 or 7" raise ValueError(msg) return v @field_validator("profiles", mode="before") @classmethod def check_profiles_is_list(cls, v: str) -> list[str]: if isinstance(v, str): v = v.split(",") elif not isinstance(v, list): msg = "must be a list or a string" raise ValueError(msg) return [profile_name for profile_name in (item.strip() for item in v) if profile_name] @field_validator("language") @classmethod def language_must_exist(cls, v: str) -> str: if v not in ["enUS"]: msg = "language not supported" raise ValueError(msg) return v @field_validator("minimum_overlay_font_size") @classmethod def font_size_in_range(cls, v: int) -> int: if not 10 <= v <= 20: msg = "Font size must be between 10 and 20, inclusive" raise ValueError(msg) return v @field_validator("move_to_inv_item_type", "move_to_stash_item_type", mode="before") @classmethod def convert_move_item_type(cls, v: str) -> list[type[MoveItemsType[Any]]]: if isinstance(v, str): v = v.split(",") elif not isinstance(v, list): msg = "must be a list or a string" raise ValueError(msg) return [MoveItemsType[v.strip()] for v in v] class HSVRangeModel(_IniBaseModel): h_s_v_min: np_array_pydantic_annotated_typing(dimensions=1) h_s_v_max: np_array_pydantic_annotated_typing(dimensions=1) def __getitem__(self, index): # TODO added this to not have to change much of the other code. should be fixed some time if index == 0: return self.h_s_v_min if index == 1: return self.h_s_v_max msg = "Index out of range" raise IndexError(msg) @model_validator(mode="after") def check_interval_sanity(self) -> HSVRangeModel: if self.h_s_v_min[0] > self.h_s_v_max[0]: msg = f"invalid hue range [{self.h_s_v_min[0]}, {self.h_s_v_max[0]}]" raise ValueError(msg) if self.h_s_v_min[1] > self.h_s_v_max[1]: msg = f"invalid saturation range [{self.h_s_v_min[1]}, {self.h_s_v_max[1]}]" raise ValueError(msg) if self.h_s_v_min[2] > self.h_s_v_max[2]: msg = f"invalid value range [{self.h_s_v_min[2]}, {self.h_s_v_max[2]}]" raise ValueError(msg) return self @field_validator("h_s_v_min", "h_s_v_max") @classmethod def values_in_range(cls, v: np.ndarray) -> np.ndarray: if len(v) != 3: msg = "must be h,s,v" raise ValueError(msg) if not -179 <= v[0] <= 179: msg = "must be in [-179, 179]" raise ValueError(msg) if not all(0 <= x <= 255 for x in v[1:3]): msg = "must be in [0, 255]" raise ValueError(msg) return v class UiOffsetsModel(_IniBaseModel): find_bullet_points_width: int find_seperator_short_offset_top: int item_descr_line_height: int item_descr_off_bottom_edge: int item_descr_pad: int item_descr_width: int vendor_center_item_x: int class UiPosModel(_IniBaseModel): possible_centers: list[tuple[int, int]] window_dimensions: tuple[int, int] class UiRoiModel(NumpyModel): rel_descr_search_left: np_array_pydantic_annotated_typing(dimensions=1) rel_descr_search_right: np_array_pydantic_annotated_typing(dimensions=1) rel_fav_flag: np_array_pydantic_annotated_typing(dimensions=1) slots_8x1: np_array_pydantic_annotated_typing(dimensions=1) slots_3x11: np_array_pydantic_annotated_typing(dimensions=1) slots_5x10: np_array_pydantic_annotated_typing(dimensions=1) sort_icon: np_array_pydantic_annotated_typing(dimensions=1) stash_menu_icon: np_array_pydantic_annotated_typing(dimensions=1) tab_slots: np_array_pydantic_annotated_typing(dimensions=1) vendor_menu_icon: np_array_pydantic_annotated_typing(dimensions=1) ================================================ FILE: src/config/ui.py ================================================ import logging import cv2 import numpy as np from src.config.data import POSITIONS, Template, load_templates from src.config.helper import singleton from src.config.settings_models import UiOffsetsModel, UiPosModel, UiRoiModel LOGGER = logging.getLogger("d4lf") class _ResTransformer: def __init__(self, resolution: str): self._target_width, self._target_height = map(int, resolution.split("x")) self._scale_x = self._target_width / POSITIONS[0][0] self._scale_y = self._target_height / POSITIONS[0][1] self._highest_ratio = 27 / 9 def _resize_image(self, src: np.ndarray) -> np.ndarray: height, width = src.shape[:2] return cv2.resize(src=src, dsize=(int(width * self._scale_y), int(height * self._scale_y))) def _transform(self, value: int) -> int: return int(value * self._scale_y) def _transform_array(self, value: np.ndarray, scale_only=False) -> np.ndarray: new_value = value * self._scale_y if scale_only: return new_value.astype(int) # handle widescreen stretching width_org = int(self._scale_y * POSITIONS[0][0]) is_right_side = new_value[0] > width_org / 2 if is_right_side: new_value[0] += self._target_width - width_org # handle black bars aspect_ratio = self._target_width / self._target_height if aspect_ratio > self._highest_ratio: new_width = int(self._target_height * self._highest_ratio) black_bar = (self._target_width - new_width) // 2 new_value[0] = new_value[0] - black_bar if is_right_side else new_value[0] + black_bar return new_value.astype(int) def _transform_list_of_tuples(self, value: list[tuple[int, int]]) -> list[tuple[int, int]]: return [self._transform_tuples(value=v) for v in value] def _transform_templates(self, templates: dict[str, Template]) -> dict[str, Template]: result = {} for key, value in templates.items(): if key.endswith("_special"): # do not transform templates that end with _special result[key] = value else: result[key] = Template( name=value.name, img_bgra=self._resize_image(src=value.img_bgra), img_bgr=self._resize_image(src=value.img_bgr), img_gray=self._resize_image(src=value.img_gray), alpha_mask=self._resize_image(src=value.alpha_mask) if value.alpha_mask is not None else None, ) return result def _transform_tuples(self, value: tuple[int, int]) -> tuple[int, int]: values = self._transform_array(value=np.array(value, dtype=int)) return int(values[0]), int(values[1]) def fromUHD(self) -> tuple[UiOffsetsModel, UiPosModel, UiRoiModel, dict[str, Template]]: offsets = UiOffsetsModel( find_bullet_points_width=self._transform(value=POSITIONS[1].find_bullet_points_width), find_seperator_short_offset_top=self._transform(value=POSITIONS[1].find_seperator_short_offset_top), item_descr_line_height=self._transform(value=POSITIONS[1].item_descr_line_height), item_descr_off_bottom_edge=self._transform(value=POSITIONS[1].item_descr_off_bottom_edge), item_descr_pad=self._transform(value=POSITIONS[1].item_descr_pad), item_descr_width=self._transform(value=POSITIONS[1].item_descr_width), vendor_center_item_x=self._transform(value=POSITIONS[1].vendor_center_item_x), ) pos = UiPosModel( possible_centers=self._transform_list_of_tuples(value=POSITIONS[2].possible_centers), window_dimensions=self._transform_tuples(value=POSITIONS[2].window_dimensions), ) roi = UiRoiModel( rel_descr_search_left=self._transform_array(value=POSITIONS[3].rel_descr_search_left, scale_only=True), rel_descr_search_right=self._transform_array(value=POSITIONS[3].rel_descr_search_right, scale_only=True), rel_fav_flag=self._transform_array(value=POSITIONS[3].rel_fav_flag, scale_only=True), slots_8x1=self._transform_array(value=POSITIONS[3].slots_8x1), slots_3x11=self._transform_array(value=POSITIONS[3].slots_3x11), slots_5x10=self._transform_array(value=POSITIONS[3].slots_5x10), sort_icon=self._transform_array(value=POSITIONS[3].sort_icon), stash_menu_icon=self._transform_array(value=POSITIONS[3].stash_menu_icon), tab_slots=self._transform_array(value=POSITIONS[3].tab_slots), vendor_menu_icon=self._transform_array(value=POSITIONS[3].vendor_menu_icon), ) templates = self._transform_templates(load_templates()) return offsets, pos, roi, templates @singleton class ResManager: def __init__(self): self._current_resolution = "3840x2160" self._offsets = POSITIONS[1] self._pos = POSITIONS[2] self._roi = POSITIONS[3] self._templates = load_templates() @property def offsets(self) -> UiOffsetsModel: return self._offsets @property def pos(self) -> UiPosModel: return self._pos @property def resolution(self) -> tuple[int, ...]: return tuple(map(int, self._current_resolution.split("x"))) @property def roi(self) -> UiRoiModel: return self._roi @property def templates(self) -> dict[str, Template]: return self._templates def set_resolution(self, res: str): if res == self._current_resolution: return self._current_resolution = res LOGGER.info(f"Setting ui resolution to {res}") self._offsets, self._pos, self._roi, self._templates = _ResTransformer(resolution=res).fromUHD() ================================================ FILE: src/dataloader.py ================================================ import json import logging import pathlib import threading from src.config import BASE_DIR from src.config.loader import IniConfigLoader from src.item.data.item_type import ItemType LOGGER = logging.getLogger(__name__) DATALOADER_LOCK = threading.Lock() class Dataloader: affix_dict = {} affix_sigil_dict = {} affix_sigil_dict_all = {} aspect_list = [] aspect_unique_dict = {} bad_tts_uniques = {} error_map = {} filter_after_keyword = [] filter_words = [] item_types_dict = {} tooltips = {} tribute_dict = {} _instance = None data_loaded = False def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) with DATALOADER_LOCK: if not cls._instance.data_loaded: cls._instance.data_loaded = True cls._instance.load_data() return cls._instance def load_data(self): with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/affixes.json").open( encoding="utf-8" ) as f: self.affix_dict: dict = json.load(f) with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/aspects.json").open( encoding="utf-8" ) as f: self.aspect_list = json.load(f) with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/corrections.json").open( encoding="utf-8" ) as f: data = json.load(f) self.error_map = data["error_map"] self.filter_after_keyword = data["filter_after_keyword"] self.filter_words = data["filter_words"] self.bad_tts_uniques = data["bad_tts_uniques"] with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/item_types.json").open( encoding="utf-8" ) as f: data = json.load(f) self.item_types_dict = data for item, value in data.items(): if item in ItemType.__members__: enum_member = ItemType[item] enum_member._value_ = value else: LOGGER.warning(f"{item} type not in item_type.py") with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/sigils.json").open( encoding="utf-8" ) as f: self.affix_sigil_dict_all = json.load(f) self.affix_sigil_dict = { **self.affix_sigil_dict_all["dungeons"], **self.affix_sigil_dict_all["minor"], **self.affix_sigil_dict_all["major"], **self.affix_sigil_dict_all["positive"], } with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/tributes.json").open( encoding="utf-8" ) as f: self.tribute_dict: dict = json.load(f) with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/tooltips.json").open( encoding="utf-8" ) as f: self.tooltips = json.load(f) with pathlib.Path(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/uniques.json").open( encoding="utf-8" ) as f: self.aspect_unique_dict = json.load(f) ================================================ FILE: src/gui/__init__.py ================================================ ================================================ FILE: src/gui/activity_log_widget.py ================================================ 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 class ActivityLogWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(10, 10, 10, 10) self.main_layout.setSpacing(10) # === LOG VIEWER === self.log_viewer = QPlainTextEdit() self.log_viewer.setReadOnly(True) self.log_viewer.setMaximumBlockCount(1000) self.log_viewer.setPlaceholderText("Waiting for d4lf to start scanning...") self.log_viewer.appendPlainText("═" * 80) self.log_viewer.appendPlainText("D4LF - Diablo 4 Loot Filter") self.log_viewer.appendPlainText("═" * 80) self.log_viewer.appendPlainText("") self.main_layout.addWidget(self.log_viewer, stretch=1) # === HOTKEYS PANEL === hotkeys_label = QLabel("Hotkeys:") hotkeys_label.setStyleSheet("font-weight: bold; margin-top: 10px;") self.main_layout.addWidget(hotkeys_label) config = IniConfigLoader() hotkey_text = QLabel() hotkey_text.setMaximumHeight(105) hotkey_text.setWordWrap(True) hotkey_text.setTextFormat(Qt.TextFormat.RichText) hotkey_text.setStyleSheet("margin-left: 5px;") hotkeys_html = "
" if not config.advanced_options.vision_mode_only: hotkeys_html += f"{config.advanced_options.run_vision_mode.upper()}: Run/Stop Vision Mode   " hotkeys_html += ( f"{config.advanced_options.run_filter.upper()}: Run/Stop Auto Filter   " ) hotkeys_html += f"{config.advanced_options.run_filter_drop.upper()}: Run/Stop Auto Filter with Item Drop   " hotkeys_html += ( f"{config.advanced_options.move_to_inv.upper()}: Move Chest → Inventory   " ) hotkeys_html += f"{config.advanced_options.move_to_chest.upper()}: Move Inventory → Chest
" hotkeys_html += f"{config.advanced_options.run_filter_force_refresh.upper()}: Force Filter (Reset Item Status)   " hotkeys_html += f"{config.advanced_options.force_refresh_only.upper()}: Reset Items (No Filter)   " else: 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 += "
" hotkey_text.setText(hotkeys_html) self.main_layout.addWidget(hotkey_text) # === CONTROL BUTTONS === button_layout = QHBoxLayout() button_layout.setSpacing(10) self.import_btn = QPushButton("Import Profile") self.import_btn.setMinimumHeight(40) button_layout.addWidget(self.import_btn) self.settings_btn = QPushButton("Settings") self.settings_btn.setMinimumHeight(40) button_layout.addWidget(self.settings_btn) self.editor_btn = QPushButton("Edit Profile") self.editor_btn.setMinimumHeight(40) button_layout.addWidget(self.editor_btn) self.user_dir_btn = QPushButton("Open User Config Directory") 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.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))) ================================================ FILE: src/gui/collapsible_widget.py ================================================ from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QStackedLayout, QVBoxLayout, QWidget class Header(QWidget): firstExpansion = pyqtSignal() # Signal emitted on first expansion def __init__(self, name, content_widget): super().__init__() self.content = content_widget self.name = name self.expand_ico = ">" # Use text instead of image self.collapse_ico = "v" # Use text instead of image self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # Create a stacked layout to hold the background and widget stacked = QStackedLayout(self) stacked.setStackingMode(QStackedLayout.StackingMode.StackAll) # Create a background label with a specific style sheet background = QLabel() background.setStyleSheet("QLabel{ background-color: rgb(93, 93, 93); padding-top: -20px; border-radius:2px}") # Create a widget and a layout to hold the icon and label widget = QWidget() layout = QHBoxLayout(widget) # Create an icon label and set its text and style sheet self.icon = QLabel() self.icon.setText(self.expand_ico) self.icon.setStyleSheet("QLabel { font-weight: bold; font-size: 20px; color: #ffffff }") layout.addWidget(self.icon) # Add the icon and the label to the layout and set margins layout.addWidget(self.icon) layout.addWidget(self.icon) layout.setContentsMargins(11, 0, 11, 0) # Create a font and a label for the header name font = QFont() font.setBold(True) self.label = QLabel(name) self.label.setStyleSheet("QLabel { margin-top: 5px; }") self.label.setFont(font) # Add the label to the layout and add a spacer for expanding layout.addWidget(self.label) layout.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) # Add the widget and the background to the stacked layout stacked.addWidget(widget) stacked.addWidget(background) # Set the minimum height of the background based on the layout height background.setMinimumHeight(int(layout.sizeHint().height() * 1.5)) self.collapse() self.first_expansion = True def mousePressEvent(self, *args): """Handle mouse events, call the function to toggle groups.""" # Toggle between expand and collapse based on the visibility of the content widget self.expand() if not self.content.isVisible() else self.collapse() def expand(self): """Expand the collapsible group.""" if self.first_expansion: self.firstExpansion.emit() self.first_expansion = False self.content.setVisible(True) self.icon.setText(self.collapse_ico) # Set text instead of pixmap def collapse(self): """Collapse the collapsible group.""" self.content.setVisible(False) self.icon.setText(self.expand_ico) def set_name(self, name): self.name = name self.label.setText(name) class Container(QWidget): firstExpansion = pyqtSignal() # Signal emitted on first expansion def __init__(self, name, color_background=False): super().__init__() # Call the constructor of the parent class layout = QVBoxLayout(self) # Create a QVBoxLayout instance and pass the current object as the parent layout.setContentsMargins(0, 0, 0, 0) # Set the margins of the layout to 0 self._content_widget = ( QWidget() ) # Create a QWidget instance and assign it to the instance variable _content_widget if color_background: # If color_background is True, set the stylesheet of _content_widget to have a lighter background color self._content_widget.setStyleSheet( ".QWidget{background-color: rgb(73, 73, 73); " "margin-left: 2px; padding-top: 20px; margin-right: 2px}" ".QLabel{background-color: rgb(73, 73, 73)}" ) self.header = Header( name, self._content_widget ) # Create a Header instance and pass the name and _content_widget as arguments layout.addWidget(self.header) # Add the header to the layout layout.addWidget(self._content_widget) # Add the _content_widget to the layout self._content_initialized = False # Track initialization state self.header.firstExpansion.connect(self.first_expansion) # assign header methods to instance attributes so they can be called outside of this class self.collapse = ( self.header.collapse ) # Assign the collapse method of the header to the instance attribute collapse self.expand = self.header.expand # Assign the expand method of the header to the instance attribute expand self.toggle = ( self.header.mousePressEvent ) # Assign the mousePressEvent method of the header to the instance attribute toggle @property def contentWidget(self): """Getter for the content widget. Returns: Content widget """ return self._content_widget # Return the _content_widget when the contentWidget property is accessed def first_expansion(self): """Handle first expansion event.""" self.firstExpansion.emit() # Notify about first expansion ================================================ FILE: src/gui/config_tab.py ================================================ import enum import os import subprocess import sys import typing from pathlib import Path from pydantic import BaseModel, ValidationError from PyQt6.QtCore import QCoreApplication, Qt, QTimer from PyQt6.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QTextBrowser, QTextEdit, QVBoxLayout, QWidget, ) from src.config.loader import IniConfigLoader from src.config.settings_models import HIDE_FROM_GUI_KEY, IS_HOTKEY_KEY, MoveItemsType from src.gui.open_user_config_button import OpenUserConfigButton CONFIG_TABNAME = "config" def _validate_and_save_changes( model, header, key, value, method_to_reset_value: typing.Callable | None = None, post_save_callback: typing.Callable[[], None] | None = None, ): current_value = getattr(model, key) try: validated_values = model.model_dump(mode="python") validated_values[key] = value type(model)(**validated_values) IniConfigLoader().save_value(header, key, value) except ValidationError as e: msg = QMessageBox() msg.setIcon(QMessageBox.Icon.Critical) message = f"There was an error setting {key} to {value}. See error below.\n\n" # Only reset the widget if the field is NOT an enum if method_to_reset_value and key != "theme": message = message + "Your value has been reset to its previous version.\n\n" method_to_reset_value(str(current_value)) message = message + str(e) msg.setText(message) msg.setWindowTitle("Error validating value") msg.setStandardButtons(QMessageBox.StandardButton.Ok) msg.exec() return False if post_save_callback and str(current_value) != str(value): post_save_callback() return True class ConfigTab(QWidget): def __init__(self, theme_changed_callback=None): self._initializing = True super().__init__() self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.theme_changed_callback = theme_changed_callback self.model_to_parameter_value_map = {} layout = QVBoxLayout(self) scrollable_layout = QVBoxLayout() scroll_widget = QWidget() scroll_area = QScrollArea(self) scroll_area.setWidgetResizable(True) button_hbox = QHBoxLayout() button_hbox.addWidget(self._setup_reset_button()) button_hbox.addWidget(OpenUserConfigButton()) scrollable_layout.addLayout(button_hbox) scrollable_layout.addWidget(self._generate_params_section(IniConfigLoader().general, "General", "general")) scrollable_layout.addWidget(self._generate_params_section(IniConfigLoader().char, "Character", "char")) scrollable_layout.addWidget( self._generate_params_section(IniConfigLoader().advanced_options, "Advanced", "advanced_options") ) scroll_widget.setLayout(scrollable_layout) scroll_area.setWidget(scroll_widget) layout.addWidget(scroll_area) instructions_label = QLabel("Instructions") layout.addWidget(instructions_label) instructions_text = QTextBrowser() instructions_text.setOpenExternalLinks(True) instructions_text.append( "All values are saved automatically immediately upon changing. Hover over any label/field to see a brief " "description of what it is for. To read more about each parameter, please view " "the config portion of the readme" ) instructions_text.setFixedHeight(80) layout.addWidget(instructions_text) self.setLayout(layout) QTimer.singleShot(0, self._finish_init) def _finish_init(self): self._initializing = False def _prompt_restart_for_vision_mode_change(self) -> None: msg = QMessageBox(self) msg.setIcon(QMessageBox.Icon.Question) msg.setWindowTitle("Restart required") msg.setText("Vision mode changes require restarting d4lf. Restart now?") restart_button = msg.addButton("Restart now", QMessageBox.ButtonRole.AcceptRole) msg.addButton("Later", QMessageBox.ButtonRole.RejectRole) msg.exec() if msg.clickedButton() is restart_button: self._restart_application() def _restart_application(self) -> None: command = [sys.executable, *sys.argv[1:]] if getattr(sys, "frozen", False) else [sys.executable, *sys.argv] creationflags = 0 if os.name == "nt": creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) try: subprocess.Popen(command, cwd=Path.cwd(), creationflags=creationflags) except OSError: msg = QMessageBox(self) msg.setIcon(QMessageBox.Icon.Critical) msg.setWindowTitle("Restart failed") msg.setText("d4lf could not be restarted automatically. Please restart it manually.") msg.setStandardButtons(QMessageBox.StandardButton.Ok) msg.exec() return if app := QCoreApplication.instance(): app.quit() def _generate_params_section(self, model: BaseModel, section_readable_header: str, section_config_header: str): group_box = QGroupBox(section_readable_header) form_layout = QFormLayout() all_parameter_metadata = model.model_json_schema()["properties"] for parameter in model: config_key, config_value = parameter parameter_metadata = all_parameter_metadata[config_key] hide_from_gui = parameter_metadata.get(HIDE_FROM_GUI_KEY) if hide_from_gui: continue description_text = parameter_metadata.get("description") is_hotkey = parameter_metadata.get(IS_HOTKEY_KEY) parameter_value_widget = self._generate_parameter_value_widget( model, section_config_header, config_key, config_value, is_hotkey ) self.model_to_parameter_value_map[section_config_header + "." + config_key] = parameter_value_widget config_with_desc = QLabel(config_key) if description_text: # The span is a hack to make the tooltip wordwrap config_with_desc.setToolTip("" + description_text + "") parameter_value_widget.setToolTip("" + description_text + "") form_layout.addRow(config_with_desc, parameter_value_widget) group_box.setLayout(form_layout) return group_box def _generate_parameter_value_widget( self, model: BaseModel, section_config_header, config_key, config_value, is_hotkey ): if config_key == "check_chest_tabs": parameter_value_widget = QChestTabWidget( model, section_config_header, config_key, config_value, IniConfigLoader().general.max_stash_tabs ) elif config_key == "max_stash_tabs": parameter_value_widget = IgnoreScrollWheelComboBox() parameter_value_widget.addItems(["6", "7"]) parameter_value_widget.setCurrentText(str(config_value)) parameter_value_widget.currentTextChanged.connect( lambda: _validate_and_save_changes( model, section_config_header, config_key, parameter_value_widget.currentText() ) ) elif config_key == "profiles": parameter_value_widget = QProfilesWidget(model, section_config_header, config_key, config_value) elif config_key in {"move_to_inv_item_type", "move_to_stash_item_type"}: parameter_value_widget = QMoveItemsWidget(model, section_config_header, config_key, config_value) elif is_hotkey: parameter_value_widget = QHotkeyWidget(model, section_config_header, config_key, config_value) elif isinstance(config_value, enum.StrEnum): parameter_value_widget = IgnoreScrollWheelComboBox() enum_type = type(config_value) # Block signals during initialization so we don't fire theme change with the old value parameter_value_widget.blockSignals(True) parameter_value_widget.addItems(list(enum_type)) parameter_value_widget.setCurrentText(config_value) parameter_value_widget.blockSignals(False) def make_on_enum_changed(key): def on_enum_changed(): _validate_and_save_changes( model, section_config_header, key, parameter_value_widget.currentText(), post_save_callback=( self._prompt_restart_for_vision_mode_change if key == "vision_mode_type" and not self._initializing else None ), ) if key == "theme" and self.theme_changed_callback and not self._initializing: self.theme_changed_callback() return on_enum_changed parameter_value_widget.currentTextChanged.connect(make_on_enum_changed(config_key)) elif isinstance(config_value, bool): parameter_value_widget = QCheckBox() parameter_value_widget.setChecked(config_value) parameter_value_widget.stateChanged.connect( lambda: _validate_and_save_changes( model, section_config_header, config_key, str(parameter_value_widget.isChecked()) ) ) else: parameter_value_widget = QLineEdit(str(config_value)) parameter_value_widget.editingFinished.connect( lambda: _validate_and_save_changes( model, section_config_header, config_key, parameter_value_widget.text(), method_to_reset_value=parameter_value_widget.setText, ) ) return parameter_value_widget def show_tab(self): self._reset_values_for_model(IniConfigLoader().general, "general") self._reset_values_for_model(IniConfigLoader().char, "char") self._reset_values_for_model(IniConfigLoader().advanced_options, "advanced_options") def reset_button_click(self): msg = QMessageBox() msg.setIcon(QMessageBox.Icon.Warning) message = "This will reset all custom values in your params.ini to their default value. Are you sure you want to continue?" msg.setText(message) msg.setWindowTitle("Reset to default values?") msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) result = msg.exec() # Store the result of msg.exec() if result == QMessageBox.StandardButton.Ok: IniConfigLoader().load(clear=True) self._reset_values_for_model(IniConfigLoader().general, "general") self._reset_values_for_model(IniConfigLoader().char, "char") self._reset_values_for_model(IniConfigLoader().advanced_options, "advanced_options") def _reset_values_for_model(self, model, section_config_header): for parameter in model: config_key, config_value = parameter parameter_value_widget = self.model_to_parameter_value_map.get(section_config_header + "." + config_key) # Should always exist but just being safe if parameter_value_widget is None: continue if isinstance(parameter_value_widget, QChestTabWidget | QProfilesWidget | QHotkeyWidget | QMoveItemsWidget): parameter_value_widget.reset_values(config_value) elif isinstance(parameter_value_widget, IgnoreScrollWheelComboBox): parameter_value_widget.blockSignals(True) parameter_value_widget.reset_values(config_value) parameter_value_widget.blockSignals(False) elif isinstance(parameter_value_widget, QCheckBox): parameter_value_widget.setChecked(config_value) else: parameter_value_widget.setText(str(config_value)) def _setup_reset_button(self) -> QPushButton: reset_button = QPushButton("Reset to defaults") reset_button.clicked.connect(self.reset_button_click) return reset_button class IgnoreScrollWheelComboBox(QComboBox): def __init__(self): super().__init__() self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) def wheelEvent(self, event): if self.hasFocus(): return QComboBox.wheelEvent(self, event) return event.ignore() def reset_values(self, value): self.blockSignals(True) self.setCurrentText(str(value)) self.blockSignals(False) class QChestTabWidget(QWidget): def __init__(self, model, section_header, config_key, chest_tab_config: list[int], max_chest_tabs): super().__init__() self.all_checkboxes: list[QCheckBox] = [] stash_checkbox_layout = QHBoxLayout() stash_checkbox_layout.setContentsMargins(0, 0, 0, 0) for x in range(max_chest_tabs): stash_checkbox = QCheckBox(self) stash_checkbox.setText(str(x + 1)) self.all_checkboxes.append(stash_checkbox) if x in chest_tab_config: stash_checkbox.setChecked(True) stash_checkbox.stateChanged.connect( lambda: self._save_changes_on_box_change(model, section_header, config_key) ) stash_checkbox_layout.addWidget(stash_checkbox) self.setLayout(stash_checkbox_layout) def reset_values(self, chest_tab_config: list[int]): for check_box in self.all_checkboxes: check_box.setChecked(int(check_box.text()) - 1 in chest_tab_config) def _save_changes_on_box_change(self, model, section_header, config_key): active_tabs = [check_box.text() for check_box in self.all_checkboxes if check_box.isChecked()] _validate_and_save_changes(model, section_header, config_key, ",".join(active_tabs), self.reset_values) class QMoveItemsWidget(QWidget): def __init__(self, model, section_header, config_key, move_selections: list[MoveItemsType]): super().__init__() layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.current_move_selections_line_edit = QLineEdit() self.reset_values(move_selections) self.current_move_selections_line_edit.setReadOnly(True) layout.addWidget(self.current_move_selections_line_edit) open_picker_button = QPushButton() open_picker_button.setText("...") open_picker_button.setMinimumWidth(20) open_picker_button.clicked.connect( lambda: self._launch_picker( model, section_header, config_key, self.current_move_selections_line_edit.text().split(", ") ) ) layout.addWidget(open_picker_button) self.setLayout(layout) def reset_values(self, move_selections: list[MoveItemsType]): self.current_move_selections_line_edit.setText(", ".join([item_type.name for item_type in move_selections])) def _launch_picker(self, model, section_header, config_key, move_selections): move_item_type_picker = QMoveItemsPicker(self, move_selections) if move_item_type_picker.exec(): move_types = move_item_type_picker.get_selected_move_types() move_types_string = ", ".join([item_type.name for item_type in move_types]) _validate_and_save_changes( model, section_header, config_key, move_types_string, self.current_move_selections_line_edit.setText ) self.reset_values(move_types) class QMoveItemsPicker(QDialog): def __init__(self, parent, move_selections): super().__init__(parent) self.setWindowTitle("Select item types") layout = QVBoxLayout() label = QLabel("Select which item types you would like to move when hotkey is pressed.") self.move_favorite_box = QCheckBox("Favorite") self.move_junk_box = QCheckBox("Junk") self.move_unmarked_box = QCheckBox("Unmarked") self.move_favorite_box.setChecked( MoveItemsType.everything.name in move_selections or MoveItemsType.favorites.name in move_selections ) self.move_junk_box.setChecked( MoveItemsType.everything.name in move_selections or MoveItemsType.junk.name in move_selections ) self.move_unmarked_box.setChecked( MoveItemsType.everything.name in move_selections or MoveItemsType.unmarked.name in move_selections ) layout.addWidget(label) layout.addWidget(self.move_favorite_box) layout.addWidget(self.move_junk_box) layout.addWidget(self.move_unmarked_box) ok_cancel_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(ok_cancel_buttons) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) layout.addWidget(self.buttonBox) self.setLayout(layout) def get_selected_move_types(self) -> list[MoveItemsType]: result = [] if self.move_favorite_box.isChecked(): result.append(MoveItemsType.favorites) if self.move_junk_box.isChecked(): result.append(MoveItemsType.junk) if self.move_unmarked_box.isChecked(): result.append(MoveItemsType.unmarked) if not result or len(result) == 3: return [MoveItemsType.everything] return result class QProfilesWidget(QWidget): def __init__(self, model, section_header, config_key, current_profiles): super().__init__() layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.current_profile_line_edit = QLineEdit() self.reset_values(current_profiles) self.current_profile_line_edit.setReadOnly(True) layout.addWidget(self.current_profile_line_edit) open_picker_button = QPushButton() open_picker_button.setText("...") open_picker_button.setMinimumWidth(20) open_picker_button.clicked.connect( lambda: self._launch_picker( model, section_header, config_key, self.current_profile_line_edit.text().split(", ") ) ) layout.addWidget(open_picker_button) self.setLayout(layout) def reset_values(self, current_profiles): self.current_profile_line_edit.setText(", ".join(current_profiles)) def _launch_picker(self, model, section_header, config_key, current_profiles): profile_picker = QProfilePicker(self, current_profiles) if profile_picker.exec(): selected_profiles = ", ".join(profile_picker.get_selected_profiles()) _validate_and_save_changes( model, section_header, config_key, selected_profiles, self.current_profile_line_edit.setText ) self.current_profile_line_edit.setText(selected_profiles) class QProfilePicker(QDialog): def __init__(self, parent, current_profiles): super().__init__(parent) self.setWindowTitle("Select profiles") overall_layout = QVBoxLayout() self.setGeometry(0, 0, 700, 500) profile_folder = IniConfigLoader().user_dir / "profiles" if not Path.exists(profile_folder): Path.mkdir(profile_folder) all_profile_files = profile_folder.iterdir() all_profiles = [ os.path.splitext(profile_file.name)[0] for profile_file in all_profile_files if profile_file.is_file() ] all_profiles.sort(key=str.lower) self.disabled_profiles_list_widget = QListWidget() self.disabled_profiles_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.disabled_profiles_list_widget.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) self.disabled_profiles_list_widget.setDefaultDropAction(Qt.DropAction.MoveAction) self.enabled_profiles_list_widget = QListWidget() self.enabled_profiles_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) self.enabled_profiles_list_widget.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) self.enabled_profiles_list_widget.setDefaultDropAction(Qt.DropAction.MoveAction) for profile_name in all_profiles: if profile_name not in current_profiles: QListWidgetItem(profile_name, self.disabled_profiles_list_widget) for profile_name in current_profiles: if profile_name in all_profiles: QListWidgetItem(profile_name, self.enabled_profiles_list_widget) list_widget_layout = QGridLayout() list_widget_layout.addWidget(QLabel("Disabled Profiles"), 0, 0) list_widget_layout.addWidget(self.disabled_profiles_list_widget, 1, 0) # Create buttons for moving profiles between lists enable_button = QPushButton("Enable") enable_button.clicked.connect( lambda: self.move_items(self.disabled_profiles_list_widget, self.enabled_profiles_list_widget) ) disable_button = QPushButton("Disable") disable_button.clicked.connect( lambda: self.move_items(self.enabled_profiles_list_widget, self.disabled_profiles_list_widget) ) list_widget_layout.addWidget(enable_button, 2, 0) list_widget_layout.addWidget(disable_button, 2, 1) list_widget_layout.addWidget(QLabel("Enabled Profiles"), 0, 1) list_widget_layout.addWidget(self.enabled_profiles_list_widget, 1, 1) overall_layout.addLayout(list_widget_layout) message = QTextEdit( "Enable/Disable profiles by selecting and then using drag&drop or the buttons.\n" "Multi select is supported.\n" "You can change order by dragging a profile up and down in the right list." ) message.setReadOnly(True) message.setFixedHeight(70) overall_layout.addWidget(message) ok_cancel_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(ok_cancel_buttons) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) overall_layout.addWidget(self.buttonBox) self.setLayout(overall_layout) def move_items(self, source_list, destination_list): for item in source_list.selectedItems(): source_list.takeItem(source_list.row(item)) destination_list.addItem(item) def get_selected_profiles(self): return [ self.enabled_profiles_list_widget.item(x).text() for x in range(self.enabled_profiles_list_widget.count()) ] class QHotkeyWidget(QWidget): def __init__(self, model, section_header, config_key, current_value): super().__init__() layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.open_picker_button = QPushButton() self.reset_values(current_value) self.open_picker_button.clicked.connect(lambda: self._launch_hotkey_dialog(model, section_header, config_key)) self.open_picker_button.setProperty("hotkeyButton", True) layout.addWidget(self.open_picker_button) self.setLayout(layout) def reset_values(self, current_value): self.open_picker_button.setText(current_value) def _launch_hotkey_dialog(self, model, section_header, config_key): hotkey_dialog = HotkeyListenerDialog(self) if hotkey_dialog.exec(): new_hotkey = hotkey_dialog.get_hotkey() if new_hotkey and _validate_and_save_changes(model, section_header, config_key, new_hotkey): self.open_picker_button.setText(new_hotkey) class HotkeyListenerDialog(QDialog): def __init__(self, parent=None, hotkey=""): super().__init__(parent) self.setWindowTitle("Set Hotkey") self.hotkey = hotkey self.layout = QVBoxLayout(self) self.label = QLabel("Press the key or combination of keys you\nwant to use as a hotkey, then click save.", self) self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.hotkey_label = QLabel(self.hotkey, self) self.hotkey_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout.addWidget(self.label) self.layout.addWidget(self.hotkey_label) self.button_layout = QHBoxLayout() self.save_button = QPushButton("Save", self) self.cancel_button = QPushButton("Cancel", self) self.save_button.clicked.connect(self.accept) self.cancel_button.clicked.connect(self.reject) self.button_layout.addWidget(self.save_button) self.button_layout.addWidget(self.cancel_button) self.layout.addLayout(self.button_layout) def keyPressEvent(self, event): modifiers = [] if event.modifiers() & Qt.KeyboardModifier.ControlModifier: modifiers.append("ctrl") if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: modifiers.append("shift") if event.modifiers() & Qt.KeyboardModifier.AltModifier: modifiers.append("alt") key = event.key() # Handle function keys if Qt.Key.Key_F1 <= key <= Qt.Key.Key_F35: non_mod_key = f"f{key - Qt.Key.Key_F1 + 1}" # Handle regular keys else: text = event.text().lower() non_mod_key = text or "" # Build final hotkey string parts = modifiers + ([non_mod_key] if non_mod_key else []) hotkey_str = "+".join(parts) self.hotkey = hotkey_str self.hotkey_label.setText(hotkey_str) def get_hotkey(self): return self.hotkey ================================================ FILE: src/gui/config_window.py ================================================ import logging import sys from pathlib import Path from PyQt6.QtCore import QPoint, QSettings, QSize, Qt from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QMainWindow from src.gui.config_tab import ConfigTab BASE_DIR = Path(sys.executable).parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent.parent ICON_PATH = BASE_DIR / "assets" / "logo.png" LOGGER = logging.getLogger(__name__) class ConfigWindow(QMainWindow): """Standalone window for Config/Settings.""" def __init__(self, parent=None, theme_changed_callback=None): super().__init__(parent) if ICON_PATH.exists(): self.setWindowIcon(QIcon(str(ICON_PATH))) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.theme_changed_callback = theme_changed_callback self.settings = QSettings("d4lf", "config") self.setWindowTitle("Settings") self.resize(self.settings.value("size", QSize(650, 800))) self.move(self.settings.value("pos", QPoint(0, 0))) if self.settings.value("maximized", "false") == "true": self.showMaximized() # Create initial config tab self.config_tab = ConfigTab(theme_changed_callback=self._on_theme_changed) self.setCentralWidget(self.config_tab) def _on_theme_changed(self): if self.theme_changed_callback: self.theme_changed_callback() # Rebuild the tab so the settings window updates visually too self._rebuild_tab() def _rebuild_tab(self): old_tab = self.config_tab self.config_tab = ConfigTab(theme_changed_callback=self._on_theme_changed) self.setCentralWidget(self.config_tab) old_tab.deleteLater() def closeEvent(self, event): """Save window size/position.""" if not self.isMaximized(): self.settings.setValue("size", self.size()) self.settings.setValue("pos", self.pos()) self.settings.setValue("maximized", self.isMaximized()) event.accept() ================================================ FILE: src/gui/d4lfitem.py ================================================ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QComboBox, QCompleter, QFormLayout, QGroupBox, QHeaderView, QLabel, QMessageBox, QSizePolicy, QTableView, QVBoxLayout, ) from src.config.profile_models import AffixFilterCountModel, AffixFilterModel, DynamicItemFilterModel, ItemFilterModel from src.gui.dialog import IgnoreScrollWheelComboBox, IgnoreScrollWheelSpinBox from src.gui.importer.gui_common import MAX_POWER class D4LFItem(QGroupBox): def __init__(self, item: DynamicItemFilterModel, affixesNames, allItemTypes): super().__init__() self.item_name = next(iter(item.root.keys())) self.item = item self.item_types = self.item.root[self.item_name].itemType self.affix_pool = self.item.root[self.item_name].affixPool self.inherent_pool = self.item.root[self.item_name].inherentPool self.min_power = self.item.root[self.item_name].minPower self.changed = False self.affixesNames = affixesNames self.allItemTypes = allItemTypes self.setTitle(self.item_name) self.setStyleSheet( "QGroupBox {font-size: 10pt;} QLabel {font-size: 10pt;} IgnoreScrollWheelComboBox {font-size: 10pt;} IgnoreScrollWheelSpinBox {font-size: 10pt;}" ) self.setMaximumSize(300, 500) self.main_layout = QVBoxLayout() self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.form_layout = QFormLayout() self.item_type_label = QLabel("Item Types:") self.item_type_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.item_type_label_info = QLabel( ", ".join([self.find_item_from_value(item_type.value) for item_type in self.item_types]) ) self.item_type_label_info.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.form_layout.addRow(self.item_type_label, self.item_type_label_info) self.minPowerEdit = IgnoreScrollWheelSpinBox() self.minPowerEdit.setMaximum(MAX_POWER) self.minPowerEdit.setMaximumWidth(75) self.minPowerEdit.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.form_layout.addRow(QLabel("minPower:"), self.minPowerEdit) self.main_layout.addLayout(self.form_layout) self.affixListLayout = None self.inherentListLayout = None if self.affix_pool: self.affixes_label = QLabel("Affixes:") self.affixes_label.setMaximumSize(200, 50) self.affixes_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.main_layout.addWidget(self.affixes_label) self.affixListLayout = QVBoxLayout() self.main_layout.addLayout(self.affixListLayout) if self.inherent_pool: self.inherent_label = QLabel("Inherent:") self.inherent_label.setMaximumSize(200, 50) self.inherent_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.main_layout.addWidget(self.inherent_label) self.inherentListLayout = QVBoxLayout() self.main_layout.addLayout(self.inherentListLayout) self.load_item() self.setLayout(self.main_layout) self.minPowerEdit.valueChanged.connect(self.item_changed) def load_item(self): self.minPowerEdit.setValue(self.min_power) for pool in self.affix_pool: for affix in pool.count: affixComboBox = self.create_affix_combobox(affix.name) self.affixListLayout.addWidget(affixComboBox) if pool.minCount is not None and pool.minGreaterAffixCount is not None: layout = self.create_form_layout(pool.minCount, pool.minGreaterAffixCount) self.affixListLayout.addLayout(layout) for pool in self.inherent_pool: for affix in pool.count: affixComboBox = self.create_affix_combobox(affix.name) self.inherentListLayout.addWidget(affixComboBox) def create_affix_combobox(self, affix_name): affixComboBox = IgnoreScrollWheelComboBox() affixComboBox.setEditable(True) affixComboBox.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) affixComboBox.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) table_view = QTableView() table_view.horizontalHeader().setVisible(False) table_view.verticalHeader().setVisible(False) table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) affixComboBox.setView(table_view) affixComboBox.addItems(self.affixesNames.values()) for i, affixes in enumerate(self.affixesNames.values()): affixComboBox.setItemData(i, affixes, Qt.ItemDataRole.ToolTipRole) key_list = list(self.affixesNames.keys()) try: idx = key_list.index(affix_name) except ValueError: self.create_alert(f"{affix_name} is not a valid affix.") return affixComboBox affixComboBox.setCurrentIndex(idx) affixComboBox.setMaximumWidth(250) affixComboBox.currentTextChanged.connect(self.item_changed) return affixComboBox def create_alert(self, msg: str): reply = QMessageBox.warning(self, "Alert", msg, QMessageBox.StandardButton.Ok) return reply == QMessageBox.StandardButton.Ok def create_form_layout(self, minCount, minGreaterAffixCount): ret = QFormLayout() mincount_label = QLabel("minCount:") mincount_spinBox = IgnoreScrollWheelSpinBox() mincount_spinBox.setMaximum(3) mincount_spinBox.setValue(minCount) mincount_spinBox.setMaximumWidth(60) mincount_spinBox.valueChanged.connect(self.item_changed) ret.addRow(mincount_label, mincount_spinBox) mingreater_label = QLabel("minGreaterAffixCount:") mingreater_spinBox = IgnoreScrollWheelSpinBox() mingreater_spinBox.setMaximum(3) mingreater_spinBox.setValue(minGreaterAffixCount) mingreater_spinBox.setMaximumWidth(60) mingreater_spinBox.valueChanged.connect(self.item_changed) ret.addRow(mingreater_label, mingreater_spinBox) return ret def set_minPower(self, minPower): self.minPowerEdit.setValue(minPower) def set_minGreaterAffix(self, minGreaterAffix): for i in range(self.affixListLayout.count()): layout = self.affixListLayout.itemAt(i).layout() if layout is not None and isinstance(layout, QFormLayout): layout.itemAt(3).widget().setValue(minGreaterAffix) def set_minCount(self, minCount): for i in range(self.affixListLayout.count()): layout = self.affixListLayout.itemAt(i).layout() if layout is not None and isinstance(layout, QFormLayout): layout.itemAt(1).widget().setValue(minCount) def find_affix_from_value(self, target_value): for key, value in self.affixesNames.items(): if value == target_value: return key return None def find_item_from_value(self, target_value): for key, value in self.allItemTypes.items(): if value == target_value: return key return None def save_item(self): self.min_power = self.minPowerEdit.value() for pool in self.affix_pool: for i in range(self.affixListLayout.count()): widget = self.affixListLayout.itemAt(i).widget() layout = self.affixListLayout.itemAt(i).layout() if widget is not None: if isinstance(widget, IgnoreScrollWheelComboBox): pool.count[i] = AffixFilterModel(name=self.find_affix_from_value(widget.currentText())) elif layout is not None and isinstance(layout, QFormLayout): pool.minCount = layout.itemAt(1).widget().value() pool.minGreaterAffixCount = layout.itemAt(3).widget().value() for pool in self.inherent_pool: for i in range(self.inherentListLayout.count()): widget = self.inherentListLayout.itemAt(i).widget() if isinstance(widget, IgnoreScrollWheelComboBox): pool.count[i] = AffixFilterModel(name=self.find_affix_from_value(widget.currentText())) self.changed = False self.item.root[self.item_name].affixPool = self.affix_pool if self.inherent_pool: self.item.root[self.item_name].inherentPool = self.inherent_pool self.item.root[self.item_name].minPower = self.min_power return self.item def save_item_create(self): new_item = ItemFilterModel() new_item.itemType = self.item_types new_item.minPower = self.minPowerEdit.value() new_item.affixPool = [] new_item.inherentPool = [] affix_filter_count_list = [] minCount = 0 minGreaterAffixCount = 0 for i in range(self.affixListLayout.count()): widget = self.affixListLayout.itemAt(i).widget() layout = self.affixListLayout.itemAt(i).layout() if widget is not None: if isinstance(widget, IgnoreScrollWheelComboBox): affix_filter_count_list.append( AffixFilterModel(name=self.find_affix_from_value(widget.currentText())) ) elif layout is not None and isinstance(layout, QFormLayout): minCount = layout.itemAt(1).widget().value() minGreaterAffixCount = layout.itemAt(3).widget().value() affix_filter_count = AffixFilterCountModel( minCount=minCount, minGreaterAffixCount=minGreaterAffixCount, count=affix_filter_count_list ) new_item.affixPool.append(affix_filter_count) if self.inherentListLayout: inherent_filter_count_list = [] for i in range(self.inherentListLayout.count()): widget = self.inherentListLayout.itemAt(i).widget() if isinstance(widget, IgnoreScrollWheelComboBox): inherent_filter_count_list.append( AffixFilterModel(name=self.find_affix_from_value(widget.currentText())) ) inherent_filter_count = AffixFilterCountModel(count=inherent_filter_count_list) new_item.inherentPool.append(inherent_filter_count) return DynamicItemFilterModel(**{self.item_name: new_item}) def item_changed(self): self.changed = True def has_changes(self): return self.changed ================================================ FILE: src/gui/dialog.py ================================================ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QCompleter, QDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QVBoxLayout, QWidget, ) from src.config.profile_models import ( AffixFilterCountModel, AffixFilterModel, DynamicItemFilterModel, ItemFilterModel, ItemRarity, TributeFilterModel, ) from src.dataloader import Dataloader from src.gui.config_tab import IgnoreScrollWheelComboBox from src.gui.importer.gui_common import MAX_POWER from src.item.data.item_type import ItemType class IgnoreScrollWheelSpinBox(QSpinBox): def __init__(self): super().__init__() self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) def wheelEvent(self, event): if self.hasFocus(): return QSpinBox.wheelEvent(self, event) return event.ignore() class MinPowerDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Set Min Power") self.setFixedSize(250, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.label = QLabel("Min Power:") self.spinBox = IgnoreScrollWheelSpinBox() self.spinBox.setRange(0, MAX_POWER) self.spinBox.setValue(MAX_POWER) self.form_layout.addRow(self.label, self.spinBox) self.main_layout.addLayout(self.form_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return self.spinBox.value() class MinGreaterDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Set Min Greater Affix") self.setFixedSize(250, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.label = QLabel("Min Greater Affix:") self.spinBox = IgnoreScrollWheelSpinBox() self.spinBox.setRange(0, 4) self.spinBox.setValue(0) self.form_layout.addRow(self.label, self.spinBox) self.main_layout.addLayout(self.form_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return self.spinBox.value() class MinPercentDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Set Min Percent Of Affix") self.setFixedSize(250, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.label = QLabel("Min Percent Of Affix:") self.spinBox = IgnoreScrollWheelSpinBox() self.spinBox.setRange(0, 100) self.spinBox.setValue(70) self.form_layout.addRow(self.label, self.spinBox) self.main_layout.addLayout(self.form_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return self.spinBox.value() class CreateItem(QDialog): def __init__(self, item_list: list[str], parent=None): super().__init__(parent) self.setWindowTitle("Create Item") self.setFixedSize(300, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.name_label = QLabel("Item Name:") self.name_input = QLineEdit() self.form_layout.addRow(self.name_label, self.name_input) self.item_list = item_list self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def accept(self): if not self.name_input.text(): QMessageBox.warning(self, "Warning", "Item name cannot be empty") return if self.name_input.text() in self.item_list: QMessageBox.warning(self, "Warning", "Item name already exist") return super().accept() def get_value(self): item_name = self.name_input.text() item_type = ItemType.Amulet item = ItemFilterModel() item.itemType = [item_type] item.affixPool = [ AffixFilterCountModel(count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], minCount=2) ] item.minPower = 100 return DynamicItemFilterModel(**{item_name: item}) class DeleteItem(QDialog): def __init__(self, item_names, parent=None): super().__init__(parent) self.setWindowTitle("Delete Items") self.setFixedSize(300, 200) self.main_layout = QVBoxLayout() self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.groupbox = QGroupBox("Items") scroll_area = QScrollArea(self) scroll_widget = QWidget(scroll_area) scrollable_layout = QVBoxLayout(scroll_widget) self.groupbox_layout = QVBoxLayout() label = QLabel("Select items to delete:") label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.groupbox_layout.addWidget(label) self.checkbox_list = [] for name in item_names: checkbox = QCheckBox(name) scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) scroll_area.setWidget(scroll_widget) self.groupbox_layout.addWidget(scroll_area) self.groupbox.setLayout(self.groupbox_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()] class DeleteAffixPool(QDialog): def __init__(self, nb_affix_pool, inherent: bool = False, parent=None): super().__init__(parent) if inherent: self.setWindowTitle("Delete Inherent Pool") self.groupbox = QGroupBox("Inherent Pool") else: self.setWindowTitle("Delete Affix Pool") self.groupbox = QGroupBox("Affix Pool") self.setFixedSize(300, 200) self.main_layout = QVBoxLayout() self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) scroll_area = QScrollArea(self) scroll_widget = QWidget(scroll_area) scrollable_layout = QVBoxLayout(scroll_widget) self.groupbox_layout = QVBoxLayout() label = QLabel("Select items to delete:") label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.groupbox_layout.addWidget(label) self.checkbox_list = [] for i in range(nb_affix_pool): checkbox = QCheckBox(f"Count {i}") scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) scroll_area.setWidget(scroll_widget) self.groupbox_layout.addWidget(scroll_area) self.groupbox.setLayout(self.groupbox_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()] class CreateSigil(QDialog): def __init__(self, whitelist_sigils: list[str], blacklist_sigils: list[str], parent=None): super().__init__(parent) self.whitelist_sigils = whitelist_sigils self.blacklist_sigils = blacklist_sigils self.setWindowTitle("Create Sigil") self.setFixedSize(300, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.name_label = QLabel("Dungeon:") self.name_input = IgnoreScrollWheelComboBox() self.name_input.setEditable(True) self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_input.addItems(sorted(Dataloader().affix_sigil_dict_all["dungeons"].values())) self.type_label = QLabel("Type: ") self.type_input = IgnoreScrollWheelComboBox() self.type_input.setEditable(True) self.type_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.type_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.type_input.addItems(["whitelist", "blacklist"]) self.form_layout.addRow(self.name_label, self.name_input) self.form_layout.addRow(self.type_label, self.type_input) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def accept(self): if self.type_input.currentText() == "whitelist" and self.name_input.currentText() in self.whitelist_sigils: QMessageBox.warning(self, "Warning", "Sigil already exist in whitelist. You can modify the existing one.") return if self.type_input.currentText() == "blacklist" and self.name_input.currentText() in self.blacklist_sigils: QMessageBox.warning(self, "Warning", "Sigil already exist in whitelist. You can modify the existing one.") return super().accept() def get_value(self): sigil_name = self.name_input.currentText() type_name = self.type_input.currentText() return sigil_name, type_name class RemoveSigil(QDialog): def __init__(self, sigils: list[str], blacklist: bool = False, parent=None): super().__init__(parent) self.sigils = sigils if blacklist: self.setWindowTitle("Delete Blacklist Sigil") self.groupbox = QGroupBox("Blacklist") else: self.setWindowTitle("Delete Whitelist Sigil") self.groupbox = QGroupBox("Whitelist") self.setFixedSize(300, 300) self.main_layout = QVBoxLayout() self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) scroll_area = QScrollArea(self) scroll_widget = QWidget(scroll_area) scrollable_layout = QVBoxLayout(scroll_widget) self.groupbox_layout = QVBoxLayout() label = QLabel("Select Sigils to delete:") label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.groupbox_layout.addWidget(label) self.checkbox_list = [] for sigil in self.sigils: checkbox = QCheckBox(sigil) scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) scroll_area.setWidget(scroll_widget) self.groupbox_layout.addWidget(scroll_area) self.groupbox.setLayout(self.groupbox_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()] class CreateTribute(QDialog): def __init__(self, tributes: list[str], parent=None): super().__init__(parent) self.tributes = tributes self.setWindowTitle("Create Tribute") self.setFixedSize(300, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.name_label = QLabel("Tribute:") self.name_input = IgnoreScrollWheelComboBox() self.name_input.setEditable(True) self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_input.addItems(sorted(Dataloader().tribute_dict.values())) self.form_layout.addRow(self.name_label, self.name_input) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def accept(self): reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()} tribute_name = reverse_dict.get(self.name_input.currentText()) if tribute_name is None: QMessageBox.warning(self, "Warning", "Select a valid tribute from the list.") return if tribute_name in self.tributes: QMessageBox.warning(self, "Warning", "Tribute already exist. You can modify the existing one.") return super().accept() def get_value(self): reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()} tribute_name = reverse_dict.get(self.name_input.currentText()) return TributeFilterModel(name=tribute_name, rarities=[]) class AddTributeRarity(QDialog): def __init__(self, rarities: list[ItemRarity], parent=None): super().__init__(parent) self.rarities = {ItemRarity(rarity) for rarity in rarities} self.setWindowTitle("Add Tribute Rarity") self.setFixedSize(300, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() self.rarity_label = QLabel("Rarity:") self.rarity_input = IgnoreScrollWheelComboBox() self.rarity_input.setEditable(True) self.rarity_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.rarity_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.rarity_input.addItems([rarity.name for rarity in ItemRarity]) self.form_layout.addRow(self.rarity_label, self.rarity_input) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def accept(self): rarity_name = self.rarity_input.currentText() if rarity_name not in ItemRarity.__members__: QMessageBox.warning(self, "Warning", "Select a valid rarity from the list.") return rarity = ItemRarity[rarity_name] if rarity in self.rarities: QMessageBox.warning(self, "Warning", "Rarity already exists in this tribute filter.") return super().accept() def get_value(self): rarity = ItemRarity[self.rarity_input.currentText()] return TributeFilterModel(rarities=[rarity]) class RemoveTribute(QDialog): def __init__(self, tributes: list[str], parent=None): super().__init__(parent) self.tributes = tributes self.setWindowTitle("Delete Tributes") self.groupbox = QGroupBox("Tributes") self.setFixedSize(300, 300) self.main_layout = QVBoxLayout() self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) scroll_area = QScrollArea(self) scroll_widget = QWidget(scroll_area) scrollable_layout = QVBoxLayout(scroll_widget) self.groupbox_layout = QVBoxLayout() label = QLabel("Select Tributes to delete:") label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.groupbox_layout.addWidget(label) self.checkbox_list = [] for tribute in self.tributes: checkbox = QCheckBox(Dataloader().tribute_dict[tribute]) if tribute else QCheckBox("None") scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) scroll_area.setWidget(scroll_widget) self.groupbox_layout.addWidget(scroll_area) self.groupbox.setLayout(self.groupbox_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()} return [reverse_dict.get(checkbox.text()) for checkbox in self.checkbox_list if checkbox.isChecked()] class AddAspectUpgrade(QDialog): def __init__(self, aspect_upgrades: list[str], parent=None): super().__init__(parent) self.aspect_upgrades = aspect_upgrades self.setWindowTitle("Add Aspect") self.setFixedSize(300, 150) self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() unchosen_aspect_ugprades = [x for x in Dataloader().aspect_list if x not in aspect_upgrades] self.name_label = QLabel("Aspect:") self.name_input = IgnoreScrollWheelComboBox() self.name_input.setEditable(True) self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_input.completer().setFilterMode(Qt.MatchFlag.MatchContains) self.name_input.addItems(unchosen_aspect_ugprades) self.form_layout.addRow(self.name_label, self.name_input) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return self.name_input.currentText() class CreateUnique(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Create Unique") self.groupbox = QGroupBox("Unique Infos") self.setFixedSize(300, 300) self.main_layout = QVBoxLayout() self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.groupbox_layout = QVBoxLayout() label = QLabel("Select info to add to the Unique:") label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.groupbox_layout.addWidget(label) self.checkbox_list = [] checkbox_aspect = QCheckBox("Aspect") checkbox_affixe = QCheckBox("Affixes") self.groupbox_layout.addWidget(checkbox_aspect) self.groupbox_layout.addWidget(checkbox_affixe) self.checkbox_list.append(checkbox_aspect) self.checkbox_list.append(checkbox_affixe) self.groupbox.setLayout(self.groupbox_layout) self.buttonLayout = QHBoxLayout() self.okButton = QPushButton("OK") self.okButton.clicked.connect(self.accept) self.cancelButton = QPushButton("Cancel") self.cancelButton.clicked.connect(self.reject) self.buttonLayout.addWidget(self.okButton) self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) self.setLayout(self.main_layout) def get_value(self): return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()] ================================================ FILE: src/gui/importer/__init__.py ================================================ ================================================ FILE: src/gui/importer/d4builds.py ================================================ import logging import re import time from typing import TYPE_CHECKING import lxml.html from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWait import src.logger from src.config.profile_models import ( AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, ItemFilterModel, ProfileModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( add_to_profiles, build_default_profile_file_name, fix_offhand_type, fix_weapon_type, get_class_name, match_to_enum, retry_importer, save_as_profile, sort_profile_filters, update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig from src.gui.importer.paragon_export import build_paragon_profile_payload, 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.data.rarity import ItemRarity from src.item.descr.text import clean_str, closest_match from src.scripts import correct_name if TYPE_CHECKING: from selenium.webdriver.chromium.webdriver import ChromiumDriver LOGGER = logging.getLogger(__name__) BASE_URL = "https://d4builds.gg/builds" BUILD_OVERVIEW_XPATH = "//*[@class='builder__stats__list']" CLASS_XPATH = "//*[contains(@class, 'builder__header__name')]" BUILD_DESCRIPTION_XPATH = "//*[contains(@class, 'builder__header__description')]" BUILD_HEADER_INPUT_XPATH = "//*[contains(@class, 'builder__header__input')]" VARIANT_INPUT_XPATH = "//*[contains(@class, 'builder__variant__input')]" SEASON_DROPDOWN_XPATH = ( "//*[contains(@class, 'builder__gear')]/*[contains(@class, 'builder__dropdown__wrapper')]" "//*[contains(@class, 'dropdown__button') and starts-with(normalize-space(), 'Season ')]" ) ITEM_GROUP_XPATH = ".//*[contains(@class, 'builder__stats__group')]" ITEM_SLOT_XPATH = ".//*[contains(@class, 'builder__stats__slot')]" ITEM_STATS_XPATH = ".//*[contains(@class, 'dropdown__button__wrapper')]" GA_XPATH = ".//*[contains(@class, 'greater__affix__button--filled')]" PAPERDOLL_ITEM_SLOT_XPATH = ".//*[contains(@class, 'builder__gear__slot')]" PAPERDOLL_ITEM_UNIQUE_NAME_XPATH = ".//*[contains(@class, 'builder__gear__name--')]" PAPERDOLL_ITEM_XPATH = ".//*[contains(@class, 'builder__gear__item') and not(contains(@class, 'disabled'))]" PAPERDOLL_LEGENDARY_ASPECT_XPATH = ( "//*[@class='builder__gear__name' and not(contains(@class, 'builder__gear__name--'))]" ) PAPERDOLL_XPATH = "//*[contains(@class, 'builder__gear__items')]" TEMPERING_ICON_XPATH = ".//*[contains(@src, 'tempering_02.png')]" 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", "") if BASE_URL not in url: LOGGER.error("Invalid url, please use a d4builds url") return LOGGER.info(f"Loading {url}") driver.get(url) wait = WebDriverWait(driver, 10) wait.until(EC.presence_of_element_located((By.XPATH, BUILD_OVERVIEW_XPATH))) wait.until(EC.presence_of_element_located((By.XPATH, PAPERDOLL_XPATH))) time.sleep( 5 ) # super hacky but I didn't find anything else. The page is not fully loaded when the above wait is done data = lxml.html.fromstring(driver.page_source) class_name, build_header, season_number, variant_name = _extract_build_metadata(data=data) build_name = build_header or class_name if not (items := data.xpath(BUILD_OVERVIEW_XPATH)): LOGGER.error(msg := "No items found") raise D4BuildsException(msg) slot_to_unique_name_map = _get_item_slots(data=data) finished_filters = [] aspect_upgrade_filters = _get_legendary_aspects(data=data) for item in items[0]: item_filter = ItemFilterModel() if not (slot := item.xpath(ITEM_SLOT_XPATH)[1].tail): LOGGER.error("No item_type found") continue if slot not in slot_to_unique_name_map: LOGGER.warning(f"Empty slots are not supported. Skipping: {slot}") continue if not (stats := item.xpath(ITEM_STATS_XPATH)): LOGGER.error(f"No stats found for {slot=}") continue item_type = None rarity = None affixes = [] inherents = [] if slot_to_unique_name_map[slot]: unique_name, rarity = slot_to_unique_name_map[slot] try: item_filter.uniqueAspect = AspectUniqueFilterModel(name=unique_name) except Exception: LOGGER.exception( f"Unexpected error adding unique aspect for {unique_name}, please report a bug and include a link to the build you were trying to import." ) is_weapon = "weapon" in slot.lower() for stat in stats: if stat.xpath(TEMPERING_ICON_XPATH) or stat.xpath(SANCTIFIED_ICON_XPATH): continue if "filled" not in stat.xpath("../..")[0].attrib["class"]: continue affix_name = _get_affix_name(stat) if not affix_name: LOGGER.warning(f"Slot {slot} is missing an affix, skipping import of that affix.") continue if is_weapon and (x := fix_weapon_type(input_str=affix_name)) is not None: item_type = x continue if ( "offhand" in slot.lower() and (x := fix_offhand_type(input_str=affix_name, class_str=class_name)) is not None ): item_type = x if any( substring in affix_name.lower() for substring in ["focus", "offhand", "shield", "totem"] ): # special line indicating the item type continue affix_obj = Affix( name=closest_match(clean_str(_corrections(input_str=affix_name)), Dataloader().affix_dict) ) if affix_obj.name is None: LOGGER.error(f"Couldn't match {affix_name=}") continue if config.import_greater_affixes and stat.xpath("../../../..")[0].xpath(GA_XPATH): affix_obj.type = AffixType.greater affixes.append(affix_obj) if not affixes: continue item_type = ( match_to_enum(enum_class=ItemType, target_string=re.sub(r"\d+", "", slot.replace(" ", ""))) if item_type is None else item_type ) if item_type is None: if is_weapon: LOGGER.warning( f"Couldn't find an item_type for weapon slot {slot}, defaulting to all weapon types instead." ) item_filter.itemType = WEAPON_TYPES else: item_filter.itemType = [] LOGGER.warning(f"Couldn't match item_type: {slot}. Please edit manually") else: item_filter.itemType = [item_type] # We don't bother importing affixes for mythics if rarity != ItemRarity.Mythic: item_filter.affixPool = [ AffixFilterCountModel( count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes], minCount=1 if rarity == ItemRarity.Unique else 3, ) ] update_mingreateraffixcount(item_filter, config.require_greater_affixes) if inherents: item_filter.inherentPool = [ AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents]) ] item_filter.minPower = 100 filter_name_template = item_filter.itemType[0].name if item_type else slot.replace(" ", "") filter_name = filter_name_template i = 2 while any(filter_name == next(iter(x)) for x in finished_filters): filter_name = f"{filter_name_template}{i}" i += 1 finished_filters.append({filter_name: item_filter}) profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.AspectUpgrades = aspect_upgrade_filters file_name = config.custom_file_name or build_default_profile_file_name( source_name="d4builds", class_name=class_name, season_number=season_number, build_header=build_header, variant_name=variant_name, ) # Optionally embed Paragon data into the profile model before saving if config.export_paragon: steps = extract_d4builds_paragon_steps(driver, class_name=class_name) if steps: profile.Paragon = build_paragon_profile_payload( build_name=build_name, source_url=url, paragon_boards_list=steps ) else: LOGGER.warning("Paragon export enabled, but no paragon data was found on this D4Builds page.") 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) LOGGER.info("Finished") def _corrections(input_str: str) -> str: input_str = input_str.lower() match input_str: case "max life": return "maximum life" case "total armor": return "armor" if "ranks to" in input_str or "ranks of" in input_str or "ranks" in input_str: return input_str.replace("ranks to", "to").replace("ranks of", "to").replace("ranks", "to") return input_str def _extract_build_metadata(data: lxml.html.HtmlElement) -> tuple[str, str, str, str]: class_name = "Unknown" if header_nodes := data.xpath(CLASS_XPATH): class_name = get_class_name(" ".join(header_nodes[0].text_content().split())) build_header = "" if description_nodes := data.xpath(BUILD_DESCRIPTION_XPATH): build_header = " ".join(description_nodes[0].text_content().split()) elif input_nodes := data.xpath(BUILD_HEADER_INPUT_XPATH): build_header = str(input_nodes[0].get("value") or "").strip() season_number = _extract_d4builds_season_number(data=data) variant_name = _extract_variant_name(data=data) return class_name, build_header, season_number, variant_name def _extract_variant_name(data: lxml.html.HtmlElement) -> str: if variant_nodes := data.xpath(VARIANT_INPUT_XPATH): if variant_value := str(variant_nodes[0].get("value") or "").strip(): return variant_value return " ".join(variant_nodes[0].text_content().split()) return "" def _extract_d4builds_season_number(data: lxml.html.HtmlElement) -> str: if not (season_nodes := data.xpath(SEASON_DROPDOWN_XPATH)): return "" season_text = " ".join(season_nodes[0].text_content().split()) if season_match := re.search(r"\bSeason\s+(\d+)\b", season_text, flags=re.IGNORECASE): return season_match.group(1) return "" def _get_item_slots(data: lxml.html.HtmlElement) -> dict[str, tuple[str, ItemRarity] | None]: result = {} if not (paperdoll := data.xpath(PAPERDOLL_XPATH)): LOGGER.error(msg := "No paperdoll found") raise D4BuildsException(msg) if not (items := paperdoll[0].xpath(PAPERDOLL_ITEM_XPATH)): LOGGER.error(msg := "No items found") raise D4BuildsException(msg) for item in items: if item.xpath(PAPERDOLL_ITEM_SLOT_XPATH): slot = item.xpath(PAPERDOLL_ITEM_SLOT_XPATH)[0].text if slot == "2H Weapon": # This happens when a build has a weapon and no offhand slot = "Weapon" unique_name_elem = item.xpath(PAPERDOLL_ITEM_UNIQUE_NAME_XPATH) if unique_name_elem: unique_name = unique_name_elem[0].text rarity = ItemRarity.Mythic if "mythic" in str(unique_name_elem[0].attrib) else ItemRarity.Unique result[slot] = (unique_name, rarity) else: result[slot] = None return result def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: result = [] if not (paperdoll := data.xpath(PAPERDOLL_XPATH)): # Shouldn't happen, earlier code would have thrown an exception return result aspects = paperdoll[0].xpath(PAPERDOLL_LEGENDARY_ASPECT_XPATH) for aspect in aspects: aspect_name = correct_name(aspect.text.lower().replace("aspect", "").strip()) if aspect_name not in Dataloader().aspect_list: LOGGER.warning( f"Legendary aspect '{aspect_name}' that is not in our aspect data, unable to add to AspectUpgrades." ) else: result.append(aspect_name) return result def _get_affix_name(stat: lxml.html.HtmlElement) -> str: """Bloodied attributes are saved in some special HTML that we need to remove here.""" for span in stat.xpath("./span"): affix_name = " ".join(span.text_content().split()) if affix_name: return affix_name return "" if __name__ == "__main__": src.logger.setup() URLS = ["https://d4builds.gg/builds/whirlwind-barbarian-endgame/?var=4"] from selenium import webdriver options = webdriver.ChromeOptions() options.add_argument("--headless=new") options.add_argument("log-level=3") driver = webdriver.Chrome(options=options) for X in URLS: config = ImportConfig( url=X, import_aspect_upgrades=True, add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, export_paragon=True, custom_file_name=None, ) import_d4builds(config, driver) ================================================ FILE: src/gui/importer/diablo_trade.py ================================================ import dataclasses import datetime import json import logging import pathlib from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import lxml.html from pydantic import ValidationError import src.logger from src.config.loader import IniConfigLoader from src.config.profile_models import AffixFilterCountModel, AffixFilterModel, ItemFilterModel, ProfileModel from src.dataloader import Dataloader from src.gui.importer.gui_common import format_number_as_short_string, match_to_enum, retry_importer, save_as_profile from src.item.data.affix import Affix from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.descr.text import clean_str, closest_match if TYPE_CHECKING: import seleniumbase LOGGER = logging.getLogger(__name__) LOGGER.propagate = True BASE_URL = "diablo.trade/listings/" @dataclasses.dataclass class _AnnotatedFilter: filter: ItemFilterModel | None = None max_price: int | None = None min_price: int | None = None @dataclasses.dataclass class _Listing: affixes: list[Affix] | None = None inherents: list[Affix] | None = None item_power: int = 0 item_rarity: ItemRarity | None = None item_type: ItemType | None = None price: int = 0 raw_data: dict[str, Any] | None = None class DiabloTradeException(Exception): pass @retry_importer(inject_webdriver=True, uc=True) def import_diablo_trade(url: str, max_listings: int, driver: seleniumbase.Driver = None): url = url.strip().replace("\n", "") if BASE_URL not in url: LOGGER.error("Invalid url, please use a diablo.trade filter url") return LOGGER.info("Start fetching listings") all_listings = [] cursor = 1 while True: api_url = _construct_api_url(listing_url=url, cursor=cursor) try: driver.default_get(url=api_url) source = lxml.html.fromstring(driver.get_page_source()) data = json.loads(source.text_content().strip()) except Exception: LOGGER.exception("Can't fetch listings, saving current data") break if not (listings := data["data"]): LOGGER.debug("Reached end") break for listing in listings: if not (item_type := match_to_enum(enum_class=ItemType, target_string=listing["itemType"])): continue if item_type in [None, ItemType.Material]: continue if "rarity" not in listing: if "name" in listing: LOGGER.debug(f"Skipping {listing['name']} because it had no rarity.") continue item_rarity = match_to_enum(enum_class=ItemRarity, target_string=listing["rarity"]) if item_rarity != ItemRarity.Legendary: continue listing_obj = _Listing( affixes=_create_affixes_from_api_dict(listing["affixes"]), inherents=_create_affixes_from_api_dict(listing["implicits"]), item_power=listing["itemPower"], item_rarity=ItemRarity.Legendary, item_type=item_type, price=listing["price"], raw_data=listing, ) try: assert listing_obj.item_type is not None assert len(listing["affixes"]) == len(listing_obj.affixes) assert len(listing["implicits"]) == len(listing_obj.inherents) except AssertionError: LOGGER.error(f"If you see this create a bug ticket! {listing=}") all_listings.append(listing_obj) LOGGER.info(f"Fetched {len(all_listings)} listings") if len(all_listings) >= max_listings: break cursor += 1 try: profile = ProfileModel(name="diablo_trade", Affixes=_create_filters_from_items(items=all_listings)) except Exception as exc: LOGGER.exception(msg := "Failed to validate profile. Dumping data for debugging.") with pathlib.Path( IniConfigLoader().user_dir / f"diablo_trade_dump_{datetime.datetime.now(tz=datetime.UTC).strftime('%Y_%m_%d_%H_%M_%S')}.json" ).open("w", encoding="utf-8") as f: json.dump(all_listings, f, indent=4, sort_keys=True) raise DiabloTradeException(msg) from exc LOGGER.info(f"Saving profile with {len(profile.Affixes)} filters") save_as_profile( file_name=f"diablo_trade_{datetime.datetime.now(tz=datetime.UTC).strftime('%Y_%m_%d_%H_%M_%S')}", profile=profile, url=url, ) LOGGER.info("Finished") def _construct_api_url(listing_url: str, cursor: int = 1) -> str: parsed_url = urlparse(listing_url) query_dict = parse_qs(parsed_url.query) query_dict["cursor"] = [str(cursor)] new_query_string = urlencode(query_dict, doseq=True) return urlunparse(( parsed_url.scheme, parsed_url.netloc, "api/items/search", parsed_url.params, new_query_string, parsed_url.fragment, )) def _create_affixes_from_api_dict(affixes: list[dict[str, Any]]) -> list[Affix]: res = [] for affix in affixes: new_affix = Affix(name=closest_match(clean_str(affix["name"]), Dataloader().affix_dict), value=affix["value"]) if isinstance(new_affix.value, list): if new_affix.value: new_affix.value = new_affix.value[0] else: new_affix.value = 0 res.append(new_affix) if len(affixes) != len(res) or any(x.name is None for x in res): LOGGER.error(f"If you see this create a bug ticket! {affixes=}") return res def _create_filters_from_items(items: list[_Listing]) -> list[dict[str, ItemFilterModel]]: to_check = items.copy() result = [] for item in items.copy(): if item not in to_check: continue to_check.remove(item) try: annotated_filter = _AnnotatedFilter( max_price=item.price, min_price=item.price, filter=ItemFilterModel( minPower=item.item_power, itemType=[item.item_type], affixPool=[ AffixFilterCountModel( count=[AffixFilterModel(name=x.name, value=x.value) for x in item.affixes] ) ], ), ) except ValidationError: LOGGER.exception(f"Failed validation. Skipping {item=}") continue to_delete = [] for to_check_item in [x for x in to_check if x.item_type in annotated_filter.filter.itemType]: annotated_filter_affixes = [(x.name, x.value) for x in annotated_filter.filter.affixPool[0].count] to_check_item_affixes = [(x.name, x.value) for x in to_check_item.affixes] for x in annotated_filter_affixes: if not any(a[0] == x[0] for a in to_check_item_affixes): break else: to_delete.append(to_check_item) annotated_filter.min_price = min(annotated_filter.min_price, to_check_item.price) annotated_filter.max_price = max(annotated_filter.max_price, to_check_item.price) for x in annotated_filter.filter.affixPool[0].count: for y in [a for a in to_check_item.affixes if x.name == a.name]: x.value = min(x.value, y.value) for to_delete_item in to_delete: to_check.remove(to_delete_item) result.append(annotated_filter) converted_result = [] for annotated_filter in result: name = ( f"{annotated_filter.filter.itemType[0].value}_{format_number_as_short_string(annotated_filter.min_price)}" ) if annotated_filter.min_price != annotated_filter.max_price: name += f"_{format_number_as_short_string(annotated_filter.max_price)}" # if there is a dict with this key name already in the list, append a number to the key name using change_key_of_dict suffixed_name = name i = 2 while any(suffixed_name in x for x in converted_result): suffixed_name = f"{name}_{i}" i += 1 converted_result.append({suffixed_name: annotated_filter.filter}) return sorted(converted_result, key=lambda x: next(iter(x))) if __name__ == "__main__": src.logger.setup() URLS = ["https://diablo.trade/listings/items?exactPrice=true&rarity=legendary&sold=true&sort=newest"] for x in URLS: import_diablo_trade(url=x, max_listings=400) ================================================ FILE: src/gui/importer/gui_common.py ================================================ import datetime import functools import logging import pathlib import re import shutil import time from typing import TYPE_CHECKING, Literal, TypeVar import httpx from ruamel.yaml import YAML, StringIO from selenium import webdriver from selenium.common.exceptions import TimeoutException from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support.wait import WebDriverWait from seleniumbase import SB from src import __version__ from src.config.loader import IniConfigLoader from src.config.profile_models import ItemFilterModel, ProfileModel # noqa: TC001 from src.config.settings_models import BrowserType from src.item.data.item_type import ItemType if TYPE_CHECKING: from collections.abc import Callable from selenium.webdriver.chromium.webdriver import ChromiumDriver LOGGER = logging.getLogger(__name__) D = TypeVar("D", bound=WebDriver | WebElement) T = TypeVar("T") HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" } PLAYER_CLASSES = ["barbarian", "druid", "necromancer", "rogue", "sorcerer", "spiritborn", "paladin", "warlock"] BUILD_SOURCES = ["d4builds", "maxroll", "mobalytics"] _SOURCE_TITLE_SUFFIXES = {"d4builds": ("D4Builds", "D4 Builds"), "maxroll": ("Maxroll",), "mobalytics": ("Mobalytics",)} MAX_POWER = 900 def extract_digits(text: str) -> int: return int("".join([char for char in text if char.isdigit()])) def fix_weapon_type(input_str: str) -> ItemType | None: input_str = input_str.lower() if "1h axe" in input_str: return ItemType.Axe if "1h mace" in input_str: return ItemType.Mace if "1h sword" in input_str: return ItemType.Sword if "2h axe" in input_str: return ItemType.Axe2H if "2h mace" in input_str: return ItemType.Mace2H if "2h scythe" in input_str: return ItemType.Scythe2H if "2h sword" in input_str: return ItemType.Sword2H if "bow" in input_str: return ItemType.Bow if "crossbow" in input_str: return ItemType.Crossbow2H if "dagger" in input_str: return ItemType.Dagger if "flail" in input_str: return ItemType.Flail if "glaive" in input_str: return ItemType.Glaive if "polearm" in input_str: return ItemType.Polearm if "quarterstaff" in input_str: return ItemType.Quarterstaff if "scythe" in input_str: return ItemType.Scythe if "staff" in input_str: return ItemType.Staff if "wand" in input_str: return ItemType.Wand return None def fix_offhand_type(input_str: str, class_str: str) -> ItemType | None: input_str = input_str.lower() class_str = class_str.lower() if "sorc" in class_str or "warlock" in class_str: return ItemType.Focus if "druid" in class_str: return ItemType.OffHandTotem if "paladin" in class_str: return ItemType.Shield if "necro" in class_str: if "focus" in input_str or ("offhand" in input_str and "lucky hit chance" in input_str): return ItemType.Focus if "shield" in input_str: return ItemType.Shield return None def format_number_as_short_string(n: int) -> str: result = n / 1_000_000 return f"{int(result)}M" if result.is_integer() else f"{result:.2f}M" def get_class_name(input_str: str) -> str: input_str = input_str.lower() for class_name in PLAYER_CLASSES: if class_name in input_str: return class_name.title() LOGGER.error(f"Couldn't match class name {input_str=}") return "Unknown" def normalize_profile_file_name(file_name: str) -> str: file_name = file_name.replace("'", "") file_name = re.sub(r"\W", "_", file_name) return re.sub(r"_+", "_", file_name).rstrip("_") def build_default_profile_file_name( source_name: str, class_name: str = "", season_number: str = "", build_header: str = "", variant_name: str = "" ) -> str: normalized_source_name = _normalize_profile_name_part(source_name) or "imported" clean_title = _clean_build_header(normalized_source_name, build_header, season_number) normalized_class_name = _normalize_profile_name_part(class_name) or "unknown" normalized_variant_name = _normalize_profile_name_part(variant_name) season_match = re.search(r"\d+", str(season_number)) normalized_season_name = f"s{season_match.group(0)}" if season_match else "" file_name_parts = [normalized_source_name, normalized_class_name] if normalized_season_name: file_name_parts.append(normalized_season_name) if clean_title: file_name_parts.append(clean_title) if normalized_variant_name: file_name_parts.append(normalized_variant_name) return normalize_profile_file_name("_".join(file_name_parts)) def _clean_build_header(source_name: str, build_header: str, season_number: str = "") -> str: clean_header = _normalize_profile_name_part(build_header) if not clean_header: return "" source_labels = _SOURCE_TITLE_SUFFIXES.get(source_name, (source_name.title(),)) for source_label in source_labels: normalized_source_label = source_label.casefold() for separator in (" - ", " | ", " · "): suffix = f"{separator}{normalized_source_label}" if clean_header.endswith(suffix): clean_header = clean_header.removesuffix(suffix) break if re.search(r"\d+", str(season_number)): clean_header = re.sub(r"^\s*(?:S\d+|Season\s+\d+)\b", "", clean_header, count=1, flags=re.IGNORECASE) clean_header = re.sub(r"\(\s*(?:S\d+|Season\s+\d+)\s*\)", "", clean_header, flags=re.IGNORECASE) clean_header = re.sub(r"\b(?:S\d+|Season\s+\d+)\b", "", clean_header, flags=re.IGNORECASE) return re.sub(r"\s+", " ", clean_header).strip(" -_:") def _normalize_profile_name_part(name_part: str) -> str: return re.sub(r"\s+", " ", str(name_part or "").strip()).casefold() def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool): if require_gas: num_greater = 0 for affix in item_filter.affixPool[0].count: num_greater += 1 if affix.want_greater else 0 item_filter.minGreaterAffixCount = num_greater else: item_filter.minGreaterAffixCount = 0 def sort_profile_filters(filters: list[dict[str, ItemFilterModel]]) -> list[dict[str, ItemFilterModel]]: return sorted(filters, key=_profile_filter_sort_key) def _profile_filter_sort_key(filter_entry: dict[str, ItemFilterModel]) -> str: filter_name, _ = next(iter(filter_entry.items())) return filter_name.casefold() def get_with_retry(url: str, custom_headers: dict[str, str] | None = None) -> httpx.Response: for _ in range(10): try: r = httpx.get(url, headers=custom_headers if custom_headers is not None else HEADERS) except httpx.RequestError: LOGGER.debug(f"Request {url} timed out, retrying...") continue if r.status_code != 200: LOGGER.debug(f"Request {url} failed with status code {r.status_code}, retrying...") continue return r LOGGER.error(msg := f"Failed to get a successful response after 10 attempts: {url=}") raise ConnectionError(msg) def handle_popups[D: WebDriver | WebElement, T]( driver: ChromiumDriver, method: Callable[[D], Literal[False] | T], timeout: int = 10 ): LOGGER.info("Handling cookie / adblock popups") wait = WebDriverWait(driver, timeout) for _ in range(3): try: elem = wait.until(method) except TimeoutException: break elem.click() time.sleep(1) def match_to_enum(enum_class, target_string: str, check_keys: bool = False): target_string = target_string.casefold().replace(" ", "").replace("-", "") for enum_member in enum_class: if enum_member.value.casefold().replace(" ", "").replace("-", "") == target_string: return enum_member if check_keys and enum_member.name.casefold().replace(" ", "").replace("-", "") == target_string: return enum_member return None def retry_importer(func=None, inject_webdriver: bool = False, uc=False): def decorator_retry_importer(wrap_function): @functools.wraps(wrap_function) def wrapper(*args, **kwargs): if inject_webdriver and "driver" not in kwargs and not args: kwargs["driver"] = setup_webdriver(uc=uc) for _ in range(2): try: res = wrap_function(*args, **kwargs) if inject_webdriver and "driver" in kwargs: kwargs["driver"].quit() except Exception: LOGGER.exception("An error occurred while importing. Retrying...") else: return res return None return wrapper return decorator_retry_importer if func is None else decorator_retry_importer(func) def save_as_profile(file_name: str, profile: ProfileModel, url: str, exclude=None, backup_file=False) -> str: file_name = normalize_profile_file_name(file_name) save_path = IniConfigLoader().user_dir / f"profiles/{file_name}.yaml" save_path.parent.mkdir(parents=True, exist_ok=True) if save_path.exists() and backup_file: backup_path = IniConfigLoader().user_dir / f"profiles/backups/{file_name}_original.yaml" backup_path.parent.mkdir(parents=True, exist_ok=True) if not backup_path.exists(): # If already backed up don't overwrite shutil.copyfile(save_path, backup_path) exclude = exclude or {"name", "Sigils"} with pathlib.Path(save_path).open("w", encoding="utf-8") as file: file.write(f"# {url}\n") file.write(f"# {datetime.datetime.now(tz=datetime.UTC).strftime('%Y-%m-%d %H:%M:%S')} (v{__version__})\n") file.write(_to_yaml_str(profile, exclude_defaults=not IniConfigLoader().general.full_dump, exclude=exclude)) LOGGER.info(f"Created profile {save_path}") return file_name def add_to_profiles(build_name): profiles = IniConfigLoader().general.profiles if build_name in profiles: LOGGER.info(f"Profile {build_name} was already an active profile.") else: profiles.append(build_name) IniConfigLoader().save_value("general", "profiles", ", ".join(profiles)) LOGGER.info(f"Added {build_name} to active profiles configuration") # Built in to_yaml_str does not preserve the order of the attributes of the model, which is important for uniques def _to_yaml_str(profile: ProfileModel, exclude_defaults: bool, exclude: set[str]) -> str: str_val = profile.model_dump_json(exclude_defaults=exclude_defaults, exclude=exclude) yaml = YAML() yaml.default_flow_style = None # Back to original dict_val = yaml.load(str_val) _sort_profile_sections(dict_val) _rm_style_info(dict_val) _use_block_style(dict_val.get("AspectUpgrades")) stream = StringIO() yaml.dump(dict_val, stream) stream.seek(0) return stream.read() def _sort_profile_sections(d): if isinstance(d, dict) and isinstance(d.get("AspectUpgrades"), list): d["AspectUpgrades"].sort(key=str.casefold) def _use_block_style(d): if hasattr(d, "fa"): d.fa.set_block_style() def _rm_style_info(d): if isinstance(d, dict): d.fa._flow_style = None for k, v in d.items(): _rm_style_info(k) _rm_style_info(v) elif isinstance(d, list): d.fa._flow_style = None for elem in d: _rm_style_info(elem) def setup_webdriver(uc: bool = False) -> ChromiumDriver: if uc: return SB(uc=uc, headless2=True) match IniConfigLoader().general.browser: case BrowserType.edge: options = webdriver.EdgeOptions() options.add_argument("--headless=new") options.add_argument("log-level=3") driver = webdriver.Edge(options=options) case BrowserType.chrome: options = webdriver.ChromeOptions() options.add_argument("--headless=new") options.add_argument("log-level=3") driver = webdriver.Chrome(options=options) case BrowserType.firefox: options = webdriver.FirefoxOptions() options.add_argument("--headless") options.add_argument("log-level=3") driver = webdriver.Firefox(options=options) return driver # It must be one of the 3 browsers due to ini validation ================================================ FILE: src/gui/importer/importer_config.py ================================================ from dataclasses import dataclass @dataclass class ImportConfig: url: str import_aspect_upgrades: bool add_to_profiles: bool import_greater_affixes: bool require_greater_affixes: bool export_paragon: bool = False custom_file_name: str | None = None ================================================ FILE: src/gui/importer/maxroll.py ================================================ import json import logging import re import lxml.html import src.logger from src.config.profile_models import ( AffixFilterCountModel, AffixFilterModel, AspectUniqueFilterModel, ItemFilterModel, ProfileModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( add_to_profiles, build_default_profile_file_name, fix_offhand_type, fix_weapon_type, get_with_retry, match_to_enum, retry_importer, save_as_profile, sort_profile_filters, update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig from src.gui.importer.paragon_export import build_paragon_profile_payload, extract_maxroll_paragon_steps from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType from src.item.data.rarity import ItemRarity from src.item.descr.text import clean_str, closest_match from src.scripts import correct_name LOGGER = logging.getLogger(__name__) LOGGER.propagate = True BUILD_GUIDE_BASE_URL = "https://maxroll.gg/d4/build-guides/" PLANNER_API_BASE_URL = "https://planners.maxroll.gg/profiles/d4/" PLANNER_API_DATA_URL = "https://assets-ng.maxroll.gg/d4-tools/game/data.min.json?376b600d" PLANNER_BASE_URL = "https://maxroll.gg/d4/planner/" SCRIPT_XPATH = "//div[@id='root']/script" BUILD_SCRIPT_PREFIX = "window.__remixContext = " PLANNER_API_REGEX = re.compile(r'(https://maxroll\.gg/d4/planner/[^"|\\]*)') SKILL_RANK_BONUS_FORMULAS = {"GearAffix_SkillRankBonus", "GearAffix_SkillRankBonus_1to2"} SKILL_RANK_AFFIX_KEY_REGEX = re.compile(r"(?:_Category_|_Special_)(?P