[
  {
    "path": ".clang-format",
    "content": "Language: Cpp\nBasedOnStyle: Google\nColumnLimit: 140\nIndentWidth: 4\nTabWidth: 4\nUseTab: Never\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Set the default behavior, in case people don't have core.autocrlf set.\n* text=auto\n\n# Denote all files that are truly binary and should not be modified.\n*.jpg binary\n*.png binary\n\n# Explicitly declare text files you want to always be normalized and converted to native line endings on checkout.\n*.md text eol=lf\n*.sh text eol=lf\n*.yaml text eol=lf\n*.yml text eol=lf\n"
  },
  {
    "path": ".github/actions/setup_env/action.yml",
    "content": "name: Setup env\n\nruns:\n  using: \"composite\"\n  steps:\n    - name: Setup uv\n      uses: astral-sh/setup-uv@v8.1.0\n      with:\n        activate-environment: true\n        cache-dependency-glob: |\n          **/uv.lock\n        enable-cache: true\n        ignore-nothing-to-cache: true\n        python-version: 3.14\n    - name: Install dependencies\n      shell: powershell\n      run: uv sync --frozen --all-groups\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: \"${{github.workflow}}-${{github.ref}}\"\n  cancel-in-progress: true\n\njobs:\n  tests:\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Setup env\n        uses: ./.github/actions/setup_env\n      - name: Run prek\n        uses: j178/prek-action@v2\n      - name: Pytest\n        shell: powershell\n        run: pytest . -m \"not selenium\" -v -n logical\n#      - name: Pytest selenium\n#        shell: powershell\n#        run: pytest . -m \"selenium\" -v\n"
  },
  {
    "path": ".github/workflows/notify.yml",
    "content": "name: Notify\n\non:\n  release:\n    types: [published]\n\njobs:\n  github-releases-to-discord:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: SethCohen/github-releases-to-discord@v1.20\n        with:\n          webhook_url: ${{ secrets.DISCORD_WEBHOOK }}\n          color: \"2105893\"\n          username: \"D4LF Release\"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  pull_request:\n    types: [closed]\n  workflow_dispatch:\n\nconcurrency:\n  group: release\n\njobs:\n  release:\n    if: |\n      github.event_name != 'pull_request' ||\n        (\n          github.event.pull_request.merged == true &&\n          contains(github.event.pull_request.labels.*.name, 'release')\n        )\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Setup env\n        uses: ./.github/actions/setup_env\n      - name: Build & Zip exe\n        id: build_zip\n        shell: powershell\n        run: |\n          python build.py\n          $version = python -c \"from src import __version__; print(__version__)\"\n          echo \"VERSION=$version\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append\n          $folderName = \"d4lf\"\n          $zipName = \"d4lf_v\" + $version\n          Compress-Archive -Path $folderName -DestinationPath \"$zipName.zip\"\n      - name: Create Tag\n        shell: powershell\n        run: |\n          git tag \"v${{ env.VERSION }}\"\n          git push origin \"v${{ env.VERSION }}\"\n      - name: Check if beta\n        id: check_beta\n        shell: powershell\n        run: |\n          if ($env:VERSION -like \"*beta*\" -or $env:VERSION -like \"*alpha*\") {\n            echo \"IS_BETA=true\" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8\n          } else {\n            echo \"IS_BETA=false\" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8\n          }\n      - uses: softprops/action-gh-release@v3\n        with:\n          files: d4lf_v*.zip\n          generate_release_notes: true\n          name: \"v${{ env.VERSION }}\"\n          prerelease: ${{ env.IS_BETA == 'true' }}\n          tag_name: \"v${{ env.VERSION }}\"\n          token: \"${{ secrets.RELEASE_TOKEN }}\"\n"
  },
  {
    "path": ".gitignore",
    "content": "!config/bnip/.gitkeep\n*.bak\n*.log\n*.pyc\n*.pyo\n*.spec\n*_generated.py\n*info_*.png\n*info_log_parsed.txt\n.coverage\n.idea/\n.pytest_cache/\n.venv\n.vs/\n*.egg-info/\n/tts/saapi\n/tts/.tools/\n/tts/x64\n__pycache__/\nbuild/\nconfig/bnip/*\nconfig/custom.*.ini\nconfig/custom.ini\ncoverage.xml\ncustom.ini\ncustom_filter_affixes.yaml\ncustom_filter_aspects.yaml\nd4lf_*\ndist/\ngenerated/\nhtmlcov/\nlogs/\nmain.build/\nmain.dist/\nmain.onefile-build/\nplayground.py\nstats/\nutils/live-view/\nvenv\nassets/last_update\n.codex\nAGENTS.md\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "default_install_hook_types: [pre-push]\ndefault_language_version:\n  python: python3.14\nminimum_prek_version: '0.3.0'\nrepos:\n  - repo: https://github.com/astral-sh/uv-pre-commit\n    rev: 0.11.14\n    hooks:\n      - id: uv-lock\n        priority: &group1 4294967294\n  - repo: https://github.com/executablebooks/mdformat\n    rev: 1.0.0\n    hooks:\n      - id: mdformat\n        priority: *group1\n        additional_dependencies:\n          - mdformat-gfm\n  - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks\n    rev: v2.16.0\n    hooks:\n      - id: pretty-format-toml\n        priority: *group1\n        args: [--autofix, --indent, \"4\"]\n        exclude: ^uv\\.lock\n      - id: pretty-format-yaml\n        priority: *group1\n        args: [--autofix, --preserve-quotes, --offset, \"2\"]\n  - repo: https://github.com/pre-commit/mirrors-clang-format\n    rev: v22.1.5\n    hooks:\n      - id: clang-format\n        priority: *group1\n        files: \\.(cpp|h)$\n  - repo: builtin\n    hooks:\n      - id: check-added-large-files\n        priority: &read-only 4294967295\n      - id: check-case-conflict\n        priority: *read-only\n      - id: check-executables-have-shebangs\n        priority: *read-only\n      - id: check-json\n        priority: *read-only\n      - id: check-toml\n        priority: *read-only\n      - id: check-yaml\n        priority: *read-only\n      - id: end-of-file-fixer\n      - id: fix-byte-order-marker\n        exclude: ^tts/sign_dll.ps1\n      - id: trailing-whitespace\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-ast\n        priority: *read-only\n      - id: pretty-format-json\n        args: [--autofix, --indent=4, --no-ensure-ascii]\n        priority: *group1\n      - id: trailing-whitespace\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.15.13\n    hooks:\n      - id: ruff-format\n        priority: *group1\n      - id: ruff-check\n        priority: &group2 4294967293\n        args: [--fix]\n  - repo: https://github.com/thetestlabs/py-psscriptanalyzer\n    rev: v0.3.1\n    hooks:\n      - id: py-psscriptanalyzer\n        priority: *read-only\n        args: ['--severity', 'Error']\n      - id: py-psscriptanalyzer-format\n        priority: *group1\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "MIT License\n\nCopyright (c) 2026 d4lf contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# ![logo](assets/logo.png)\n\n## 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\n\nFilter items and sigils in your inventory based on affixes, aspects and thresholds of their values. For questions,\nfeature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6T) or use github issues.\n\n![sample](assets/thumbnail.jpg)\n\n## Features\n\n- Filter items in inventory and stash\n- Filter by item type, item power and greater affix count\n- Filter by affix and their values, with per-affix greater affix requirements\n- Filter uniques by their affix and aspect values\n- Filter sigils by blacklisting and whitelisting locations and affixes\n- Filter tributes by name or rarity\n- Quickly move items from your stash or inventory\n- Supported resolutions are all aspect ratios between 16:10 and 21:9\n- Paragon Overlay with import from supported build planners (Mobalytics, Maxroll, D4Builds)\n\n## How to Setup\n\n### Installation and quick start guide (New instructions for season 12 that must be followed!)\n\n- Download and extract the latest version (.zip) from the releases: https://github.com/d4lfteam/d4lf/releases\n- Find your \"Diablo IV\" directory. Copy the path and have it in your clipboard:\n  - In Battle.net, click the gear icon next to the Play button and select \"Open in Explorer\"\n  - In Steam, right click the game, select Manage > Browse local files\n- 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)\n  - Navigate to your d4lf directory\n  - Double-click `install_dll.cmd`\n    - If asked for administrator permissions, provide them.\n    - When asked for your Diablo 4 path, provide it\n    - When asked to install a certificate, allow it.\n    - If everything is successful, proceed with the guide. Otherwise join the [discord](https://discord.gg/YyzaPhAN6T) or post an issue in github.\n- Generate a profile of what Diablo 4 items you want to filter for. To do so you have a few options:\n  - Run d4lf.exe and import a profile using the import window by pasting a build page from popular planner websites\n  - Create one yourself by looking at the [examples](#how-to-filter--profiles) below\n- If created manually, place the profile in the `C:/Users/<WINDOWS_USER>/.d4lf/profiles` folder. The D4LF\n  importer window has a button to open this folder directly. If imported they are placed there automatically.\n- Run d4lf.exe and use the config button to configure the profiles in the general section. Select the '...' next to profiles to activate which\n  profiles you want to use.\n- Ensure all [game settings](#game-settings) are configured properly.\n- If you made changes, restart d4lf.exe and launch Diablo 4.\n- Use the hotkeys listed in d4lf.exe to run filtering. By default, F11 will run the loot filter and filter your items.\n- 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.\n\n### Game Settings\n\n- Game Language must be English\n- 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.\n- Font scale in Graphics settings must be small or medium\n- HDR makes the screen too bright and D4LF is unable to read the state of some items on screen. It must be disabled.\n- Use Screen Reader must be enabled in Options > Accessibility\n- 3rd Party Screen Reader must be enabled in Options > Accessibility (The voice will go away when DLL is installed, see quick start guide above)\n\n### Common problems\n\n- The tool shows a warning saying \"TTS connection has not been made yet.\" but I've set everything up correctly.\n  - If you're seeing this error, it means D4LF has found the DLL is in the correct location but the TTS connection is\n    still not being made. This is most likely due to an issue with your windows user not allowing Diablo to connect to\n    the third party screen reader. The following steps should resolve it:\n    - Set Diablo 4 to run as administrator. First, navigate to your Diablo 4 directory.\n      - Steam User: Right click on the game and choose Properties. In that menu, go to Installed Files and hit Browse.\n      - Battle.net User: On the game page, click the gear icon and choose Show in Explorer\n    - 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\"\n    - Run Diablo 4 again through Steam/Battle.net and see if that resolved the issue.\n    - 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.\n- The GUI crashes immediately upon opening, with no error message given\n  - This almost always means there is an issue in your params.ini. Delete the file in `C:/Users/<WINDOWS_USER>/.d4lf/` and then open the GUI and configure\n    your params.ini through the Settings window in D4LF. Using the GUI for configuration will ensure the file is always accurate.\n- Mouse control isn't possible\n  - Due to your local windows settings, the tool might not be able to control the mouse. Just run the tool as admin\n    and it should work. If you don't want to run it as admin, you can disable the mouse control in the params.ini\n    by setting `vision_mode_only` to `true`.\n- Paragon overlay does not appear / does nothing\n  - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays).\n  - Ensure your profiles folder contains `*.yaml`/`*.yml` profile files with a top-level `Paragon:` section (default: `C:/Users/<WINDOWS_USER>/.d4lf/profiles`).\n  - Check/adjust `advanced/settings/toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys.\n\n### TTS\n\nD4 uses a third-party TTS engine called Tolk. Tolk has a feature that allows custom third-party TTS DLLs to be loaded.\nD4 automatically loads the DLL, which actually just sends the text to another application rather than reading it aloud.\nThis is similar to having a Braille TTS application for D4.\n\nThe 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:\n\n- Copy the dll file to the Diablo 4 directory\n- Download the signtool needed to add a local signature to the dll\n- Runs the signtool and signs the dll\n\nIf you prefer running it from a terminal, you can run `.\\install_dll.cmd`.\n\nFor very advanced users that don't want to automatically download signtool.exe, you can run `.\\install_dll.cmd -signtool_path \"<full path to signtool.exe>\"`\n\n### Configs\n\nThe config folder in `C:/Users/<WINDOWS_USER>/.d4lf` contains:\n\n- **profiles/\\*.yaml**: These files determine what should be filtered. Profiles created by the GUI will be placed here\n  automatically.\n- **params.ini**: Different hotkey settings and number of chest stashes that should be looked at. Management of this\n  file should be done through the GUI in the config window.\n- **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/<WINDOWS_USER>/.d4lf/profiles`\n\n### params.ini (Settings window in GUI)\n\n| [general]                                         | Description                                                                                                                                                                                                                                                                                                                                                                                                                              |\n| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 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                                                                                                                                                                                                                                                                                                 |\n| auto_use_temper_manuals                           | When using the loot filter, should found temper manuals be automatically used? Note: Will not work with stash open.                                                                                                                                                                                                                                                                                                                      |\n| browser                                           | Which browser to use to get builds, please make sure you pick an installed browser: chrome, edge or firefox are currently supported.                                                                                                                                                                                                                                                                                                     |\n| 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                                                                                                                                                                                                                             |\n| do_not_junk_ancestral_legendaries                 | Do not mark ancestral legendaries as junk.                                                                                                                                                                                                                                                                                                                                                                                               |\n| full_dump                                         | When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not                                                                                                                                                                                                                                                                                                                                |\n| 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                                                                                                                                                                                                                                                                                           |\n| 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. <br/>- `favorite`: Mark the unique as favorite and vision mode will show it as green (default)<br/>- `ignore`: Do nothing with the unique and vision mode will show it as green<br/>- `junk`: Mark any uniques that don't match any filters as junk and show as red in vision mode |\n| ignore_escalation_sigils                          | When filtering Sigils, should escalation sigils be ignored?                                                                                                                                                                                                                                                                                                                                                                              |\n| keep_aspects                                      | - `all`: Keep all legendary items <br>- `upgrade`: Keep all legendary items that upgrade your codex of power. If the item matches no profile, it will be highlighted in orange <br>- `none`: Keep no legendary items based on aspect (they are still filtered!) <br>-                                                                                                                                                                    |\n| mark_as_favorite                                  | Whether to favorite matched items or not. Defaults to true                                                                                                                                                                                                                                                                                                                                                                               |\n| 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.                                                                                                                                                                                                                                                                                          |\n| 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.                                                                                                                                                                                                      |\n| move_to_inv_item_type<br/>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. <br>- `favorites`: Move favorites only <br>- `junk`: Move junk only <br>- `unmarked`: Only items not marked as favorite or junk <br>- `everything`: Move everything                                                                                                             |\n| 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                                                                                                                                                                                                                                                                                              |\n| colorblind_mode                                   | Enable a colorblind friendly palette for loot filter and paragon overlays                                                                                                                                                                                                                                                                                                                                                                |\n| 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.                                                                                                                                                                                                       |\n\n| [char]    | Description                       |\n| --------- | --------------------------------- |\n| inventory | Your hotkey for opening inventory |\n\n| [advanced_options]           | Description                                                                                                                                       |\n| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| move_to_inv                  | Hotkey for moving items from stash to inventory                                                                                                   |\n| move_to_chest                | Hotkey for moving items from inventory to stash                                                                                                   |\n| run_filter                   | Hotkey to start/stop filtering items                                                                                                              |\n| run_filter_drop              | Hotkey to start/stop filtering items. Unmatched items are dropped instead of marked as junk                                                       |\n| run_filter_force_refresh     | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset                                                        |\n| run_vision_mode              | Hotkey to start/stop vision mode                                                                                                                  |\n| force_refresh_only           | Hotkey to reset all item statuses without running a filter after                                                                                  |\n| exit_key                     | Hotkey to exit d4lf.exe                                                                                                                           |\n| toggle_paragon_overlay       | Hotkey to open/close the Paragon overlay                                                                                                          |\n| log_lvl                      | Logging level. Can be any of [debug, info, warning, error, critical]                                                                              |\n| 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                          |\n| vision_mode_only             | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled.                                     |\n| 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) |\n\n### GUI\n\nd4lf.exe is the one-stop shop for all operations, including running the D4LF process and any configuration changes.\n\nIf you prefer a standalone console-only experience, you can run d4lf-consoleonly.bat instead which will not open a GUI\nas well. It is still recommended you open the GUI for any configurations management.\n\nExcept for changing the vision mode, all changes are automatically applied when made. If you make changes to a profile, those will be\nautomatically picked up and no restart is necessary.\n\nCurrent functionality:\n\n- Import builds from maxroll/d4builds/mobalytics\n- Toggle the integrated Paragon overlay (default hotkey: F10)\n- Complete management of your settings through the config tab\n- A beta version of a manual profile editor/creator\n\nEach window gives further instructions on how to use it and what kind of input it expects.\n\n## How to filter / Profiles\n\nAll profiles define whitelist filters. If no filter included in your profiles matches the item, it will be discarded.\n\nYour config files will be validated on startup and will prevent the program from starting if the structure or syntax is\nincorrect. The error message will provide hints about the specific problem.\n\nThe following sections will explain each type of filter that you can specify in your profiles. How you define them in\nyour 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\nfilter, or even split the same type of filter over multiple files. Ultimately, all profiles specified in\nyour `params.ini` will be used to determine if an item should be kept. If one of the profiles wants to keep the item, it\nwill be kept regardless of the other profiles. Similarly, if a filter is missing in all profiles (e.g., there is\nno `Sigils` section in any profile), all corresponding items (in this case, sigils) will be kept.\n\n### Affix / Unique Aspect Filter Syntax\n\nYou 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.\n\n- You can use the Edit Profile window in the GUI, which is the recommended approach\n- You can also manually edit your profile.\n\nThe instructions below are all about editing the file manually, but the explanations apply to the GUI as well.\n\n<details><summary>Examples</summary>\n\n```yaml\n\n# Filter for attack speed\n- { name: attack_speed }\n# Filter for attack speed with a threshold value.\n# The filter keeps larger rolls when the tooltip range increases and smaller rolls when the range decreases.\n- { name: attack_speed, value: 4 }\n# Filter for attack speed where the affix is greater than 50% of the potential maximum\n- { name: attack_speed, minPercentOfAffix: 50 }\n```\n\n</details>\n\n### Affixes\n\nAffixes are defined by the top-level key `Affixes`. It contains a list of filters that you want to apply. Each filter\nhas a name and can filter for any combination of the following:\n\n- `itemType`: The name of the type or a list of multiple types.\n  See [assets/lang/enUS/item_types.json](assets/lang/enUS/item_types.json)\n- `minPower`: Minimum item power\n- `minGreaterAffixCount`: Minimum number of greater affixes expected on the overall item. See [Greater Affix Filtering](#greater-affix-filtering) for more information on filtering GAs.\n- `affixPool`: A list of multiple different rulesets to filter for. Each ruleset must be fulfilled or the item is\n  discarded\n  - `count`: Define a list of affixes (see [syntax](#affix--unique-aspect-filter-syntax)) and\n    optionally `minCount`, `maxCount` and `minGreaterAffixCount`\n    - `minCount`: specifies the minimum number of affixes that must match the item. defaults to amount of specified\n      affixes\n    - `maxCount` specifies the maximum number of affixes that must match the item. defaults to amount of specified\n      affixes\n- `inherentPool`: The same rules as for `affixPool` apply, but this is evaluated against the inherent affixes of the\n  item\n- `uniqueAspect`: If you're looking for a specific unique, this is how you define it. It has the following properties:\n  - `name`: (Required) The name of the unique you are looking for. You can find a list in [uniques.json](assets/lang/enUS/uniques.json)\n  - `value`: What is the minimum value the aspect must have. You can not have both this and minPercentOfAspect\n  - `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.\n\n<details><summary>Config Examples</summary>\n\n```yaml\nAffixes:\n  # Search for chest armor and pants that are at least item level 725 and have at least 3 affixes of the affixPool\n  - NiceArmor:\n      itemType: [ chest armor, pants ]\n      minPower: 725\n      affixPool:\n        - count:\n            - { name: dexterity, value: 33 }\n            - { name: damage_reduction, value: 5 }\n            - { name: lucky_hit_chance, value: 3 }\n            - { name: total_armor, value: 9 }\n            - { name: maximum_life, value: 700 }\n          minCount: 3\n\n  # 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\n  - NiceArmor:\n      itemType: chest armor\n      minPower: 925\n      affixPool:\n        - count:\n            - { name: dexterity }\n            - { name: damage_reduction }\n            - { name: lucky_hit_chance }\n            - { name: total_armor }\n            - { name: maximum_life }\n          minCount: 3\n          minGreaterAffixCount: 2\n\n  # Search for boots that have at least 2 of the specified affixes and either max evade charges or reduced evade cooldown as inherent affix\n  - GreatBoots:\n      itemType: boots\n      minPower: 800\n      inherentPool:\n        - count:\n            - { name: maximum_evade_charges }\n            - { name: attacks_reduce_evades_cooldown_by_seconds }\n          minCount: 1\n      affixPool:\n        - count:\n            - { name: movement_speed, value: 16 }\n            - { name: cold_resistance }\n            - { name: lightning_resistance }\n          minCount: 2\n\n    # Search for boots that have at least 2 of the specified affixes AND are a Penitent Greaves\n    # The Greaves must have at least 19% damage multiplier to chilled enemies (Greaves's range is 15-25)\n    # Note this would not match non-unique boots that have movement speed and cold resistance, it will only match a Penitent Greaves\n  - GreatUniqueBoots:\n      itemType: boots\n      minPower: 800\n      affixPool:\n        - count:\n            - { name: movement_speed, value: 16 }\n            - { name: cold_resistance }\n            - { name: lightning_resistance }\n          minCount: 2\n      uniqueAspect:\n        - name: penitent_greaves\n          minPercentOfAspect: 50\n\n  # Search for boots with movement speed and 1 resistances from a pool of all resistances.\n  # No need to add maxCount to the resistance group since it isn't possible for an item to have more than one resistance affix\n  - ResBoots:\n      itemType: boots\n      minPower: 800\n      affixPool:\n        - count:\n            - { name: movement_speed, value: 16 }\n        - count:\n            - { name: shadow_resistance }\n            - { name: cold_resistance }\n            - { name: lightning_resistance }\n            - { name: fire_resistance }\n            - { name: poison_resistance }\n          minCount: 1\n\n  # Search for boots with movement speed. At least two of all item affixes must be a greater affix, but we don't care which\n  - GreaterAffixBoots:\n      itemType: boots\n      minPower: 800\n      minGreaterAffixCount: 2\n      affixPool:\n        - count:\n            - { name: movement_speed, value: 16 }\n\n  # Keep all ancestral items, even if they don't match a different filter\n  - AncestralMatch:\n      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]\n      minPower: 900\n```\n\n</details>\n\nAffix names are lower case and spaces are replaced by underscore. You can find the full list of names\nin [assets/lang/enUS/affixes.json](assets/lang/enUS/affixes.json).\n\n### Filtering on percent of affix instead of value\n\nYou 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.\n\nA 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.\n\nIf you put in `minPercentOfAffix` you can not also put `value` for that affix. It must be one or the other.\n\nThese rules also apply for `minPercentOfAspect` on the `uniqueAspect` and in `GlobalUniques`.\n\n<details><summary>Config Examples</summary>\n\n```yaml\nAffixes:\n  # Search for chest armor that is at least item level 925 and have at least 3 affixes of the affixPool.\n  # It must have more than 40 damage_reduction, and armor must be at least 70% of its potential maximum affix value\n  - NiceArmor:\n      itemType: chest armor\n      minPower: 925\n      affixPool:\n        - count:\n            - { name: dexterity }\n            - { name: damage_reduction, value: 40 }\n            - { name: lucky_hit_chance }\n            - { name: armor, minPercentOfAffix: 70 }\n            - { name: maximum_life }\n          minCount: 3\n\n```\n\n</details>\n\n### Greater Affix Filtering\n\nD4LF provides two complementary ways to filter items based on Greater Affixes:\n\n#### 1. Item-Level Greater Affix Count (`minGreaterAffixCount`)\n\nThis filter requires a minimum total number of Greater Affixes on the entire item, regardless of which affixes they are.\n\n<details><summary>Example</summary>\n\n```yaml\nAffixes:\n  - GreaterAffixBoots:\n      itemType: boots\n      minGreaterAffixCount: 2  # Item must have at least 2 Greater Affixes total\n      affixPool:\n        - count:\n            - { name: movement_speed }\n            - { name: maximum_life }\n            - { name: strength }\n            - { name: fire_resistance }\n          minCount: 3\n```\n\n</details>\n\n#### 2. Per-Affix Greater Affix Requirements (`want_greater`)\n\nWhen using the Profile Editor GUI or when importing affixes using the importer, you can mark/import specific affixes\nwith a \"Greater\" checkbox. This is shown as `want_greater` in the profile. This is a list of affixes that you would prefer\nto be greater affixes. The `minGreaterAffixCount` value on the item is still respected, so if you have two affixes tagged\nas `want_greater` but a `minGreaterAffixCount` of 1, an item with one of those two affixes as GA will be kept. If neither\nof those affixes are GA but a different one is, the item will not be kept.\n\n<details><summary>Example</summary>\n\n```yaml\nAffixes:\n  - PerfectBoots:\n      itemType: boots\n      affixPool:\n        - count:\n            - { name: movement_speed, want_greater: true }  # MUST be a Greater Affix\n            - { name: maximum_life, want_greater: true }    # MUST be a Greater Affix\n            - { name: strength }                            # Can be normal or Greater\n            - { name: fire_resistance }                      # Can be normal or Greater\n          minCount: 3\n      minGreaterAffixCount: 2  # Auto-set by GUI if Auto-Sync is checked, or Require Greater Affixes is checked on the importer\n```\n\n**This item would match:** Boots with movement_speed (GA), maximum_life (GA), cold_resistance (normal), fire_resistance (normal)\\\n**Why:** movement_speed and maximum_life are both Greater Affixes as required, and item has 4 affixes (meets minCount of 3)\n\n**This item would NOT match:** Boots with movement_speed (normal), maximum_life (GA), cold_resistance (normal), fire_resistance (normal)\\\n**Why:** movement_speed is marked as `want_greater: true` but is not a Greater Affix on the item\n\n</details>\n\n#### Common Use Cases\n\n<details><summary>Examples</summary>\n\n**\"I want boots with at least 2 Greater Affixes, don't care which ones\"**\n\n```yaml\n- itemType: boots\n  minGreaterAffixCount: 2\n  affixPool:\n    - count:\n        - { name: movement_speed }\n        - { name: maximum_life }\n        - { name: strength }\n        - { name: fire_resistance }\n      minCount: 3\n```\n\n**\"I want boots where movement_speed MUST be a Greater Affix\"**\n\n```yaml\n- itemType: boots\n  minGreaterAffixCount: 1  # The minGreaterAffixCount is important, if it was 0 then movement_speed would not be required to be GA\n  affixPool:\n    - count:\n        - { name: movement_speed, want_greater: true }\n        - { name: maximum_life }\n        - { name: strength }\n        - { name: fire_resistance }\n      minCount: 3\n```\n\n**\"I want boots where both movement_speed AND maximum_life MUST be Greater Affixes\"**\n\n```yaml\n- itemType: boots\n  minGreaterAffixCount: 2  # minGreaterAffixCount of 2 requires both to be GA\n  affixPool:\n    - count:\n        - { name: movement_speed, want_greater: true }\n        - { name: maximum_life, want_greater: true }\n        - { name: strength }\n        - { name: fire_resistance }\n      minCount: 3\n```\n\n**\"I want boots where either movement_speed OR maximum_life are Greater Affixes\"**\n\n```yaml\n- itemType: boots\n  minGreaterAffixCount: 1  # minGreaterAffixCount of 1 requires either to be GA\n  affixPool:\n    - count:\n        - { name: movement_speed, want_greater: true }\n        - { name: maximum_life, want_greater: true }\n        - { name: strength } # If strength on the item was greater and the top two were not, this would not be matched\n        - { name: fire_resistance }\n      minCount: 3\n```\n\n</details>\n\n### AspectUpgrades\n\nLegendary Aspects that you want to be notified of receiving upgrades for can be placed in your profile.\nThey are defined in the top-level key `AspectUpgrades`.\n\nThis filter is generally for build-specific aspects that you'd like to be made aware of when you receive an upgrade so you can\nupgrade that aspect immediately at the occultist. We notify the user by favoriting the item and showing orange text or\norange highlighting when hovering over the item.\n\nIf the item matches any other profile, this filter does nothing. This filter does respect the `mark_as_favorite` config property.\nAny aspects that do not match this filter or are not codex upgrades are handled by the `keep_aspects` config property.\n\n<details><summary>Config Examples</summary>\n\n```yaml\nAspectUpgrades:\n  # This would mark Snowveiled Adventurer's Pants as a favorite if it's a codex upgrade. It would ignore the pants otherwise.\n  - of_singed_extremities\n  - snowveiled\n```\n\n```yaml\n# This works exact same as above, it's just a different way to format it\nAspectUpgrades: [of_singed_extremities, snowveiled]\n```\n\n</details>\n\nAspect names are lower case and spaces are replaced by underscore. You can find the full list of names\nin [assets/lang/enUS/aspects.json](assets/lang/enUS/aspects.json).\n\n### Sigils\n\nSigils are defined by the top-level key `Sigils`. It contains a list of affix or location names that you want to filter\nfor. If no Sigil filter is provided, all Sigils will be kept.\n\n<details><summary>Config Examples</summary>\n\n```yaml\nSigils:\n  blacklist:\n    # locations\n    - endless_gates\n    - vault_of_the_forsaken\n\n    # affixes\n    - armor_breakers\n    - resistance_breakers\n```\n\nIf you want to filter for a specific affix or location, you can also use the `whitelist` key. Even if `whitelist` is\npresent, `blacklist` will be used to discard sigils that match any of the blacklisted affixes or locations.\n\n```yaml\n# Only keep sigils for vault_of_the_forsaken without any of the affixes armor_breakers and resistance_breakers\nSigils:\n  blacklist:\n    - armor_breakers\n    - resistance_breakers\n  whitelist:\n    - vault_of_the_forsaken\n```\n\nTo switch that priority, you can add the `priority` key with the value `whitelist`.\n\n```yaml\n# This will keep all vault of the forsaken sigils even if they have armor_breakers or resistance_breakers\nSigils:\n  blacklist:\n    - armor_breakers\n    - resistance_breakers\n  whitelist:\n    - vault_of_the_forsaken\n  priority: whitelist\n```\n\nYou can also create conditional filters based on a single affix or location.\n\n```yaml\n# Only keep sigils for iron_hold when it also has shadow_damage\nSigils:\n  blacklist:\n    - armor_breakers\n    - resistance_breakers\n  whitelist:\n    - [ iron_hold, shadow_damage ]\n```\n\n</details>\n\nSigil affixes and location names are lower case and spaces are replaced by underscore. You can find the full list of\nnames in [assets/lang/enUS/sigils.json](assets/lang/enUS/sigils.json).\n\n### Tributes\n\nTributes are defined by the top-level key `Tributes`. It contains a list of either tribute names or rarities you want\nto keep. Any not in the list are not kept. If no Tribute filter is provided, all Tributes will be kept.\n\nMythic tributes are always kept no matter what.\n\n<details><summary>Config Examples</summary>\n\n```yaml\n# Keeps tribute_of_mystique and all legendary and unique tributes\nTributes:\n  - tribute_of_mystique\n  - [legendary, unique]\n```\n\nIf you're exceptionally pressed for time, you can just put the name of the tribute without \"tribute_of\\_\" at the beginning.\n\n```yaml\n# Keeps Tribute of Mystique and Tribute of Ascendance (Resolute) and nothing else\nTributes:\n  - mystique\n  - ascendance_resolute\n```\n\n</details>\n\nTribute names are lower case and spaces are replaced by underscore. Parentheses are removed. Note that United and\nResolute 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\nin [rarity.py](src/item/data/rarity.py)\n\n### GlobalUniques\n\nIf you are searching for a specific Unique, use the `uniqueAspect` key in [the Affixes section](#affixes). If you\nadditionally want to keep other uniques that have particular stats, use the `GlobalUniques` key.\n\nGlobal unique filters are defined by the top-level key `GlobalUniques`. It contains a list of parameters that you want\nto filter for. If no global unique filter is provided or if the item does not match any unique filter (affix or otherwise),\nuniques will be handled according to the handle_uniques configuration. All mythics are marked as favorite regardless of\nany filter or configuration.\n\nThe following global filters are available:\n\n- `minGreaterAffixCount`: Only keep uniques with a specific number of greater affixes\n- `minPercentOfAspect`: Only keep uniques whose aspect is above a percentage of the total possible.\n  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\n  of 150 would be marked as junk. Situations where a smaller value is what is wanted are automatically handled as well.\n- `minPower`: The minimum item power of uniques to keep\n- `profileAlias`: In vision mode, uniques show as <filename>.<aspect>. For example myuniques.yaml with fists_of_fate aspect defined\n  would show as myuniques.fists_of_fate. The label for the filename can be configured at the aspect level using the\n  profileAlias flag (see examples).\n\n<details><summary>Config Examples</summary>\n\n```yaml\n# Take all uniques with item power > 900\nGlobalUniques:\n  - minPower: 900\n```\n\n```yaml\n# Take all uniques with at least 1 greater affix. It would show in logs/vision mode as cool_stuff.<name of unique>\nGlobalUniques:\n  - minGreaterAffixCount: 1\n    profileAlias: cool_stuff\n```\n\n```yaml\n# Note that if a unique matches any filter, it is kept. Each - denotes a new filter.\n# For example, the below will keep all uniques that have two greater affixes OR an aspect percentage greater than 80\nGlobalUniques:\n  - minGreaterAffixCount: 2\n  - minPercentOfAspect: 80\n```\n\n```yaml\n# Conversely, this will match all uniques that have two greater affixes AND an aspect percentage greater than 80\nGlobalUniques:\n  - minGreaterAffixCount: 2\n    minPercentOfAspect: 80\n```\n\n</details>\n\n## Paragon overlay\n\n![sample](assets/paragon_overlay.jpg)\n\nD4LF can import Paragon boards from supported build planners and show them in-game using the Paragon overlay.\n\n**How to use**\n\n1. Import your build from a supported planner (Mobalytics / Maxroll / D4Builds).\n1. Enable **Import Paragon** in the importer. Paragon data will be stored in your profile YAMLs in the profiles folder (default: `~/.d4lf/profiles`).\n1. Toggle the Paragon overlay using the hotkey (default **F10**, configurable in *Advanced options*).\n1. 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.\n\n**Tips**\n\n- Overlays may not work in exclusive fullscreen; use **borderless windowed** if the overlay does not appear.\n- Planner websites can change over time. If an import/export stops working, please report a bug.\n\n## Future Plans\n\n- A video explaining the initial setup\n- Evaluate using joystick emulation to further increase speed for users willing to do additional setup\n- Finish GUI documentation\n- 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!\n\n## Develop\n\n### Setup using uv\n\nIf you intend to submit PRs, create your own fork of d4lf and clone that in the steps below.\n\nBefore beginning, [install uv](https://docs.astral.sh/uv/getting-started/installation/#winget).\n\n```bash\ngit clone https://github.com/d4lfteam/d4lf\ncd d4lf\nuv sync\nuv run python -m src.main\n```\n\nIf 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.\n\n### Formatting & Linting\n\nJust use prek. If it's your first setup, you will need to install the NuGet package provider. Open Windows Powershell and run::\n\n```\nInstall-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser\n```\n\nThen run:\n\n```bash\nprek install\n```\n\nOtherwise just run:\n\n```bash\nprek run -a\n```\n\n### A note on use of AI for PRs\n\nAI usage is not banned for D4LF, but some things need to be kept in mind:\n\n- You are responsible for any PR you submit.\n  - It is expected you have tested your code\n  - It is expected you will fix any bugs resulting from your work\n  - You need to have an understanding of the changes you're making and why you're making them\n- PRs should change as little code as possible, only what needs to be changed for the new feature you are implementing.\n- Unless something is being deleted, existing code comments should be maintained\n- 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.\n- 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.\n\nUltimately, 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.\n\n## Credits\n\n- Icon based of: [CarbotAnimations](https://www.youtube.com/carbotanimations/about)\n- Some of the OCR code is originally from [@gleed](https://github.com/aliig). Good guy\n- Names and textures for matching from [Blizzard](https://www.blizzard.com)\n- Thanks to NekrosStratia for the initial idea and help with TTS mode\n"
  },
  {
    "path": "assets/lang/enUS/How to add to these files.md",
    "content": "These files are all autogenerated from data from d4companion and d4data.\nAny manual additions to them will be overwritten the next time that data is updated.\n\nIf you want to add data to these files, do the following steps:\n\n1. Download the latest version of d4data: https://github.com/DiabloTools/d4data.git\n1. Download the latest version of d4companion: https://github.com/josdemmers/Diablo4Companion\n1. Run [gen_data.py](/src/tools/gen_data.py). You provide the paths of the above two downloads. For example,\n   you might run: `python gen_data.py C:\\Users\\you\\code\\d4data C:\\Users\\you\\code\\Diablo4Companion`\n\nIf 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.\n\nYou 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.\n\nThe 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.\n\nAfter adding your custom data, run gen_data again and ensure your asset file looks how you expect. Open a PR after.\n"
  },
  {
    "path": "assets/lang/enUS/affixes.json",
    "content": "{\n    \"abyss_damage\": \"abyss damage\",\n    \"advance_resource_generation\": \"advance resource generation\",\n    \"aegis_cooldown_reduction\": \"aegis cooldown reduction\",\n    \"agility_cooldown_reduction\": \"agility cooldown reduction\",\n    \"agility_damage\": \"agility damage\",\n    \"all_damage_multiplier\": \"all damage multiplier\",\n    \"all_stats\": \"all stats\",\n    \"all_stats_per_ferocity_or_resolve_stack\": \"all stats per ferocity or resolve stack\",\n    \"ancient_damage\": \"ancient damage\",\n    \"anima_of_the_forest_grants_attack_speed\": \"anima of the forest grants attack speed\",\n    \"arbiter_duration\": \"arbiter duration\",\n    \"arbiter_of_justice_cooldown_reduction\": \"arbiter of justice cooldown reduction\",\n    \"archfiend_damage\": \"archfiend damage\",\n    \"armor\": \"armor\",\n    \"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\",\n    \"armor_in_arbiter_form\": \"armor in arbiter form\",\n    \"armor_while_in_human_form\": \"armor while in human form\",\n    \"at_level_)\": \"at level )\",\n    \"attack_speed\": \"attack speed\",\n    \"attack_speed_for_seconds_after_casting_a_defensive_skill\": \"attack speed for seconds after casting a defensive skill\",\n    \"attack_speed_for_seconds_after_dodging_an_attack\": \"attack speed for seconds after dodging an attack\",\n    \"attack_speed_while_berserking\": \"attack speed while berserking\",\n    \"attacks_reduce_evades_cooldown_by_seconds\": \"attacks reduce evades cooldown by seconds\",\n    \"attacks_reduce_ultimate_cooldown_by_seconds\": \"attacks reduce ultimate cooldown by seconds\",\n    \"aura_cooldown_reduction\": \"aura cooldown reduction\",\n    \"aura_enhancement_potency\": \"aura enhancement potency\",\n    \"aura_potency\": \"aura potency\",\n    \"ball_lightning_can_be_cast_while_moving\": \"ball lightning can be cast while moving\",\n    \"ball_lightning_projectile_speed\": \"ball lightning projectile speed\",\n    \"barrier_generation\": \"barrier generation\",\n    \"basic_attack_speed\": \"basic attack speed\",\n    \"basic_damage\": \"basic damage\",\n    \"basic_lucky_hit_chance\": \"basic lucky hit chance\",\n    \"basic_resource_generation\": \"basic resource generation\",\n    \"berserking_duration\": \"berserking duration\",\n    \"bleeding_damage\": \"bleeding damage\",\n    \"blight_chill_potency\": \"blight chill potency\",\n    \"blizzard_damage\": \"blizzard damage\",\n    \"block_chance\": \"block chance\",\n    \"blood_attack_speed\": \"blood attack speed\",\n    \"blood_damage\": \"blood damage\",\n    \"blood_howl_cooldown_reduction\": \"blood howl cooldown reduction\",\n    \"blood_howl_grants_stealth_for_seconds\": \"blood howl grants stealth for seconds\",\n    \"blood_mist_cooldown_reduction\": \"blood mist cooldown reduction\",\n    \"blood_orb_healing\": \"blood orb healing\",\n    \"blood_orbs_restore_essence\": \"blood orbs restore essence\",\n    \"blood_surge_drains_times_from_elites\": \"blood surge drains times from elites\",\n    \"blood_wave_cooldown_reduction\": \"blood wave cooldown reduction\",\n    \"bone_critical_strike_chance\": \"bone critical strike chance\",\n    \"bone_critical_strike_damage\": \"bone critical strike damage\",\n    \"bone_damage\": \"bone damage\",\n    \"bone_prison_cooldown_reduction\": \"bone prison cooldown reduction\",\n    \"bone_spirit_cooldown_reduction\": \"bone spirit cooldown reduction\",\n    \"bone_spirit_damage\": \"bone spirit damage\",\n    \"bone_storm_duration\": \"bone storm duration\",\n    \"boulder_cooldown_reduction\": \"boulder cooldown reduction\",\n    \"boulder_damage\": \"boulder damage\",\n    \"brandish_resource_generation\": \"brandish resource generation\",\n    \"brawling_cooldown_reduction\": \"brawling cooldown reduction\",\n    \"brawling_damage\": \"brawling damage\",\n    \"burning_damage\": \"burning damage\",\n    \"call_of_the_ancients_cooldown_reduction\": \"call of the ancients cooldown reduction\",\n    \"caltrops_cooldown_reduction\": \"caltrops cooldown reduction\",\n    \"casted_hydras_have_heads\": \"casted hydras have heads\",\n    \"casting_blood_wave_fortifies_you_for_maximum_life\": \"casting blood wave fortifies you for maximum life\",\n    \"casting_bone_spear_reduces_blood_waves_cooldown_by_seconds\": \"casting bone spear reduces blood waves cooldown by seconds\",\n    \"casting_justice_skills_restores_primary_resource\": \"casting justice skills restores primary resource\",\n    \"casting_macabre_skills_restores_primary_resource\": \"casting macabre skills restores primary resource\",\n    \"casting_ultimate_skills_restores_primary_resource\": \"casting ultimate skills restores primary resource\",\n    \"casting_valor_skills_restores_primary_resource\": \"casting valor skills restores primary resource\",\n    \"casting_wrath_skills_restores_primary_resource\": \"casting wrath skills restores primary resource\",\n    \"cataclysm_cooldown_reduction\": \"cataclysm cooldown reduction\",\n    \"cataclysm_damage\": \"cataclysm damage\",\n    \"centipede_damage\": \"centipede damage\",\n    \"challenging_shout_cooldown_reduction\": \"challenging shout cooldown reduction\",\n    \"chance_for_arbiter_to_deal_double_damage\": \"chance for arbiter to deal double damage\",\n    \"chance_for_army_of_the_dead_to_deal_double_damage\": \"chance for army of the dead to deal double damage\",\n    \"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\",\n    \"chance_for_basic_skills_to_deal_double_damage\": \"chance for basic skills to deal double damage\",\n    \"chance_for_blood_lance_to_deal_double_damage\": \"chance for blood lance to deal double damage\",\n    \"chance_for_bone_storm_to_deal_double_damage\": \"chance for bone storm to deal double damage\",\n    \"chance_for_brandish_to_deal_double_damage\": \"chance for brandish to deal double damage\",\n    \"chance_for_clash_to_deal_double_damage\": \"chance for clash to deal double damage\",\n    \"chance_for_concussive_stomp_to_extra_hit\": \"chance for concussive stomp to extra hit\",\n    \"chance_for_core_skills_to_hit_twice\": \"chance for core skills to hit twice\",\n    \"chance_for_corpse_explosion_to_deal_double_damage\": \"chance for corpse explosion to deal double damage\",\n    \"chance_for_incinerate_to_deal_double_damage\": \"chance for incinerate to deal double damage\",\n    \"chance_for_judgement_to_deal_double_damage\": \"chance for judgement to deal double damage\",\n    \"chance_for_minion_attacks_to_fortify_you_for_maximum_life\": \"chance for minion attacks to fortify you for maximum life\",\n    \"chance_for_payback_to_deal_double_damage\": \"chance for payback to deal double damage\",\n    \"chance_for_pestilent_swarm_to_deal_double_damage\": \"chance for pestilent swarm to deal double damage\",\n    \"chance_for_potency_skills_to_deal_double_damage\": \"chance for potency skills to deal double damage\",\n    \"chance_for_projectiles_to_cast_twice\": \"chance for projectiles to cast twice\",\n    \"chance_for_rapid_fire_projectiles_to_cast_twice\": \"chance for rapid fire projectiles to cast twice\",\n    \"chance_for_ravens_to_deal_double_damage\": \"chance for ravens to deal double damage\",\n    \"chance_for_retribution_to_deal_double_damage\": \"chance for retribution to deal double damage\",\n    \"chance_for_rock_splitter_to_deal_double_damage\": \"chance for rock splitter to deal double damage\",\n    \"chance_for_rushing_claw_to_deal_double_damage\": \"chance for rushing claw to deal double damage\",\n    \"chance_for_sever_to_deal_double_damage\": \"chance for sever to deal double damage\",\n    \"chance_for_shield_bash_to_deal_double_damage\": \"chance for shield bash to deal double damage\",\n    \"chance_for_shield_charge_to_deal_double_damage\": \"chance for shield charge to deal double damage\",\n    \"chance_for_soar_to_deal_double_damage\": \"chance for soar to deal double damage\",\n    \"chance_for_soulrift_to_deal_double_damage\": \"chance for soulrift to deal double damage\",\n    \"chance_for_spear_of_the_heavens_to_deal_double_damage\": \"chance for spear of the heavens to deal double damage\",\n    \"chance_for_the_devourer_to_deal_double_damage\": \"chance for the devourer to deal double damage\",\n    \"chance_for_the_hunter_to_deal_double_damage\": \"chance for the hunter to deal double damage\",\n    \"chance_for_the_protector_to_deal_double_damage\": \"chance for the protector to deal double damage\",\n    \"chance_for_the_seeker_to_deal_double_damage\": \"chance for the seeker to deal double damage\",\n    \"chance_for_thrash_to_deal_double_damage\": \"chance for thrash to deal double damage\",\n    \"chance_for_thunderspike_to_deal_double_damage\": \"chance for thunderspike to deal double damage\",\n    \"chance_for_vortex_to_extra_hit\": \"chance for vortex to extra hit\",\n    \"chance_for_withering_fist_to_deal_double_damage\": \"chance for withering fist to deal double damage\",\n    \"chance_for_zeal_to_deal_double_damage\": \"chance for zeal to deal double damage\",\n    \"chance_to_cluck_thrice\": \"chance to cluck thrice\",\n    \"chance_when_struck_to_fortify_for_life\": \"chance when struck to fortify for life\",\n    \"chance_when_struck_to_gain_life_as_barrier_for_seconds\": \"chance when struck to gain life as barrier for seconds\",\n    \"charge_cooldown_reduction\": \"charge cooldown reduction\",\n    \"charge_damage\": \"charge damage\",\n    \"chill_slow_potency\": \"chill slow potency\",\n    \"clash_resource_generation\": \"clash resource generation\",\n    \"cold_damage\": \"cold damage\",\n    \"cold_damage_multiplier\": \"cold damage multiplier\",\n    \"cold_mage_attack_speed\": \"cold mage attack speed\",\n    \"cold_resistance\": \"cold resistance\",\n    \"companion_cooldown_reduction\": \"companion cooldown reduction\",\n    \"companion_damage\": \"companion damage\",\n    \"concealment_cooldown_reduction\": \"concealment cooldown reduction\",\n    \"condemn_cooldown_reduction\": \"condemn cooldown reduction\",\n    \"conjuration_cooldowns_are_reduced_by_seconds_when_a_frozen_orb_explodes\": \"conjuration cooldowns are reduced by seconds when a frozen orb explodes\",\n    \"conjuration_damage\": \"conjuration damage\",\n    \"consecration_cooldown_reduction\": \"consecration cooldown reduction\",\n    \"cooldown_reduction\": \"cooldown reduction\",\n    \"core_attack_speed\": \"core attack speed\",\n    \"core_damage\": \"core damage\",\n    \"core_resource_cost_reduction\": \"core resource cost reduction\",\n    \"corpse_attack_speed\": \"corpse attack speed\",\n    \"corpse_damage\": \"corpse damage\",\n    \"corpse_explosion_damage\": \"corpse explosion damage\",\n    \"corpse_explosion_fears_and_slows_for_seconds\": \"corpse explosion fears and slows for seconds\",\n    \"corpse_tendrils_damage\": \"corpse tendrils damage\",\n    \"corrupting_damage\": \"corrupting damage\",\n    \"counterattack_charges\": \"counterattack charges\",\n    \"crackling_energy_damage\": \"crackling energy damage\",\n    \"critical_strike_and_vulnerable_damage\": \"critical strike and vulnerable damage\",\n    \"critical_strike_chance\": \"critical strike chance\",\n    \"critical_strike_chance_against_chilled_enemies\": \"critical strike chance against chilled enemies\",\n    \"critical_strike_chance_against_close_enemies\": \"critical strike chance against close enemies\",\n    \"critical_strike_chance_against_crowd_controlled_enemies\": \"critical strike chance against crowd controlled enemies\",\n    \"critical_strike_chance_against_feared_enemies\": \"critical strike chance against feared enemies\",\n    \"critical_strike_chance_against_injured_enemies\": \"critical strike chance against injured enemies\",\n    \"critical_strike_chance_against_stunned_enemies\": \"critical strike chance against stunned enemies\",\n    \"critical_strike_chance_to_each_enhanced_rapid_fire_bonus\": \"critical strike chance to each enhanced rapid fire bonus\",\n    \"critical_strike_damage\": \"critical strike damage\",\n    \"critical_strike_damage_multiplier\": \"critical strike damage multiplier\",\n    \"crowd_control_duration\": \"crowd control duration\",\n    \"crowd_control_duration_lucky_hit_up_to_a_chance_to_heal_life\": \"crowd control duration lucky hit up to a chance to heal life\",\n    \"curse_duration\": \"curse duration\",\n    \"cutthroat_attack_speed\": \"cutthroat attack speed\",\n    \"cutthroat_critical_strike_chance\": \"cutthroat critical strike chance\",\n    \"cutthroat_critical_strike_damage\": \"cutthroat critical strike damage\",\n    \"cutthroat_damage\": \"cutthroat damage\",\n    \"cyclone_armor_cooldown_reduction\": \"cyclone armor cooldown reduction\",\n    \"cyclone_armor_damage\": \"cyclone armor damage\",\n    \"damage\": \"damage\",\n    \"damage_for_seconds_after_dodging_an_attack\": \"damage for seconds after dodging an attack\",\n    \"damage_for_seconds_after_gaining_resolve\": \"damage for seconds after gaining resolve\",\n    \"damage_for_seconds_after_killing_an_elite\": \"damage for seconds after killing an elite\",\n    \"damage_for_seconds_after_picking_up_a_blood_orb\": \"damage for seconds after picking up a blood orb\",\n    \"damage_on_next_attack_after_entering_stealth\": \"damage on next attack after entering stealth\",\n    \"damage_over_time\": \"damage over time\",\n    \"damage_over_time_duration\": \"damage over time duration\",\n    \"damage_over_time_multiplier\": \"damage over time multiplier\",\n    \"damage_per_combo_point_spent\": \"damage per combo point spent\",\n    \"damage_per_overpower_stack\": \"damage per overpower stack\",\n    \"damage_reduction\": \"damage reduction\",\n    \"damage_reduction_for_each_active_ball_lightning\": \"damage reduction for each active ball lightning\",\n    \"damage_reduction_for_your_summons\": \"damage reduction for your summons\",\n    \"damage_reduction_from_bleeding_enemies\": \"damage reduction from bleeding enemies\",\n    \"damage_reduction_from_burning_enemies\": \"damage reduction from burning enemies\",\n    \"damage_reduction_from_close_enemies\": \"damage reduction from close enemies\",\n    \"damage_reduction_from_corrupted_enemies\": \"damage reduction from corrupted enemies\",\n    \"damage_reduction_from_distant_enemies\": \"damage reduction from distant enemies\",\n    \"damage_reduction_from_elites\": \"damage reduction from elites\",\n    \"damage_reduction_from_enemies_affected_by_blood_skills\": \"damage reduction from enemies affected by blood skills\",\n    \"damage_reduction_from_enemies_affected_by_curse_skills\": \"damage reduction from enemies affected by curse skills\",\n    \"damage_reduction_from_enemies_affected_by_trap_skills\": \"damage reduction from enemies affected by trap skills\",\n    \"damage_reduction_from_poisoned_enemies\": \"damage reduction from poisoned enemies\",\n    \"damage_reduction_per_crackling_energy_charge\": \"damage reduction per crackling energy charge\",\n    \"damage_reduction_while_fortified\": \"damage reduction while fortified\",\n    \"damage_reduction_while_healthy\": \"damage reduction while healthy\",\n    \"damage_reduction_while_injured\": \"damage reduction while injured\",\n    \"damage_reduction_while_standing_still\": \"damage reduction while standing still\",\n    \"damage_reduction_while_unstoppable\": \"damage reduction while unstoppable\",\n    \"damage_reduction_while_you_have_a_barrier\": \"damage reduction while you have a barrier\",\n    \"damage_to_angels_and_demons\": \"damage to angels and demons\",\n    \"damage_to_bleeding_enemies\": \"damage to bleeding enemies\",\n    \"damage_to_burning_enemies\": \"damage to burning enemies\",\n    \"damage_to_chilled_enemies\": \"damage to chilled enemies\",\n    \"damage_to_close_enemies\": \"damage to close enemies\",\n    \"damage_to_corrupted_enemies\": \"damage to corrupted enemies\",\n    \"damage_to_crowd_controlled_enemies\": \"damage to crowd controlled enemies\",\n    \"damage_to_cursed_enemies\": \"damage to cursed enemies\",\n    \"damage_to_dazed_enemies\": \"damage to dazed enemies\",\n    \"damage_to_distant_enemies\": \"damage to distant enemies\",\n    \"damage_to_elites\": \"damage to elites\",\n    \"damage_to_frozen_enemies\": \"damage to frozen enemies\",\n    \"damage_to_healthy_enemies\": \"damage to healthy enemies\",\n    \"damage_to_immobilized_enemies\": \"damage to immobilized enemies\",\n    \"damage_to_injured_enemies\": \"damage to injured enemies\",\n    \"damage_to_judged_enemies\": \"damage to judged enemies\",\n    \"damage_to_knockeddown_enemies\": \"damage to knockeddown enemies\",\n    \"damage_to_poisoned_enemies\": \"damage to poisoned enemies\",\n    \"damage_to_poultry\": \"damage to poultry\",\n    \"damage_to_slowed_enemies\": \"damage to slowed enemies\",\n    \"damage_to_stunned_enemies\": \"damage to stunned enemies\",\n    \"damage_to_trapped_enemies\": \"damage to trapped enemies\",\n    \"damage_to_weakened_enemies\": \"damage to weakened enemies\",\n    \"damage_when_spending_resolve\": \"damage when spending resolve\",\n    \"damage_when_swapping_weapons\": \"damage when swapping weapons\",\n    \"damage_while_berserking\": \"damage while berserking\",\n    \"damage_while_fortified\": \"damage while fortified\",\n    \"damage_while_healthy\": \"damage while healthy\",\n    \"damage_while_in_arbiter_form\": \"damage while in arbiter form\",\n    \"damage_while_in_human_form\": \"damage while in human form\",\n    \"damage_while_iron_maelstrom_is_active\": \"damage while iron maelstrom is active\",\n    \"damage_while_shadowform_is_active\": \"damage while shadowform is active\",\n    \"damage_while_shapeshifted\": \"damage while shapeshifted\",\n    \"damage_while_war_cry_is_active\": \"damage while war cry is active\",\n    \"damage_while_wrath_of_the_berserker_is_active\": \"damage while wrath of the berserker is active\",\n    \"damage_with_dualwielded_weapons\": \"damage with dualwielded weapons\",\n    \"damage_with_ranged_weapons\": \"damage with ranged weapons\",\n    \"damage_with_twohanded_bludgeoning_weapons\": \"damage with twohanded bludgeoning weapons\",\n    \"damage_with_twohanded_slashing_weapons\": \"damage with twohanded slashing weapons\",\n    \"dark_shroud_cooldown_reduction\": \"dark shroud cooldown reduction\",\n    \"darkness_damage\": \"darkness damage\",\n    \"dash_cooldown_reduction\": \"dash cooldown reduction\",\n    \"dash_damage\": \"dash damage\",\n    \"death_blow_cooldown_reduction\": \"death blow cooldown reduction\",\n    \"death_blow_damage\": \"death blow damage\",\n    \"death_trap_cooldown_reduction\": \"death trap cooldown reduction\",\n    \"debilitating_roar_cooldown_reduction\": \"debilitating roar cooldown reduction\",\n    \"deep_freeze_cooldown_reduction\": \"deep freeze cooldown reduction\",\n    \"defensive_cooldown_reduction\": \"defensive cooldown reduction\",\n    \"defensive_damage\": \"defensive damage\",\n    \"defiance_aura_potency\": \"defiance aura potency\",\n    \"demonform_damage_bonus\": \"demonform damage bonus\",\n    \"demonology_damage\": \"demonology damage\",\n    \"desecrated_ground_damage\": \"desecrated ground damage\",\n    \"dexterity\": \"dexterity\",\n    \"disciple_damage\": \"disciple damage\",\n    \"dodge_chance\": \"dodge chance\",\n    \"dodge_chance_against_close_enemies\": \"dodge chance against close enemies\",\n    \"dodge_chance_against_distant_enemies\": \"dodge chance against distant enemies\",\n    \"dodge_chance_while_channeling_dance_of_knives\": \"dodge chance while channeling dance of knives\",\n    \"drinking_a_potion_grants_movement_speed_for_seconds\": \"drinking a potion grants movement speed for seconds\",\n    \"dust_devil_damage\": \"dust devil damage\",\n    \"eagle_damage\": \"eagle damage\",\n    \"earth_attack_speed\": \"earth attack speed\",\n    \"earth_critical_strike_chance\": \"earth critical strike chance\",\n    \"earth_critical_strike_damage\": \"earth critical strike damage\",\n    \"earth_damage\": \"earth damage\",\n    \"earth_lucky_hit_chance\": \"earth lucky hit chance\",\n    \"earthen_bulwark_cooldown_reduction\": \"earthen bulwark cooldown reduction\",\n    \"earthquake_damage\": \"earthquake damage\",\n    \"enchantment_damage\": \"enchantment damage\",\n    \"energy_cost_reduction\": \"energy cost reduction\",\n    \"energy_on_kill\": \"energy on kill\",\n    \"energy_regeneration\": \"energy regeneration\",\n    \"energy_when_a_stun_grenade_explodes\": \"energy when a stun grenade explodes\",\n    \"enhanced_rupture_explosion_size\": \"enhanced rupture explosion size\",\n    \"essence_cost_reduction\": \"essence cost reduction\",\n    \"essence_on_hit\": \"essence on hit\",\n    \"essence_on_kill\": \"essence on kill\",\n    \"essence_per_enemy_drained_by_blood_surge\": \"essence per enemy drained by blood surge\",\n    \"essence_regeneration\": \"essence regeneration\",\n    \"evade_cooldown_reduction\": \"evade cooldown reduction\",\n    \"evade_grants_attack_speed_for_seconds\": \"evade grants attack speed for seconds\",\n    \"evade_grants_movement_speed_for_seconds\": \"evade grants movement speed for seconds\",\n    \"evade_grants_unhindered_for_seconds\": \"evade grants unhindered for seconds\",\n    \"evade_leaves_behind_desecrated_ground_for_seconds\": \"evade leaves behind desecrated ground for seconds\",\n    \"faith_on_kill\": \"faith on kill\",\n    \"faith_regeneration\": \"faith regeneration\",\n    \"falling_star_cooldown_reduction\": \"falling star cooldown reduction\",\n    \"familiar_damage\": \"familiar damage\",\n    \"familiar_lucky_hit_chance\": \"familiar lucky hit chance\",\n    \"fanaticism_aura_potency\": \"fanaticism aura potency\",\n    \"feast_every_kills_chains_hook_nearby_enemies\": \"feast every kills, chains hook nearby enemies\",\n    \"feast_every_kills_gain_berserking_for_seconds\": \"feast every kills, gain berserking for seconds\",\n    \"feast_every_kills_release_a_bloodsplosion_for_damage\": \"feast every kills, release a bloodsplosion for damage\",\n    \"feast_every_kills_reset_random_cooldowns\": \"feast every kills, reset random cooldowns\",\n    \"feast_every_kills_restore_of_your_maximum_primary_resource\": \"feast every kills, restore of your maximum primary resource\",\n    \"feast_every_kills_savagely_bite_times_for_damage_and_apply_vulnerable\": \"feast every kills, savagely bite times for damage and apply vulnerable\",\n    \"feast_every_kills_your_next_core_skill_cast_deals_additional_damage\": \"feast every kills, your next core skill cast deals additional damage\",\n    \"ferocity_potency\": \"ferocity potency\",\n    \"fire_and_cold_damage\": \"fire and cold damage\",\n    \"fire_damage\": \"fire damage\",\n    \"fire_damage_multiplier\": \"fire damage multiplier\",\n    \"fire_damage_ranks_of_the_inner_flames_passive\": \"fire damage ranks of the inner flames passive\",\n    \"fire_lucky_hit_chance\": \"fire lucky hit chance\",\n    \"fire_resistance\": \"fire resistance\",\n    \"fireball_attack_speed\": \"fireball attack speed\",\n    \"fireball_projectile_speed\": \"fireball projectile speed\",\n    \"focus_cooldown_reduction\": \"focus cooldown reduction\",\n    \"focus_damage\": \"focus damage\",\n    \"fortify_generation\": \"fortify generation\",\n    \"fortress_cooldown_reduction\": \"fortress cooldown reduction\",\n    \"freeze_duration\": \"freeze duration\",\n    \"frost_critical_strike_chance\": \"frost critical strike chance\",\n    \"frost_damage\": \"frost damage\",\n    \"frost_nova_cooldown_reduction\": \"frost nova cooldown reduction\",\n    \"fury_cost_reduction\": \"fury cost reduction\",\n    \"fury_on_kill\": \"fury on kill\",\n    \"fury_regeneration\": \"fury regeneration\",\n    \"gem_strength_in_this_item\": \"gem strength in this item\",\n    \"gold_drop_rate\": \"gold drop rate\",\n    \"golem_active_cooldown_reduction\": \"golem active cooldown reduction\",\n    \"golem_damage\": \"golem damage\",\n    \"golems_inherit_of_your_thorns\": \"golems inherit of your thorns\",\n    \"gorilla_damage\": \"gorilla damage\",\n    \"grenade_damage\": \"grenade damage\",\n    \"grizzly_rage_cooldown_reduction\": \"grizzly rage cooldown reduction\",\n    \"ground_stomp_cooldown_reduction\": \"ground stomp cooldown reduction\",\n    \"ground_stomp_damage\": \"ground stomp damage\",\n    \"hammer_of_the_ancients_damage_for_seconds_after_an_earthquake_explodes\": \"hammer of the ancients damage for seconds after an earthquake explodes\",\n    \"healing_received\": \"healing received\",\n    \"heavens_fury_cooldown_reduction\": \"heavens fury cooldown reduction\",\n    \"hellfire_damage\": \"hellfire damage\",\n    \"hewed_flesh_grants_maximum_life_as_barrier_for_seconds\": \"hewed flesh grants maximum life as barrier for seconds\",\n    \"holy_bolt_resource_generation\": \"holy bolt resource generation\",\n    \"holy_damage\": \"holy damage\",\n    \"holy_damage_multiplier\": \"holy damage multiplier\",\n    \"holy_light_aura_potency\": \"holy light aura potency\",\n    \"human_damage\": \"human damage\",\n    \"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\",\n    \"hunger_after_you_cast_a_cooldown_kill_to_your_kill_streak\": \"hunger after you cast a cooldown, kill to your kill streak\",\n    \"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\",\n    \"hunger_every_resource_chance_for_kill_to_your_kill_streak\": \"hunger every resource, chance for kill to your kill streak\",\n    \"hunger_increased_chance_for_additional_gold_during_kill_streaks\": \"hunger increased chance for additional gold during kill streaks\",\n    \"hunger_increased_chance_for_additional_salvage_materials_during_your_kill_streaks\": \"hunger increased chance for additional salvage materials during your kill streaks\",\n    \"hunger_increased_chance_for_feast_items_during_your_kill_streaks\": \"hunger increased chance for feast items during your kill streaks\",\n    \"hunger_increased_chance_for_hunger_items_during_kill_streaks\": \"hunger increased chance for hunger items during kill streaks\",\n    \"hunger_increased_chance_for_rampage_items_during_kill_streaks\": \"hunger increased chance for rampage items during kill streaks\",\n    \"hunger_increased_chance_for_runes_during_your_kill_streaks\": \"hunger increased chance for runes during your kill streaks\",\n    \"hunger_increased_experience_from_kill_streaks\": \"hunger increased experience from kill streaks\",\n    \"hunger_increased_reputation_from_kill_streaks\": \"hunger increased reputation from kill streaks\",\n    \"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\",\n    \"hurricane_cooldown_reduction\": \"hurricane cooldown reduction\",\n    \"hurricane_damage\": \"hurricane damage\",\n    \"hydra_damage\": \"hydra damage\",\n    \"hydra_lucky_hit_chance\": \"hydra lucky hit chance\",\n    \"hydra_resource_cost_reduction\": \"hydra resource cost reduction\",\n    \"ice_blades_cooldown_reduction\": \"ice blades cooldown reduction\",\n    \"ice_blades_damage\": \"ice blades damage\",\n    \"ice_blades_lucky_hit_chance\": \"ice blades lucky hit chance\",\n    \"ice_spike_damage\": \"ice spike damage\",\n    \"ice_spikes_freeze_enemies_for_seconds\": \"ice spikes freeze enemies for seconds\",\n    \"imbued_critical_strike_damage\": \"imbued critical strike damage\",\n    \"imbued_damage\": \"imbued damage\",\n    \"imbuement_cooldown_reduction\": \"imbuement cooldown reduction\",\n    \"imbuement_damage\": \"imbuement damage\",\n    \"imbuement_potency\": \"imbuement potency\",\n    \"immobilize_duration\": \"immobilize duration\",\n    \"impairment_reduction\": \"impairment reduction\",\n    \"incarnate_cooldown_reduction\": \"incarnate cooldown reduction\",\n    \"indestructible\": \"indestructible\",\n    \"inferno_cooldown_reduction\": \"inferno cooldown reduction\",\n    \"inner_sight_duration\": \"inner sight duration\",\n    \"intelligence\": \"intelligence\",\n    \"invigorating_strike_energy_regeneration\": \"invigorating strike energy regeneration\",\n    \"iron_maelstrom_cooldown_reduction\": \"iron maelstrom cooldown reduction\",\n    \"iron_maiden_damage\": \"iron maiden damage\",\n    \"iron_skin_cooldown_reduction\": \"iron skin cooldown reduction\",\n    \"item_quality\": \"item quality\",\n    \"jaguar_damage\": \"jaguar damage\",\n    \"judicator_damage\": \"judicator damage\",\n    \"juggernaut_damage\": \"juggernaut damage\",\n    \"justice_cooldown_reduction\": \"justice cooldown reduction\",\n    \"justice_damage\": \"justice damage\",\n    \"kick_cooldown_reduction\": \"kick cooldown reduction\",\n    \"kick_damage\": \"kick damage\",\n    \"lacerate_cooldown_reduction\": \"lacerate cooldown reduction\",\n    \"lacerate_damage\": \"lacerate damage\",\n    \"leap_cooldown_reduction\": \"leap cooldown reduction\",\n    \"leap_damage\": \"leap damage\",\n    \"life_on_hit\": \"life on hit\",\n    \"life_on_kill\": \"life on kill\",\n    \"life_per_seconds\": \"life per seconds\",\n    \"life_regeneration\": \"life regeneration\",\n    \"life_steal\": \"life steal\",\n    \"lightning_bolt_damage\": \"lightning bolt damage\",\n    \"lightning_critical_strike_damage\": \"lightning critical strike damage\",\n    \"lightning_damage\": \"lightning damage\",\n    \"lightning_damage_multiplier\": \"lightning damage multiplier\",\n    \"lightning_resistance\": \"lightning resistance\",\n    \"lightning_spear_cooldown_reduction\": \"lightning spear cooldown reduction\",\n    \"lightning_spear_damage\": \"lightning spear damage\",\n    \"lightning_spear_lucky_hit_chance\": \"lightning spear lucky hit chance\",\n    \"lucky_hit_chance\": \"lucky hit chance\",\n    \"lucky_hit_chance_while_you_have_a_barrier\": \"lucky hit chance while you have a barrier\",\n    \"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\",\n    \"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\",\n    \"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\",\n    \"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\",\n    \"lucky_hit_up_to_a_bleeding_damage_over_seconds\": \"lucky hit up to a bleeding damage over seconds\",\n    \"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\",\n    \"lucky_hit_up_to_a_chance_to_become_berserking\": \"lucky hit up to a chance to become berserking\",\n    \"lucky_hit_up_to_a_chance_to_chill_for_seconds\": \"lucky hit up to a chance to chill for seconds\",\n    \"lucky_hit_up_to_a_chance_to_daze_for_seconds\": \"lucky hit up to a chance to daze for seconds\",\n    \"lucky_hit_up_to_a_chance_to_deal_cold_damage\": \"lucky hit up to a chance to deal cold damage\",\n    \"lucky_hit_up_to_a_chance_to_deal_fire_damage\": \"lucky hit up to a chance to deal fire damage\",\n    \"lucky_hit_up_to_a_chance_to_deal_holy_damage\": \"lucky hit up to a chance to deal holy damage\",\n    \"lucky_hit_up_to_a_chance_to_deal_lightning_damage\": \"lucky hit up to a chance to deal lightning damage\",\n    \"lucky_hit_up_to_a_chance_to_deal_physical_damage\": \"lucky hit up to a chance to deal physical damage\",\n    \"lucky_hit_up_to_a_chance_to_deal_poison_damage\": \"lucky hit up to a chance to deal poison damage\",\n    \"lucky_hit_up_to_a_chance_to_deal_shadow_damage\": \"lucky hit up to a chance to deal shadow damage\",\n    \"lucky_hit_up_to_a_chance_to_execute_injured_nonelites\": \"lucky hit up to a chance to execute injured nonelites\",\n    \"lucky_hit_up_to_a_chance_to_fear_for_seconds\": \"lucky hit up to a chance to fear for seconds\",\n    \"lucky_hit_up_to_a_chance_to_freeze_for_seconds\": \"lucky hit up to a chance to freeze for seconds\",\n    \"lucky_hit_up_to_a_chance_to_gain_a_stack_of_frenzy\": \"lucky hit up to a chance to gain a stack of frenzy\",\n    \"lucky_hit_up_to_a_chance_to_gain_damage_for_seconds\": \"lucky hit up to a chance to gain damage for seconds\",\n    \"lucky_hit_up_to_a_chance_to_heal_life\": \"lucky hit up to a chance to heal life\",\n    \"lucky_hit_up_to_a_chance_to_immobilize_for_seconds\": \"lucky hit up to a chance to immobilize for seconds\",\n    \"lucky_hit_up_to_a_chance_to_knockback_for_seconds\": \"lucky hit up to a chance to knockback for seconds\",\n    \"lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds\": \"lucky hit up to a chance to make enemies vulnerable for seconds\",\n    \"lucky_hit_up_to_a_chance_to_restore_primary_resource\": \"lucky hit up to a chance to restore primary resource\",\n    \"lucky_hit_up_to_a_chance_to_slow_for_seconds\": \"lucky hit up to a chance to slow for seconds\",\n    \"lucky_hit_up_to_a_chance_to_stun_for_seconds\": \"lucky hit up to a chance to stun for seconds\",\n    \"lucky_hit_up_to_a_chance_to_taunt_for_seconds\": \"lucky hit up to a chance to taunt for seconds\",\n    \"lucky_hit_up_to_a_chance_to_weaken_for_seconds\": \"lucky hit up to a chance to weaken for seconds\",\n    \"lucky_hit_up_to_a_damage_for_seconds\": \"lucky hit up to a damage for seconds\",\n    \"lunging_strike_healing\": \"lunging strike healing\",\n    \"macabre_damage\": \"macabre damage\",\n    \"main_hand_weapon_damage\": \"main hand weapon damage\",\n    \"mana_cost_reduction\": \"mana cost reduction\",\n    \"mana_on_kill\": \"mana on kill\",\n    \"mana_regeneration\": \"mana regeneration\",\n    \"marksman_attack_speed_per_precison_stack\": \"marksman attack speed per precison stack\",\n    \"marksman_critical_strike_chance\": \"marksman critical strike chance\",\n    \"marksman_critical_strike_damage\": \"marksman critical strike damage\",\n    \"marksman_damage\": \"marksman damage\",\n    \"mastery_damage\": \"mastery damage\",\n    \"maximum_energy\": \"maximum energy\",\n    \"maximum_essence\": \"maximum essence\",\n    \"maximum_evade_charges\": \"maximum evade charges\",\n    \"maximum_fury\": \"maximum fury\",\n    \"maximum_life\": \"maximum life\",\n    \"maximum_mana\": \"maximum mana\",\n    \"maximum_poison_traps\": \"maximum poison traps\",\n    \"maximum_resolve_stacks\": \"maximum resolve stacks\",\n    \"maximum_resource\": \"maximum resource\",\n    \"maximum_spirit\": \"maximum spirit\",\n    \"maximum_summon_life\": \"maximum summon life\",\n    \"maximum_vigor\": \"maximum vigor\",\n    \"meteor_size\": \"meteor size\",\n    \"minions_inherit_of_your_thorns\": \"minions inherit of your thorns\",\n    \"mobility_cooldown_reduction\": \"mobility cooldown reduction\",\n    \"mobility_damage\": \"mobility damage\",\n    \"mobility_skills_grant_movement_speed_for_seconds\": \"mobility skills grant movement speed for seconds\",\n    \"movement_speed\": \"movement speed\",\n    \"movement_speed_for_seconds_after_killing_an_elite\": \"movement speed for seconds after killing an elite\",\n    \"movement_speed_for_seconds_after_killing_an_enemy\": \"movement speed for seconds after killing an enemy\",\n    \"movement_speed_for_seconds_after_picking_up_crackling_energy\": \"movement speed for seconds after picking up crackling energy\",\n    \"movement_speed_while_berserking\": \"movement speed while berserking\",\n    \"movement_speed_while_cataclysm_is_active\": \"movement speed while cataclysm is active\",\n    \"movement_speed_while_hurricane_is_active\": \"movement speed while hurricane is active\",\n    \"movement_speed_while_in_human_form\": \"movement speed while in human form\",\n    \"movement_speed_while_shapeshifted_into_a_werewolf\": \"movement speed while shapeshifted into a werewolf\",\n    \"movement_speed_while_the_inner_sight_gauge_is_full\": \"movement speed while the inner sight gauge is full\",\n    \"mystic_circle_potency\": \"mystic circle potency\",\n    \"nature_magic_cooldown_reduction\": \"nature magic cooldown reduction\",\n    \"nature_magic_skill_cooldown_reduction\": \"nature magic skill cooldown reduction\",\n    \"nonphysical_damage\": \"nonphysical damage\",\n    \"occult_damage\": \"occult damage\",\n    \"overpower_chance\": \"overpower chance\",\n    \"overpower_critical_damage\": \"overpower critical damage\",\n    \"pestilent_swarm_damage\": \"pestilent swarm damage\",\n    \"petrify_cooldown_reduction\": \"petrify cooldown reduction\",\n    \"physical_critical_strike_chance_against_elites\": \"physical critical strike chance against elites\",\n    \"physical_damage\": \"physical damage\",\n    \"physical_damage_multiplier\": \"physical damage multiplier\",\n    \"physical_resistance\": \"physical resistance\",\n    \"pickup_radius\": \"pickup radius\",\n    \"poison_creeper_cooldown_reduction\": \"poison creeper cooldown reduction\",\n    \"poison_creeper_damage\": \"poison creeper damage\",\n    \"poison_damage\": \"poison damage\",\n    \"poison_damage_multiplier\": \"poison damage multiplier\",\n    \"poison_damage_over_time_duration\": \"poison damage over time duration\",\n    \"poison_resistance\": \"poison resistance\",\n    \"poison_trap_cooldown_reduction\": \"poison trap cooldown reduction\",\n    \"poisoning_damage\": \"poisoning damage\",\n    \"potency_cooldown_reduction\": \"potency cooldown reduction\",\n    \"potency_damage\": \"potency damage\",\n    \"potion_capacity\": \"potion capacity\",\n    \"potion_drop_rate\": \"potion drop rate\",\n    \"potion_healing\": \"potion healing\",\n    \"primary_centipede_spirit_hall_damage\": \"primary centipede spirit hall damage\",\n    \"primary_eagle_spirit_hall_damage\": \"primary eagle spirit hall damage\",\n    \"primary_gorilla_spirit_hall_damage\": \"primary gorilla spirit hall damage\",\n    \"primary_jaguar_spirit_hall_damage\": \"primary jaguar spirit hall damage\",\n    \"puncture_resource_generation\": \"puncture resource generation\",\n    \"purify_cooldown_reduction\": \"purify cooldown reduction\",\n    \"pyromancy_attack_speed\": \"pyromancy attack speed\",\n    \"pyromancy_critical_strike_damage\": \"pyromancy critical strike damage\",\n    \"pyromancy_damage\": \"pyromancy damage\",\n    \"rabies_cooldown_reduction\": \"rabies cooldown reduction\",\n    \"rabies_damage\": \"rabies damage\",\n    \"rain_of_arrows_cooldown_reduction\": \"rain of arrows cooldown reduction\",\n    \"rain_of_arrows_damage\": \"rain of arrows damage\",\n    \"rain_of_arrows_skill_cooldown_reduction\": \"rain of arrows skill cooldown reduction\",\n    \"rampage_attack_speed_per_kill_streak_tier\": \"rampage attack speed per kill streak tier\",\n    \"rampage_cooldown_reduction_per_kill_streak_tier\": \"rampage cooldown reduction per kill streak tier\",\n    \"rampage_critical_strike_chance_per_kill_streak_tier\": \"rampage critical strike chance per kill streak tier\",\n    \"rampage_dexterity_per_kill_streak_tier\": \"rampage dexterity per kill streak tier\",\n    \"rampage_intelligence_per_kill_streak_tier\": \"rampage intelligence per kill streak tier\",\n    \"rampage_life_on_hit_per_kill_streak_tier\": \"rampage life on hit per kill streak tier\",\n    \"rampage_lucky_hit_chance_per_kill_streak_tier\": \"rampage lucky hit chance per kill streak tier\",\n    \"rampage_maximum_life_per_kill_streak_tier\": \"rampage maximum life per kill streak tier\",\n    \"rampage_movement_speed_per_kill_streak_tier\": \"rampage movement speed per kill streak tier\",\n    \"rampage_resource_cost_reduction_per_kill_streak_tier\": \"rampage resource cost reduction per kill streak tier\",\n    \"rampage_strength_per_kill_streak_tier\": \"rampage strength per kill streak tier\",\n    \"rampage_willpower_per_kill_streak_tier\": \"rampage willpower per kill streak tier\",\n    \"rank_of_all_agility_skills\": \"rank of all agility skills\",\n    \"ranks_of_the_aggressive_resistance_passive\": \"ranks of the aggressive resistance passive\",\n    \"ranks_of_the_concussive_passive\": \"ranks of the concussive passive\",\n    \"ranks_of_the_heightened_senses_passive\": \"ranks of the heightened senses passive\",\n    \"ranks_of_the_hewed_flesh_passive\": \"ranks of the hewed flesh passive\",\n    \"ravager_on_kill_duration_extension\": \"ravager on kill duration extension\",\n    \"ravens_attack_speed\": \"ravens attack speed\",\n    \"ravens_cooldown_reduction\": \"ravens cooldown reduction\",\n    \"ravens_damage\": \"ravens damage\",\n    \"razor_wings_charges\": \"razor wings charges\",\n    \"resistance_to_all_elements\": \"resistance to all elements\",\n    \"resolve_generated\": \"resolve generated\",\n    \"resource_cost_reduction\": \"resource cost reduction\",\n    \"resource_generation\": \"resource generation\",\n    \"resource_generation_and_maximum\": \"resource generation and maximum\",\n    \"resource_generation_while_wielding_a_scythe\": \"resource generation while wielding a scythe\",\n    \"resource_generation_while_wielding_a_shield\": \"resource generation while wielding a shield\",\n    \"resource_generation_with_dualwielded_weapons\": \"resource generation with dualwielded weapons\",\n    \"resource_generation_with_polearms\": \"resource generation with polearms\",\n    \"resource_generation_with_twohanded_bludgeoning_weapons\": \"resource generation with twohanded bludgeoning weapons\",\n    \"resource_generation_with_twohanded_slashing_weapons\": \"resource generation with twohanded slashing weapons\",\n    \"resource_generation_with_twohanded_weapons\": \"resource generation with twohanded weapons\",\n    \"resource_on_hit\": \"resource on hit\",\n    \"resource_regeneration\": \"resource regeneration\",\n    \"rock_splitter_resource_generation\": \"rock splitter resource generation\",\n    \"rupture_cooldown_reduction\": \"rupture cooldown reduction\",\n    \"rupture_damage\": \"rupture damage\",\n    \"rushing_claw_charges\": \"rushing claw charges\",\n    \"scourge_poisoning_duration\": \"scourge poisoning duration\",\n    \"sever_size\": \"sever size\",\n    \"shade_damage\": \"shade damage\",\n    \"shadow_clone_cooldown_reduction\": \"shadow clone cooldown reduction\",\n    \"shadow_clone_damage\": \"shadow clone damage\",\n    \"shadow_clones_execute_injured_nonelite_enemies\": \"shadow clones execute injured nonelite enemies\",\n    \"shadow_damage\": \"shadow damage\",\n    \"shadow_damage_multiplier\": \"shadow damage multiplier\",\n    \"shadow_lucky_hit_chance\": \"shadow lucky hit chance\",\n    \"shadow_resistance\": \"shadow resistance\",\n    \"shadow_step_cooldown_reduction\": \"shadow step cooldown reduction\",\n    \"shadow_step_damage\": \"shadow step damage\",\n    \"shapeshifting_attack_speed\": \"shapeshifting attack speed\",\n    \"shield_charge_cooldown_reduction\": \"shield charge cooldown reduction\",\n    \"shock_critical_strike_chance\": \"shock critical strike chance\",\n    \"shock_critical_strike_damage\": \"shock critical strike damage\",\n    \"shock_damage\": \"shock damage\",\n    \"shout_cooldown_reduction\": \"shout cooldown reduction\",\n    \"shred_critical_strike_chance\": \"shred critical strike chance\",\n    \"shrine_buff_duration\": \"shrine buff duration\",\n    \"sigil_damage\": \"sigil damage\",\n    \"sigil_duration\": \"sigil duration\",\n    \"skeletal_mages_inherit_of_your_thorns\": \"skeletal mages inherit of your thorns\",\n    \"skeletal_warriors_inherit_of_your_thorns\": \"skeletal warriors inherit of your thorns\",\n    \"skeleton_mage_damage\": \"skeleton mage damage\",\n    \"slow_duration_reduction\": \"slow duration reduction\",\n    \"smoke_grenade_cooldown_reduction\": \"smoke grenade cooldown reduction\",\n    \"smoke_grenade_damage\": \"smoke grenade damage\",\n    \"soar_cooldown_reduction\": \"soar cooldown reduction\",\n    \"soar_deals_up_to_damage_based_on_distance_traveled\": \"soar deals up to damage based on distance traveled\",\n    \"soar_grants_maximum_life_as_barrier_for_seconds\": \"soar grants maximum life as barrier for seconds\",\n    \"spirit_cost_reduction\": \"spirit cost reduction\",\n    \"spirit_on_kill\": \"spirit on kill\",\n    \"spirit_regeneration\": \"spirit regeneration\",\n    \"steel_grasp_cooldown_reduction\": \"steel grasp cooldown reduction\",\n    \"steel_grasp_damage\": \"steel grasp damage\",\n    \"steel_grasp_stuns_for_seconds\": \"steel grasp stuns for seconds\",\n    \"storm_cooldown_reduction\": \"storm cooldown reduction\",\n    \"storm_critical_strike_chance\": \"storm critical strike chance\",\n    \"storm_damage\": \"storm damage\",\n    \"storm_feather_potency\": \"storm feather potency\",\n    \"storm_strike_chains_to_targets\": \"storm strike chains to targets\",\n    \"strength\": \"strength\",\n    \"stun_duration\": \"stun duration\",\n    \"stun_grenade_damage\": \"stun grenade damage\",\n    \"stun_grenade_size\": \"stun grenade size\",\n    \"subterfuge_cooldown_reduction\": \"subterfuge cooldown reduction\",\n    \"summon_attack_speed\": \"summon attack speed\",\n    \"summon_damage\": \"summon damage\",\n    \"summon_movement_speed\": \"summon movement speed\",\n    \"teleport_cooldown_reduction\": \"teleport cooldown reduction\",\n    \"teleport_damage\": \"teleport damage\",\n    \"the_devourer_cooldown_reduction\": \"the devourer cooldown reduction\",\n    \"the_hunter_cooldown_reduction\": \"the hunter cooldown reduction\",\n    \"the_protector_cooldown_reduction\": \"the protector cooldown reduction\",\n    \"the_seeker_charges\": \"the seeker charges\",\n    \"the_seeker_cooldown_reduction\": \"the seeker cooldown reduction\",\n    \"thorns\": \"thorns\",\n    \"thorns_while_fortified\": \"thorns while fortified\",\n    \"thrash_resource_generation\": \"thrash resource generation\",\n    \"thunderspike_resource_generation\": \"thunderspike resource generation\",\n    \"to_abyss_skills\": \"to abyss skills\",\n    \"to_advance\": \"to advance\",\n    \"to_aegis\": \"to aegis\",\n    \"to_agility_skills\": \"to agility skills\",\n    \"to_all_skills\": \"to all skills\",\n    \"to_ancient_skills\": \"to ancient skills\",\n    \"to_arc_lash\": \"to arc lash\",\n    \"to_archfiend_skills\": \"to archfiend skills\",\n    \"to_armored_hide\": \"to armored hide\",\n    \"to_arrow_storm_skills\": \"to arrow storm skills\",\n    \"to_aura_skills\": \"to aura skills\",\n    \"to_ball_lightning\": \"to ball lightning\",\n    \"to_barrage\": \"to barrage\",\n    \"to_bash\": \"to bash\",\n    \"to_basic_skills\": \"to basic skills\",\n    \"to_blade_shift\": \"to blade shift\",\n    \"to_blazing_scream\": \"to blazing scream\",\n    \"to_blessed_hammer\": \"to blessed hammer\",\n    \"to_blessed_shield\": \"to blessed shield\",\n    \"to_blight\": \"to blight\",\n    \"to_blizzard\": \"to blizzard\",\n    \"to_blood_howl\": \"to blood howl\",\n    \"to_blood_lance\": \"to blood lance\",\n    \"to_blood_mist\": \"to blood mist\",\n    \"to_blood_skills\": \"to blood skills\",\n    \"to_blood_surge\": \"to blood surge\",\n    \"to_bombardment\": \"to bombardment\",\n    \"to_bone_prison\": \"to bone prison\",\n    \"to_bone_skills\": \"to bone skills\",\n    \"to_bone_spear\": \"to bone spear\",\n    \"to_bone_spirit\": \"to bone spirit\",\n    \"to_bone_splinters\": \"to bone splinters\",\n    \"to_boulder\": \"to boulder\",\n    \"to_brandish\": \"to brandish\",\n    \"to_brawling_skills\": \"to brawling skills\",\n    \"to_caltrops\": \"to caltrops\",\n    \"to_centipede_skills\": \"to centipede skills\",\n    \"to_chain_lightning\": \"to chain lightning\",\n    \"to_challenging_shout\": \"to challenging shout\",\n    \"to_charge\": \"to charge\",\n    \"to_charged_bolts\": \"to charged bolts\",\n    \"to_clash\": \"to clash\",\n    \"to_claw\": \"to claw\",\n    \"to_cold_imbuement\": \"to cold imbuement\",\n    \"to_combat_skills\": \"to combat skills\",\n    \"to_command_abodian\": \"to command abodian\",\n    \"to_command_aegrom\": \"to command aegrom\",\n    \"to_command_fallen\": \"to command fallen\",\n    \"to_command_laalish\": \"to command laalish\",\n    \"to_command_valloch\": \"to command valloch\",\n    \"to_companion_skills\": \"to companion skills\",\n    \"to_concealment\": \"to concealment\",\n    \"to_concussive_stomp\": \"to concussive stomp\",\n    \"to_condemn\": \"to condemn\",\n    \"to_conjuration_skills\": \"to conjuration skills\",\n    \"to_consecration\": \"to consecration\",\n    \"to_core_skills\": \"to core skills\",\n    \"to_corpse_explosion\": \"to corpse explosion\",\n    \"to_corpse_skills\": \"to corpse skills\",\n    \"to_corpse_tendrils\": \"to corpse tendrils\",\n    \"to_counterattack\": \"to counterattack\",\n    \"to_crushing_hand\": \"to crushing hand\",\n    \"to_curse_skills\": \"to curse skills\",\n    \"to_cutthroat_skills\": \"to cutthroat skills\",\n    \"to_cyclone_armor\": \"to cyclone armor\",\n    \"to_dance_of_knives\": \"to dance of knives\",\n    \"to_dark_prison\": \"to dark prison\",\n    \"to_dark_shroud\": \"to dark shroud\",\n    \"to_darkness_skills\": \"to darkness skills\",\n    \"to_dash\": \"to dash\",\n    \"to_death_blow\": \"to death blow\",\n    \"to_deaths_reach\": \"to deaths reach\",\n    \"to_debilitating_roar\": \"to debilitating roar\",\n    \"to_decompose\": \"to decompose\",\n    \"to_decrepify\": \"to decrepify\",\n    \"to_defensive_skills\": \"to defensive skills\",\n    \"to_defiance_aura\": \"to defiance aura\",\n    \"to_demonology_skills\": \"to demonology skills\",\n    \"to_disciple_skills\": \"to disciple skills\",\n    \"to_divine_lance\": \"to divine lance\",\n    \"to_doom\": \"to doom\",\n    \"to_double_swing\": \"to double swing\",\n    \"to_dread_claws\": \"to dread claws\",\n    \"to_dust_devil_skills\": \"to dust devil skills\",\n    \"to_eagle_skills\": \"to eagle skills\",\n    \"to_earth_skills\": \"to earth skills\",\n    \"to_earth_spike\": \"to earth spike\",\n    \"to_earthen_bulwark\": \"to earthen bulwark\",\n    \"to_earthquake_skills\": \"to earthquake skills\",\n    \"to_falling_star\": \"to falling star\",\n    \"to_familiar\": \"to familiar\",\n    \"to_fanaticism_aura\": \"to fanaticism aura\",\n    \"to_fire_bolt\": \"to fire bolt\",\n    \"to_fireball\": \"to fireball\",\n    \"to_firewall\": \"to firewall\",\n    \"to_flame_shield\": \"to flame shield\",\n    \"to_flay\": \"to flay\",\n    \"to_flurry\": \"to flurry\",\n    \"to_focus_skills\": \"to focus skills\",\n    \"to_forceful_arrow\": \"to forceful arrow\",\n    \"to_frenzy\": \"to frenzy\",\n    \"to_frost_bolt\": \"to frost bolt\",\n    \"to_frost_nova\": \"to frost nova\",\n    \"to_frost_skills\": \"to frost skills\",\n    \"to_frozen_orb\": \"to frozen orb\",\n    \"to_golem\": \"to golem\",\n    \"to_gorilla_skills\": \"to gorilla skills\",\n    \"to_grenade_skills\": \"to grenade skills\",\n    \"to_ground_stomp\": \"to ground stomp\",\n    \"to_hammer_of_the_ancients\": \"to hammer of the ancients\",\n    \"to_heartseeker\": \"to heartseeker\",\n    \"to_hell_fracture\": \"to hell fracture\",\n    \"to_hellfire_skills\": \"to hellfire skills\",\n    \"to_hellion_sting\": \"to hellion sting\",\n    \"to_hemorrhage\": \"to hemorrhage\",\n    \"to_holy_bolt\": \"to holy bolt\",\n    \"to_holy_light_aura\": \"to holy light aura\",\n    \"to_human_skills\": \"to human skills\",\n    \"to_hurricane\": \"to hurricane\",\n    \"to_hydra\": \"to hydra\",\n    \"to_ice_armor\": \"to ice armor\",\n    \"to_ice_blades\": \"to ice blades\",\n    \"to_ice_shards\": \"to ice shards\",\n    \"to_imbuement_skills\": \"to imbuement skills\",\n    \"to_incinerate\": \"to incinerate\",\n    \"to_infernal_breath\": \"to infernal breath\",\n    \"to_invigorating_strike\": \"to invigorating strike\",\n    \"to_iron_maiden\": \"to iron maiden\",\n    \"to_iron_shrapnel_skills\": \"to iron shrapnel skills\",\n    \"to_iron_skin\": \"to iron skin\",\n    \"to_jaguar_skills\": \"to jaguar skills\",\n    \"to_judicator_skills\": \"to judicator skills\",\n    \"to_juggernaut_skills\": \"to juggernaut skills\",\n    \"to_justice_skills\": \"to justice skills\",\n    \"to_kick\": \"to kick\",\n    \"to_landslide\": \"to landslide\",\n    \"to_leap\": \"to leap\",\n    \"to_lightning_spear\": \"to lightning spear\",\n    \"to_lightning_storm\": \"to lightning storm\",\n    \"to_lunging_strike\": \"to lunging strike\",\n    \"to_macabre_skills\": \"to macabre skills\",\n    \"to_marksman_and_cutthroat_skills\": \"to marksman and cutthroat skills\",\n    \"to_marksman_skills\": \"to marksman skills\",\n    \"to_martial_skills\": \"to martial skills\",\n    \"to_mastery_skills\": \"to mastery skills\",\n    \"to_maul\": \"to maul\",\n    \"to_meteor\": \"to meteor\",\n    \"to_mighty_throw\": \"to mighty throw\",\n    \"to_minion_skills\": \"to minion skills\",\n    \"to_mobility_skills\": \"to mobility skills\",\n    \"to_molten_bomb\": \"to molten bomb\",\n    \"to_nature_magic_skills\": \"to nature magic skills\",\n    \"to_nether_step\": \"to nether step\",\n    \"to_occult_skills\": \"to occult skills\",\n    \"to_payback\": \"to payback\",\n    \"to_penetrating_shot\": \"to penetrating shot\",\n    \"to_poison_creeper\": \"to poison creeper\",\n    \"to_poison_imbuement\": \"to poison imbuement\",\n    \"to_poison_trap\": \"to poison trap\",\n    \"to_potency_skills\": \"to potency skills\",\n    \"to_prime_bone_storms_damage_reduction\": \"to prime bone storms damage reduction\",\n    \"to_profane_sentinel\": \"to profane sentinel\",\n    \"to_pulverize\": \"to pulverize\",\n    \"to_puncture\": \"to puncture\",\n    \"to_purify\": \"to purify\",\n    \"to_pyromancy_skills\": \"to pyromancy skills\",\n    \"to_quill_volley\": \"to quill volley\",\n    \"to_rabies\": \"to rabies\",\n    \"to_rake\": \"to rake\",\n    \"to_rally\": \"to rally\",\n    \"to_rallying_cry\": \"to rallying cry\",\n    \"to_rampage\": \"to rampage\",\n    \"to_rapid_fire\": \"to rapid fire\",\n    \"to_ravager\": \"to ravager\",\n    \"to_ravens\": \"to ravens\",\n    \"to_razor_wings\": \"to razor wings\",\n    \"to_reap\": \"to reap\",\n    \"to_rend\": \"to rend\",\n    \"to_rock_splitter\": \"to rock splitter\",\n    \"to_rupture\": \"to rupture\",\n    \"to_rushing_claw\": \"to rushing claw\",\n    \"to_scourge\": \"to scourge\",\n    \"to_sever\": \"to sever\",\n    \"to_shade_skills\": \"to shade skills\",\n    \"to_shadow_imbuement\": \"to shadow imbuement\",\n    \"to_shadow_step\": \"to shadow step\",\n    \"to_shapeshifting_skills\": \"to shapeshifting skills\",\n    \"to_shield_bash\": \"to shield bash\",\n    \"to_shield_charge\": \"to shield charge\",\n    \"to_shock_skills\": \"to shock skills\",\n    \"to_shred\": \"to shred\",\n    \"to_sigil_of_chaos\": \"to sigil of chaos\",\n    \"to_sigil_of_subversion\": \"to sigil of subversion\",\n    \"to_sigil_of_summons\": \"to sigil of summons\",\n    \"to_sigil_skills\": \"to sigil skills\",\n    \"to_skeletal_mage_mastery\": \"to skeletal mage mastery\",\n    \"to_skeleton_mage\": \"to skeleton mage\",\n    \"to_skeleton_warrior\": \"to skeleton warrior\",\n    \"to_slashing_skills\": \"to slashing skills\",\n    \"to_smoke_grenade\": \"to smoke grenade\",\n    \"to_soar\": \"to soar\",\n    \"to_soul_shard_skills\": \"to soul shard skills\",\n    \"to_soulrift\": \"to soulrift\",\n    \"to_spark\": \"to spark\",\n    \"to_spear_of_the_heavens\": \"to spear of the heavens\",\n    \"to_steel_grasp\": \"to steel grasp\",\n    \"to_steel_grasp_cold_imbuement_frost_hurricane_or_skeletal_mage_mastery\": \"to steel grasp, cold imbuement, frost, hurricane, or skeletal mage mastery\",\n    \"to_stinger\": \"to stinger\",\n    \"to_stone_burst\": \"to stone burst\",\n    \"to_storm_skills\": \"to storm skills\",\n    \"to_storm_strike\": \"to storm strike\",\n    \"to_subterfuge_skills\": \"to subterfuge skills\",\n    \"to_teleport\": \"to teleport\",\n    \"to_the_pack_leader_spirit_boons_lucky_hit_chance\": \"to the pack leader spirit boons lucky hit chance\",\n    \"to_thrash\": \"to thrash\",\n    \"to_thunderspike\": \"to thunderspike\",\n    \"to_tornado\": \"to tornado\",\n    \"to_tortured_wretch\": \"to tortured wretch\",\n    \"to_touch_of_death\": \"to touch of death\",\n    \"to_toxic_skin\": \"to toxic skin\",\n    \"to_trample\": \"to trample\",\n    \"to_trap_skills\": \"to trap skills\",\n    \"to_twisting_blades\": \"to twisting blades\",\n    \"to_tyrants_grasp\": \"to tyrants grasp\",\n    \"to_ultimate_skills\": \"to ultimate skills\",\n    \"to_umbral_chains\": \"to umbral chains\",\n    \"to_upheaval\": \"to upheaval\",\n    \"to_upheaval_cutthroat_pyromancy_earth_or_blood\": \"to upheaval, cutthroat, pyromancy, earth, or blood\",\n    \"to_valor_skills\": \"to valor skills\",\n    \"to_versatile_skills\": \"to versatile skills\",\n    \"to_vortex\": \"to vortex\",\n    \"to_wall_of_agony\": \"to wall of agony\",\n    \"to_war_cry\": \"to war cry\",\n    \"to_weapon_mastery_skills\": \"to weapon mastery skills\",\n    \"to_werebear_skills\": \"to werebear skills\",\n    \"to_werewolf_skills\": \"to werewolf skills\",\n    \"to_whirlwind\": \"to whirlwind\",\n    \"to_wind_shear\": \"to wind shear\",\n    \"to_withering_fist\": \"to withering fist\",\n    \"to_wolves\": \"to wolves\",\n    \"to_wrath_skills\": \"to wrath skills\",\n    \"to_zeal\": \"to zeal\",\n    \"to_zealot_skills\": \"to zealot skills\",\n    \"total_armor\": \"total armor\",\n    \"total_armor_while_in_werebear_form\": \"total armor while in werebear form\",\n    \"total_armor_while_in_werewolf_form\": \"total armor while in werewolf form\",\n    \"total_bonus_experience\": \"total bonus experience\",\n    \"trample_cooldown_reduction\": \"trample cooldown reduction\",\n    \"trample_damage\": \"trample damage\",\n    \"trap_cooldown_reduction\": \"trap cooldown reduction\",\n    \"trap_damage\": \"trap damage\",\n    \"traps_arm_seconds_faster\": \"traps arm seconds faster\",\n    \"twisting_blades_returns_faster\": \"twisting blades returns faster\",\n    \"ultimate_cooldown_reduction\": \"ultimate cooldown reduction\",\n    \"ultimate_damage\": \"ultimate damage\",\n    \"unstable_currents_cooldown_reduction\": \"unstable currents cooldown reduction\",\n    \"upheaval_overpowers_stun_for_seconds\": \"upheaval overpowers stun for seconds\",\n    \"valor_cooldown_reduction\": \"valor cooldown reduction\",\n    \"versatile_damage\": \"versatile damage\",\n    \"vigor_cost_reduction\": \"vigor cost reduction\",\n    \"vigor_on_kill\": \"vigor on kill\",\n    \"vigor_regeneration\": \"vigor regeneration\",\n    \"vigor_when_resolve_is_lost\": \"vigor when resolve is lost\",\n    \"vulnerable_damage\": \"vulnerable damage\",\n    \"vulnerable_damage_multiplier\": \"vulnerable damage multiplier\",\n    \"war_cry_cooldown_reduction\": \"war cry cooldown reduction\",\n    \"weapon_damage\": \"weapon damage\",\n    \"weapon_mastery_attack_speed\": \"weapon mastery attack speed\",\n    \"weapon_mastery_cooldown_reduction\": \"weapon mastery cooldown reduction\",\n    \"weapon_mastery_damage\": \"weapon mastery damage\",\n    \"werebear_damage\": \"werebear damage\",\n    \"werewolf_attack_speed\": \"werewolf attack speed\",\n    \"werewolf_critical_strike_chance\": \"werewolf critical strike chance\",\n    \"werewolf_critical_strike_damage\": \"werewolf critical strike damage\",\n    \"werewolf_damage\": \"werewolf damage\",\n    \"while_injured_your_potion_also_grants_maximum_life_as_barrier\": \"while injured, your potion also grants maximum life as barrier\",\n    \"while_injured_your_potion_also_grants_movement_speed_for_seconds\": \"while injured, your potion also grants movement speed for seconds\",\n    \"while_injured_your_potion_also_restores_resource\": \"while injured, your potion also restores resource\",\n    \"willpower\": \"willpower\",\n    \"wing_strike_damage\": \"wing strike damage\",\n    \"withering_fist_resource_generation\": \"withering fist resource generation\",\n    \"wolves_attack_speed\": \"wolves attack speed\",\n    \"wolves_cooldown_reduction\": \"wolves cooldown reduction\",\n    \"wolves_damage\": \"wolves damage\",\n    \"wrath_every_kills\": \"wrath every kills\",\n    \"wrath_of_the_berserker_cooldown_reduction\": \"wrath of the berserker cooldown reduction\",\n    \"wrath_regeneration\": \"wrath regeneration\",\n    \"your_trap_skills_are_also_considered_core_skills\": \"your trap skills are also considered core skills\",\n    \"zealot_critical_strike_chance\": \"zealot critical strike chance\",\n    \"zealot_critical_strike_damage\": \"zealot critical strike damage\",\n    \"zealot_damage\": \"zealot damage\",\n    \"zenith_cooldown_reduction\": \"zenith cooldown reduction\"\n}\n"
  },
  {
    "path": "assets/lang/enUS/aspects.json",
    "content": "[\n    \"accelerating\",\n    \"aggressive\",\n    \"agile\",\n    \"aphotic\",\n    \"apostles\",\n    \"archdruids\",\n    \"assimilation\",\n    \"balanced\",\n    \"ballistic\",\n    \"bane-link\",\n    \"battle-mad\",\n    \"battle-mad\",\n    \"battle_casters\",\n    \"battle_casters\",\n    \"battle_fervors\",\n    \"bear_clan_berserkers\",\n    \"bladedancers\",\n    \"blast-trappers\",\n    \"blast-trappers\",\n    \"blasting\",\n    \"blood_boiling\",\n    \"blood_boiling\",\n    \"blood_getters\",\n    \"bold_chieftains\",\n    \"bone_breakers\",\n    \"brawlers\",\n    \"breakneck_bandits\",\n    \"bristleback\",\n    \"bruisers\",\n    \"brutal\",\n    \"bulwarks\",\n    \"bulwarks\",\n    \"cadaverous\",\n    \"charged\",\n    \"cheats\",\n    \"clandestine\",\n    \"coldbringers\",\n    \"coldclip\",\n    \"conceited\",\n    \"conjuration_masters\",\n    \"crashstone\",\n    \"craven\",\n    \"cremators\",\n    \"crushing\",\n    \"cut_to_the_bone\",\n    \"deadeyes\",\n    \"death_wish\",\n    \"demonic\",\n    \"devilish\",\n    \"duelists\",\n    \"duelists\",\n    \"dust_devils\",\n    \"earthstrikers\",\n    \"earthstrikers\",\n    \"edgemasters\",\n    \"elementalists\",\n    \"eluding\",\n    \"embattled\",\n    \"encased\",\n    \"encased\",\n    \"energizing\",\n    \"enfeebling\",\n    \"enshrouding\",\n    \"envenomed\",\n    \"escape_artists\",\n    \"everliving\",\n    \"everliving\",\n    \"executioners\",\n    \"exploiters\",\n    \"fastblood\",\n    \"fell_soothsayers\",\n    \"ferocious\",\n    \"firestarter\",\n    \"flamethrowers\",\n    \"flamewalkers\",\n    \"flash_fire\",\n    \"frostbitten\",\n    \"frostblitz\",\n    \"galvanic\",\n    \"galvanized_slashers\",\n    \"ghostwalker\",\n    \"glacial\",\n    \"gorefeast\",\n    \"gravitational\",\n    \"great_storm\",\n    \"grenadiers\",\n    \"heavy_hitting\",\n    \"hectic\",\n    \"hellbent_commander\",\n    \"high_velocity\",\n    \"high_velocity\",\n    \"hulking\",\n    \"icy_alchemists\",\n    \"icy_alchemists\",\n    \"impairing\",\n    \"incendiary\",\n    \"infiltrators\",\n    \"insatiable\",\n    \"insidious\",\n    \"inspiring_leader\",\n    \"iron_blood\",\n    \"irrepressible\",\n    \"jolting\",\n    \"joltkeepers\",\n    \"juggernauts\",\n    \"lightning_dancers\",\n    \"lightning_rod\",\n    \"lightning_rod\",\n    \"lingering\",\n    \"lord_of_bloods\",\n    \"lord_of_bloods\",\n    \"luckbringer\",\n    \"mage-lords\",\n    \"mage-lords\",\n    \"malicious\",\n    \"mangled\",\n    \"manglers\",\n    \"menacing\",\n    \"methodical\",\n    \"mighty_storms\",\n    \"mired_sharpshooters\",\n    \"misanthropic\",\n    \"moonrage\",\n    \"natures_reach\",\n    \"necrotic_carapace\",\n    \"needleflare\",\n    \"nefarious\",\n    \"neurotoxic\",\n    \"nightstalkers\",\n    \"obstinate\",\n    \"of_abundant_energy\",\n    \"of_accursed_touch\",\n    \"of_adaptability\",\n    \"of_aftermath\",\n    \"of_akarats_blessing\",\n    \"of_alacrity\",\n    \"of_alchemical_advantage\",\n    \"of_amplified_damage\",\n    \"of_ancestral_charge\",\n    \"of_ancestral_echoes\",\n    \"of_ancestral_force\",\n    \"of_ancient_flame\",\n    \"of_ancient_flame\",\n    \"of_anemia\",\n    \"of_angelic_masterwork\",\n    \"of_anger_management\",\n    \"of_anger_management\",\n    \"of_anticline_burst\",\n    \"of_apogeic_furor\",\n    \"of_apogeic_furor\",\n    \"of_apprehension\",\n    \"of_arcane_ward\",\n    \"of_armageddon\",\n    \"of_armageddon\",\n    \"of_arrogance\",\n    \"of_arrow_storms\",\n    \"of_artful_initiative\",\n    \"of_ascension\",\n    \"of_assistance\",\n    \"of_audacity\",\n    \"of_authority\",\n    \"of_avoidance\",\n    \"of_barbed_roses\",\n    \"of_berserk_fury\",\n    \"of_berserk_ripping\",\n    \"of_binding_embers\",\n    \"of_binding_morass\",\n    \"of_biting_cold\",\n    \"of_biting_cold\",\n    \"of_bitter_infection\",\n    \"of_booming_voice\",\n    \"of_bristling_vengeance\",\n    \"of_bul-kathos\",\n    \"of_burning_rage\",\n    \"of_bursting_bones\",\n    \"of_bursting_venoms\",\n    \"of_calamity\",\n    \"of_cauterization\",\n    \"of_celestial_strife\",\n    \"of_channeling\",\n    \"of_charged_flash\",\n    \"of_chastisement\",\n    \"of_coagulation\",\n    \"of_coalesced_blood\",\n    \"of_cold_judgement\",\n    \"of_combined_strikes\",\n    \"of_combustion\",\n    \"of_compound_fracture\",\n    \"of_concentration\",\n    \"of_concussive_blend\",\n    \"of_concussive_strikes\",\n    \"of_conflagration\",\n    \"of_contamination\",\n    \"of_contemplation\",\n    \"of_corruption\",\n    \"of_creeping_cadaver\",\n    \"of_creeping_death\",\n    \"of_crippling_darkness\",\n    \"of_dazzling_light\",\n    \"of_death_chill\",\n    \"of_deaths_defense\",\n    \"of_debilitating_darkness\",\n    \"of_debilitating_darkness\",\n    \"of_debilitating_toxins\",\n    \"of_decay\",\n    \"of_dedication\",\n    \"of_deeper_shadows\",\n    \"of_deflection\",\n    \"of_delayed_extinction\",\n    \"of_deluge\",\n    \"of_diabolical_armor\",\n    \"of_disobedience\",\n    \"of_dominance\",\n    \"of_dominance\",\n    \"of_earthquakes\",\n    \"of_efficiency\",\n    \"of_electrified_claws\",\n    \"of_elemental_acuity\",\n    \"of_elemental_attunement\",\n    \"of_elemental_constellation\",\n    \"of_elemental_constellation\",\n    \"of_elemental_fate\",\n    \"of_elusive_menace\",\n    \"of_elusive_menace\",\n    \"of_empowered_feathers\",\n    \"of_encircling_blades\",\n    \"of_encroaching_wrath\",\n    \"of_endless_fury\",\n    \"of_endless_talons\",\n    \"of_endurance\",\n    \"of_engulfing_flames\",\n    \"of_entrapment\",\n    \"of_excellence\",\n    \"of_excellence\",\n    \"of_exhilaration\",\n    \"of_exorcism\",\n    \"of_explosive_verve\",\n    \"of_explosive_verve\",\n    \"of_exposed_flesh\",\n    \"of_falling_feathers\",\n    \"of_falling_feathers\",\n    \"of_fathomless_dark\",\n    \"of_fevered_mauling\",\n    \"of_fiendish_oppression\",\n    \"of_fierce_winds\",\n    \"of_finality\",\n    \"of_firm_decree\",\n    \"of_fleet_wings\",\n    \"of_forest_power\",\n    \"of_fortune\",\n    \"of_forward_momentum\",\n    \"of_frenzied_onslaught\",\n    \"of_frosty_strides\",\n    \"of_frozen_memories\",\n    \"of_frozen_memories\",\n    \"of_frozen_orbit\",\n    \"of_furious_impulse\",\n    \"of_giant_strides\",\n    \"of_gloom\",\n    \"of_glynns_anvil\",\n    \"of_gore_quills\",\n    \"of_grasping_whirlwind\",\n    \"of_grim_prognosis\",\n    \"of_guttural_yell\",\n    \"of_hales_salve\",\n    \"of_hardened_bones\",\n    \"of_haste\",\n    \"of_heavenly_strength\",\n    \"of_herculean_spectacle\",\n    \"of_heresy\",\n    \"of_hewed_flesh\",\n    \"of_hewed_flesh\",\n    \"of_hit_and_run\",\n    \"of_holy_cadence\",\n    \"of_holy_punishment\",\n    \"of_human_ingenuity\",\n    \"of_ignition\",\n    \"of_imitated_imbuement\",\n    \"of_immolation\",\n    \"of_impending_deluge\",\n    \"of_impetus\",\n    \"of_incendiary_fissures\",\n    \"of_inevitable_fate\",\n    \"of_infestation\",\n    \"of_inner_calm\",\n    \"of_innervation\",\n    \"of_interdiction\",\n    \"of_interdiction\",\n    \"of_intricacy\",\n    \"of_invigorating_will\",\n    \"of_iron_rain\",\n    \"of_iron_rain\",\n    \"of_jacques_fervor\",\n    \"of_kinetic_suppression\",\n    \"of_lageras_sovereignty\",\n    \"of_lapas_scripture\",\n    \"of_lava\",\n    \"of_layered_wards\",\n    \"of_lethal_dusk\",\n    \"of_limitless_rage\",\n    \"of_loyalty\",\n    \"of_malevolence\",\n    \"of_malice\",\n    \"of_mending_obscurity\",\n    \"of_mending_stone\",\n    \"of_merciless_cold\",\n    \"of_metamorphosis\",\n    \"of_might\",\n    \"of_militance\",\n    \"of_minds_awakening\",\n    \"of_misfortune\",\n    \"of_momentum\",\n    \"of_mutilation\",\n    \"of_natural_balance\",\n    \"of_natural_defenses\",\n    \"of_natural_instincts\",\n    \"of_natural_selection\",\n    \"of_nebulous_brews\",\n    \"of_noxious_ice\",\n    \"of_numbing_wrath\",\n    \"of_overheating\",\n    \"of_overwhelming_currents\",\n    \"of_overwhelming_currents\",\n    \"of_peril\",\n    \"of_perpetual_stomping\",\n    \"of_pestilence\",\n    \"of_pestilent_points\",\n    \"of_piercing_cold\",\n    \"of_piercing_cold\",\n    \"of_piercing_static\",\n    \"of_piercing_static\",\n    \"of_pilgrims_progress\",\n    \"of_plains_power\",\n    \"of_poisonous_clouds\",\n    \"of_poisonous_clouds\",\n    \"of_potent_blood\",\n    \"of_potent_exchange\",\n    \"of_prolific_fury\",\n    \"of_proselytizing\",\n    \"of_putrefaction\",\n    \"of_quickening_fog\",\n    \"of_quicksand\",\n    \"of_rallying_reversal\",\n    \"of_rapid_ossification\",\n    \"of_rathmas_chosen\",\n    \"of_reactive_armor\",\n    \"of_reanimation\",\n    \"of_recalling_feathers\",\n    \"of_recalling_feathers\",\n    \"of_redirected_force\",\n    \"of_refutation\",\n    \"of_retaliation\",\n    \"of_retribution\",\n    \"of_retribution\",\n    \"of_righteous_rage\",\n    \"of_ritual_synthesis\",\n    \"of_salvation\",\n    \"of_scorching_heat\",\n    \"of_scorn\",\n    \"of_searing_impact\",\n    \"of_searing_wards\",\n    \"of_serration\",\n    \"of_shared_misery\",\n    \"of_shattered_stars\",\n    \"of_shattering_steel\",\n    \"of_shelter\",\n    \"of_shielding_bones\",\n    \"of_shielding_bones\",\n    \"of_shredding_blades\",\n    \"of_shredding_blades\",\n    \"of_simple_reprisal\",\n    \"of_singed_extremities\",\n    \"of_siphoned_victuals\",\n    \"of_siphoning_strikes\",\n    \"of_sky_power\",\n    \"of_slaughter\",\n    \"of_sly_steps\",\n    \"of_soil_power\",\n    \"of_spiked_armor\",\n    \"of_splintering_energy\",\n    \"of_splintering_energy\",\n    \"of_splintering_shards\",\n    \"of_stolen_vigor\",\n    \"of_sundered_ground\",\n    \"of_supremacy\",\n    \"of_surprise\",\n    \"of_swift_spirit\",\n    \"of_synergy\",\n    \"of_synergy\",\n    \"of_target_practice\",\n    \"of_tempering_blows\",\n    \"of_temporal_incisions\",\n    \"of_tenacity\",\n    \"of_tenuous_agility\",\n    \"of_tenuous_survival\",\n    \"of_terror\",\n    \"of_the_agile_wolf\",\n    \"of_the_arbiters_zephyr\",\n    \"of_the_bounding_conduit\",\n    \"of_the_calm_breeze\",\n    \"of_the_calm_breeze\",\n    \"of_the_changelings_debt\",\n    \"of_the_crowded_sage\",\n    \"of_the_cursed_aura\",\n    \"of_the_damned\",\n    \"of_the_dark_dance\",\n    \"of_the_dark_howl\",\n    \"of_the_deflecting_barrier\",\n    \"of_the_dire_whirlwind\",\n    \"of_the_disciple\",\n    \"of_the_disciple\",\n    \"of_the_embalmer\",\n    \"of_the_embalmer\",\n    \"of_the_enchanter\",\n    \"of_the_expectant\",\n    \"of_the_firebird\",\n    \"of_the_flaming_rampage\",\n    \"of_the_followed_path\",\n    \"of_the_fortress\",\n    \"of_the_frozen_tundra\",\n    \"of_the_frozen_wake\",\n    \"of_the_frozen_wake\",\n    \"of_the_golden_hour\",\n    \"of_the_great_feast\",\n    \"of_the_indomitable\",\n    \"of_the_iron_warrior\",\n    \"of_the_judicator\",\n    \"of_the_juggernauts_covenant\",\n    \"of_the_lights_mending\",\n    \"of_the_lights_mending\",\n    \"of_the_long_shadow\",\n    \"of_the_moonrise\",\n    \"of_the_northern_guard\",\n    \"of_the_orange_herald\",\n    \"of_the_orange_herald\",\n    \"of_the_pack_alpha\",\n    \"of_the_protector\",\n    \"of_the_prudent_heart\",\n    \"of_the_rabid_beast\",\n    \"of_the_relentless_armsmaster\",\n    \"of_the_rushing_wilds\",\n    \"of_the_shapeshifter\",\n    \"of_the_solitary_shadow\",\n    \"of_the_stampede\",\n    \"of_the_umbral\",\n    \"of_the_unbroken_tether\",\n    \"of_the_unholy_confederate\",\n    \"of_the_unleashed_beast\",\n    \"of_the_unsatiated\",\n    \"of_the_untarnished_blaze\",\n    \"of_the_unwavering\",\n    \"of_the_ursine_horror\",\n    \"of_the_valintyr\",\n    \"of_the_void\",\n    \"of_the_warpath\",\n    \"of_the_wildrage\",\n    \"of_the_wildrage\",\n    \"of_the_zealots_covenant\",\n    \"of_thickened_blood\",\n    \"of_torment\",\n    \"of_transfusion\",\n    \"of_true_sight\",\n    \"of_turbulence\",\n    \"of_tyraels_jurisdiction\",\n    \"of_ultimate_shadow\",\n    \"of_uncanny_treachery\",\n    \"of_unnatural_movement\",\n    \"of_unstable_imbuements\",\n    \"of_unstoppable_force\",\n    \"of_untimely_death\",\n    \"of_unyielding_hits\",\n    \"of_utmost_glory\",\n    \"of_valiance\",\n    \"of_verdant_restoration\",\n    \"of_vocalized_empowerment\",\n    \"of_volatile_shadows\",\n    \"of_voracious_rage\",\n    \"of_walloping\",\n    \"of_warmth\",\n    \"of_watkins_law\",\n    \"of_wild_claws\",\n    \"of_wolfs_rain\",\n    \"ominous\",\n    \"opportunists\",\n    \"osseous_gale\",\n    \"overcharged\",\n    \"overcharged\",\n    \"overheating\",\n    \"overwhelming\",\n    \"perforators\",\n    \"powershifting\",\n    \"prepared_assailants\",\n    \"prodigys\",\n    \"progenitors\",\n    \"protecting\",\n    \"pyroclastic\",\n    \"raid_leaders\",\n    \"raiders\",\n    \"rangers\",\n    \"rapid\",\n    \"ravenous\",\n    \"raw_might\",\n    \"raw_might\",\n    \"reapers\",\n    \"rebounding\",\n    \"recharging\",\n    \"recharging\",\n    \"rejuvenating\",\n    \"relentless_berserkers\",\n    \"remorseless\",\n    \"requiem\",\n    \"resistant_assailants\",\n    \"revelators\",\n    \"rip_and_tear\",\n    \"rotting\",\n    \"ruthless\",\n    \"sacrificial\",\n    \"sadistic\",\n    \"sapping\",\n    \"scornful\",\n    \"seismic-shift\",\n    \"serpentine\",\n    \"shadow-soaked\",\n    \"shadowslicer\",\n    \"shard_of_dawn\",\n    \"shattered\",\n    \"shattered\",\n    \"shepherds\",\n    \"shifters\",\n    \"shivering\",\n    \"sickfoots\",\n    \"skinwalkers\",\n    \"skullbreakers\",\n    \"slaking\",\n    \"smiting\",\n    \"snap_frozen\",\n    \"snowguards\",\n    \"snowveiled\",\n    \"snowveiled\",\n    \"spirit_bond\",\n    \"splintering\",\n    \"squires\",\n    \"stable\",\n    \"starlight\",\n    \"starving_ravagers\",\n    \"steadfast_berserkers\",\n    \"steadfast_berserkers\",\n    \"sticker-thought\",\n    \"stoneworkers\",\n    \"storm_splitters\",\n    \"storm_swell\",\n    \"stormchasers\",\n    \"stormcrows\",\n    \"subterranean\",\n    \"sunderfrost\",\n    \"the_penitents\",\n    \"tidal\",\n    \"tides_of_blood\",\n    \"tormentors\",\n    \"toxic_alchemists\",\n    \"trappers\",\n    \"tricksters\",\n    \"tricksters\",\n    \"umbrous\",\n    \"undying\",\n    \"unrelenting\",\n    \"unyielding_commanders\",\n    \"vanguards\",\n    \"vanquishing\",\n    \"vehement_brawlers\",\n    \"vengeful\",\n    \"veteran_brawlers\",\n    \"vigorous\",\n    \"virtuous\",\n    \"virulent\",\n    \"vulpines\",\n    \"wanton_rupture\",\n    \"weapon_masters\",\n    \"wildbolt\",\n    \"wind_striker\",\n    \"windlasher\",\n    \"winter_touch\",\n    \"writhing\",\n    \"wywards\"\n]\n"
  },
  {
    "path": "assets/lang/enUS/corrections.json",
    "content": "{\n    \"bad_tts_uniques\": {\n        \"bane_ofahjad-den\": \"bane_of_ahjad-den\",\n        \"galvanicazurite\": \"galvanic_azurite\",\n        \"grandfather\": \"the_grandfather\",\n        \"kilt_ofblackwing\": \"kilt_of_blackwing\",\n        \"mjￃﾖlnic_ryng\": \"mjölnic_ryng\",\n        \"sunstainedwar-crozier\": \"sunstained_war-crozier\"\n    },\n    \"error_map\": {\n        \" arbarian\": \" barbarian\",\n        \"(arbarian\": \"(barbarian\",\n        \"@arbarian\": \"(barbarian\",\n        \"garbarian\": \"barbarian\",\n        \"gorcerer\": \"sorcerer\",\n        \"mruid\": \"(druid\",\n        \"omuid\": \"(druid\",\n        \"seythe\": \"scythe\",\n        \"thoms\": \"thorns\",\n        \"tier s\": \"tier 5\",\n        \"tier1\": \"tier 1\",\n        \"tier2\": \"tier 2\",\n        \"tier3\": \"tier 3\",\n        \"tier4\": \"tier 4\",\n        \"tier5\": \"tier 5\",\n        \"tier6\": \"tier 6\",\n        \"tier7\": \"tier 7\",\n        \"tier8\": \"tier 8\",\n        \"tier9\": \"tier 9\",\n        \"tmlelligence\": \"intelligence\",\n        \"tomado\": \"tornado\",\n        \"ttem\": \"item\",\n        \"two- handed\": \"two-handed\",\n        \"two-handed!\": \"two-handed\",\n        \"two-handed.\": \"two-handed\"\n    },\n    \"filter_after_keyword\": [\n        \" cts \",\n        \"account\",\n        \"compare\",\n        \"dearest will\",\n        \"empty socket\",\n        \"granted\",\n        \"pacts\",\n        \"requires lev\",\n        \"requires level\",\n        \"requires world\",\n        \"scroll down\",\n        \"scroll up\",\n        \"sell value\",\n        \"when equipped\"\n    ],\n    \"filter_words\": [\n        \"account bound\",\n        \"barbarian\",\n        \"by your clas\",\n        \"by your class\",\n        \"druid\",\n        \"dungeon affixe\",\n        \"imprinted\",\n        \"monster level\",\n        \"necromancer\",\n        \"not useable\",\n        \"only)\",\n        \"operties lost\",\n        \"properties lost\",\n        \"requires world tier 3\",\n        \"requires world tier 4\",\n        \"revives allowed\",\n        \"rogue\",\n        \"sorcerer\",\n        \"sorceress\"\n    ]\n}\n"
  },
  {
    "path": "assets/lang/enUS/item_types.json",
    "content": "{\n    \"Amulet\": \"amulet\",\n    \"Axe\": \"axe\",\n    \"Axe2H\": \"two-handed axe\",\n    \"Boots\": \"boots\",\n    \"Bow\": \"bow\",\n    \"ChestArmor\": \"chest armor\",\n    \"Crossbow2H\": \"crossbow\",\n    \"Dagger\": \"dagger\",\n    \"Elixir\": \"elixir\",\n    \"Flail\": \"flail\",\n    \"Focus\": \"focus\",\n    \"Glaive\": \"glaive\",\n    \"Gloves\": \"gloves\",\n    \"Helm\": \"helm\",\n    \"Incense\": \"custom type incense\",\n    \"Legs\": \"pants\",\n    \"Mace\": \"mace\",\n    \"Mace2H\": \"two-handed mace\",\n    \"Material\": \"custom type material\",\n    \"OffHandTotem\": \"totem\",\n    \"Polearm\": \"polearm\",\n    \"Quarterstaff\": \"quarterstaff\",\n    \"Ring\": \"ring\",\n    \"Scythe\": \"scythe\",\n    \"Scythe2H\": \"two-handed scythe\",\n    \"Shield\": \"shield\",\n    \"Sigil\": \"custom type sigil\",\n    \"Staff\": \"staff\",\n    \"Sword\": \"sword\",\n    \"Sword2H\": \"two-handed sword\",\n    \"TemperManual\": \"temper manual\",\n    \"Tome\": \"tome\",\n    \"Wand\": \"wand\"\n}\n"
  },
  {
    "path": "assets/lang/enUS/paragon_maxroll_ids.json",
    "content": "{\n    \"boards\": {\n        \"Paragon_Barb_00\": \"Start\",\n        \"Paragon_Barb_01\": \"Hemorrhage\",\n        \"Paragon_Barb_02\": \"Blood Rage\",\n        \"Paragon_Barb_03\": \"Carnage\",\n        \"Paragon_Barb_04\": \"Decimator\",\n        \"Paragon_Barb_05\": \"Bone Breaker\",\n        \"Paragon_Barb_06\": \"Flawless Technique\",\n        \"Paragon_Barb_07\": \"Warbringer\",\n        \"Paragon_Barb_08\": \"Weapons Master\",\n        \"Paragon_Barb_10\": \"Force of Nature\",\n        \"Paragon_Druid_00\": \"Start\",\n        \"Paragon_Druid_01\": \"Thunderstruck\",\n        \"Paragon_Druid_02\": \"Earthen Devastation\",\n        \"Paragon_Druid_03\": \"Survival Instincts\",\n        \"Paragon_Druid_04\": \"Lust for Carnage\",\n        \"Paragon_Druid_05\": \"Heightened Malice\",\n        \"Paragon_Druid_06\": \"Inner Beast\",\n        \"Paragon_Druid_07\": \"Constricting Tendrils\",\n        \"Paragon_Druid_08\": \"Ancestral Guidance\",\n        \"Paragon_Druid_10\": \"Untamed\",\n        \"Paragon_Necro_00\": \"Start\",\n        \"Paragon_Necro_01\": \"Cult Leader\",\n        \"Paragon_Necro_02\": \"Hulking Monstrosity\",\n        \"Paragon_Necro_03\": \"Flesh-eater\",\n        \"Paragon_Necro_04\": \"Scent of Death\",\n        \"Paragon_Necro_05\": \"Bone Graft\",\n        \"Paragon_Necro_06\": \"Blood Begets Blood\",\n        \"Paragon_Necro_07\": \"Bloodbath\",\n        \"Paragon_Necro_08\": \"Wither\",\n        \"Paragon_Necro_10\": \"Frailty\",\n        \"Paragon_Paladin_00\": \"Start\",\n        \"Paragon_Paladin_01\": \"Castle\",\n        \"Paragon_Paladin_02\": \"Shield Bearer\",\n        \"Paragon_Paladin_03\": \"Fervent\",\n        \"Paragon_Paladin_04\": \"Preacher\",\n        \"Paragon_Paladin_05\": \"Divinity\",\n        \"Paragon_Paladin_06\": \"Relentless\",\n        \"Paragon_Paladin_07\": \"Sentencing\",\n        \"Paragon_Paladin_08\": \"Endure\",\n        \"Paragon_Paladin_09\": \"Beacon\",\n        \"Paragon_Rogue_00\": \"Start\",\n        \"Paragon_Rogue_01\": \"Eldritch Bounty\",\n        \"Paragon_Rogue_02\": \"Tricks of the Trade\",\n        \"Paragon_Rogue_03\": \"Cheap Shot\",\n        \"Paragon_Rogue_04\": \"Deadly Ambush\",\n        \"Paragon_Rogue_05\": \"Leyrana's Instinct\",\n        \"Paragon_Rogue_06\": \"No Witnesses\",\n        \"Paragon_Rogue_07\": \"Exploit Weakness\",\n        \"Paragon_Rogue_08\": \"Cunning Stratagem\",\n        \"Paragon_Rogue_10\": \"Danse Macabre\",\n        \"Paragon_Sorc_00\": \"Start\",\n        \"Paragon_Sorc_01\": \"Searing Heat\",\n        \"Paragon_Sorc_02\": \"Frigid Fate\",\n        \"Paragon_Sorc_03\": \"Static Surge\",\n        \"Paragon_Sorc_04\": \"Elemental Summoner\",\n        \"Paragon_Sorc_05\": \"Burning Instinct\",\n        \"Paragon_Sorc_06\": \"Icefall\",\n        \"Paragon_Sorc_07\": \"Ceaseless Conduit\",\n        \"Paragon_Sorc_08\": \"Enchantment Master\",\n        \"Paragon_Sorc_10\": \"Fundamental Release\",\n        \"Paragon_Spirit_0\": \"Start\",\n        \"Paragon_Spirit_01\": \"In-Fighter\",\n        \"Paragon_Spirit_02\": \"Spiney Skin\",\n        \"Paragon_Spirit_03\": \"Viscous Shield\",\n        \"Paragon_Spirit_04\": \"Bitter Medicine\",\n        \"Paragon_Spirit_05\": \"Revealing\",\n        \"Paragon_Spirit_06\": \"Drive\",\n        \"Paragon_Spirit_07\": \"Convergence\",\n        \"Paragon_Spirit_08\": \"Sapping\"\n    },\n    \"glyphs\": {\n        \"Rare_001_Intelligence_Main\": \"Enchanter\",\n        \"Rare_002_Intelligence_Main\": \"Unleash\",\n        \"Rare_003_Intelligence_Main\": \"Elementalist\",\n        \"Rare_004_Intelligence_Main\": \"Adept\",\n        \"Rare_005_Intelligence_Main\": \"Conjurer\",\n        \"Rare_006_Intelligence_Main\": \"Charged\",\n        \"Rare_007_Willpower_Side\": \"Torch\",\n        \"Rare_008_Willpower_Side\": \"Pyromaniac\",\n        \"Rare_009_Willpower_Side\": \"Cryopathy\",\n        \"Rare_010_Dexterity_Main\": \"Tactician\",\n        \"Rare_011_Intelligence_Side\": \"Guzzler\",\n        \"Rare_011_Willpower_Side\": \"Imbiber\",\n        \"Rare_012_Intelligence_Side\": \"Protector\",\n        \"Rare_012_Willpower_Side\": \"Reinforced\",\n        \"Rare_013_Dexterity_Side\": \"Poise\",\n        \"Rare_014_Dexterity_Side\": \"Territorial\",\n        \"Rare_014_Strength_Main\": \"Turf\",\n        \"Rare_014_Strength_Side\": \"Turf\",\n        \"Rare_015_Dexterity_Side\": \"Flamefeeder\",\n        \"Rare_016_Dexterity_Side\": \"Exploit\",\n        \"Rare_016_Intelligence_Side\": \"Exploit\",\n        \"Rare_016_Strength_Side\": \"Exploit\",\n        \"Rare_017_Dexterity_Side\": \"Winter\",\n        \"Rare_018_Dexterity_Side\": \"Electrocute\",\n        \"Rare_019_Dexterity_Side\": \"Destruction\",\n        \"Rare_020_Dexterity_Side\": \"Control\",\n        \"Rare_020_Intelligence_Main\": \"Control\",\n        \"Rare_020_Intelligence_Side\": \"Control\",\n        \"Rare_021_Strength_Main\": \"Ambidextrous\",\n        \"Rare_022_Strength_Main\": \"Might\",\n        \"Rare_023_Strength_Main\": \"Cleaver\",\n        \"Rare_024_Strength_Main\": \"Seething\",\n        \"Rare_025_Strength_Main\": \"Crusher\",\n        \"Rare_026_Strength_Main\": \"Executioner\",\n        \"Rare_027_Strength_Main\": \"Ire\",\n        \"Rare_028_Strength_Main\": \"Marshal\",\n        \"Rare_029_Dexterity_Side\": \"Bloodfeeder\",\n        \"Rare_030_Dexterity_Side\": \"Wrath\",\n        \"Rare_031_Dexterity_Side\": \"Weapon Master\",\n        \"Rare_032_Dexterity_Side\": \"Mortal Draw\",\n        \"Rare_033_Intelligence_Side\": \"Revenge\",\n        \"Rare_033_Willpower_Side\": \"Revenge\",\n        \"Rare_033_Willpower_Side_Necro\": \"Revenge\",\n        \"Rare_034_Intelligence_Side\": \"Undaunted\",\n        \"Rare_034_Willpower_Side\": \"Undaunted\",\n        \"Rare_035_Intelligence_Side\": \"Dominate\",\n        \"Rare_035_Willpower_Side\": \"Dominate\",\n        \"Rare_035_Willpower_Side_Necro\": \"Dominate\",\n        \"Rare_036_Willpower_Side\": \"Disembowel\",\n        \"Rare_037_Willpower_Side\": \"Brawl\",\n        \"Rare_038_Intelligence_Main\": \"Corporeal\",\n        \"Rare_039_Willpower_Main\": \"Fang and Claw\",\n        \"Rare_040_Willpower_Main\": \"Earth and Sky\",\n        \"Rare_041_Intelligence_Side\": \"Wilds\",\n        \"Rare_042_Willpower_Main\": \"Werebear\",\n        \"Rare_043_Willpower_Main\": \"Werewolf\",\n        \"Rare_044_Willpower_Main\": \"Human\",\n        \"Rare_045_Intelligence_Side\": \"Bane\",\n        \"Rare_045_Strength_Side\": \"Bane\",\n        \"Rare_046_Dexterity_Side\": \"Abyssal\",\n        \"Rare_046_Intelligence_Side\": \"Keeper\",\n        \"Rare_047_Dexterity_Side\": \"Fulminate\",\n        \"Rare_047_Intelligence_Side\": \"Fulminate\",\n        \"Rare_048_Dexterity_Side\": \"Tracker\",\n        \"Rare_048_Intelligence_Side\": \"Tracker\",\n        \"Rare_049_Dexterity_Side\": \"Outmatch\",\n        \"Rare_049_Strength_Main\": \"Outmatch\",\n        \"Rare_049_Strength_Side\": \"Outmatch\",\n        \"Rare_050_Dexterity_Main\": \"Spirit\",\n        \"Rare_050_Dexterity_Side\": \"Spirit\",\n        \"Rare_050_Willpower_Side\": \"Spirit\",\n        \"Rare_051_Dexterity_Side\": \"Shapeshifter\",\n        \"Rare_052_Dexterity_Main\": \"Versatility\",\n        \"Rare_053_Dexterity_Main\": \"Closer\",\n        \"Rare_054_Dexterity_Main\": \"Ranger\",\n        \"Rare_055_Dexterity_Main\": \"Chip\",\n        \"Rare_055_Dexterity_Side\": \"Chip\",\n        \"Rare_055_Willpower_Side\": \"Chip\",\n        \"Rare_056_Dexterity_Main\": \"Frostfeeder\",\n        \"Rare_057_Dexterity_Main\": \"Fluidity\",\n        \"Rare_058_Intelligence_Side\": \"Infusion\",\n        \"Rare_059_Dexterity_Main\": \"Devious\",\n        \"Rare_060_Dexterity_Side\": \"Warrior\",\n        \"Rare_061_Intelligence_Side\": \"Combat\",\n        \"Rare_062_Dexterity_Side\": \"Gravekeeper\",\n        \"Rare_063_Intelligence_Side\": \"Canny\",\n        \"Rare_064_Intelligence_Side\": \"Efficacy\",\n        \"Rare_065_Intelligence_Side\": \"Snare\",\n        \"Rare_066_Dexterity_Side\": \"Essence\",\n        \"Rare_067_Strength_Side\": \"Pride\",\n        \"Rare_068_Strength_Side\": \"Ambush\",\n        \"Rare_069_Intelligence_Main\": \"Sacrificial\",\n        \"Rare_070_Intelligence_Main\": \"Blood-drinker\",\n        \"Rare_071_Intelligence_Main\": \"Deadraiser\",\n        \"Rare_072_Intelligence_Main\": \"Mage\",\n        \"Rare_073_Intelligence_Main\": \"Amplify\",\n        \"Rare_074_Willpower_Side\": \"Golem\",\n        \"Rare_075_Willpower_Side\": \"Scourge\",\n        \"Rare_076_Strength_Main\": \"Diminish\",\n        \"Rare_076_Strength_Side\": \"Diminish\",\n        \"Rare_077_Willpower_Side\": \"Warding\",\n        \"Rare_078_Willpower_Side\": \"Darkness\",\n        \"Rare_079_Dexterity_Side\": \"Exploit\",\n        \"Rare_080_Strength_Main\": \"Twister\",\n        \"Rare_081_Strength_Main\": \"Rumble\",\n        \"Rare_082_Dexterity_Main\": \"Explosive\",\n        \"Rare_083_Intelligence_Side\": \"Nightstalker\",\n        \"Rare_084_Intelligence_Main\": \"Stalagmite\",\n        \"Rare_085_Dexterity_Side\": \"Invocation\",\n        \"Rare_086_Dexterity_Side\": \"Tectonic\",\n        \"Rare_087_Willpower_Main\": \"Electrocution\",\n        \"Rare_088_Intelligence_Main\": \"Exhumation\",\n        \"Rare_089_Willpower_Side\": \"Desecration\",\n        \"Rare_090_Dexterity_Main\": \"Menagerist\",\n        \"Rare_091_Strength_Side\": \"Hone\",\n        \"Rare_092_Intelligence_Side\": \"Consumption\",\n        \"Rare_093_Dexterity_Main\": \"Fitness\",\n        \"Rare_094_Intelligence_Side\": \"Ritual\",\n        \"Rare_095_Dexterity_Main\": \"Jagged Plume\",\n        \"Rare_096_Strength_Side\": \"Innate\",\n        \"Rare_097_Dexterity_Main\": \"Wildfire\",\n        \"Rare_098_Strength_Side\": \"Colossal\",\n        \"Rare_100_Dexterity_Main\": \"Talon\",\n        \"Rare_101_Strength_Side\": \"Hubris\",\n        \"Rare_102_Dexterity_Main\": \"Fester\",\n        \"Rare_103_Strength_Main\": \"Sentinel\",\n        \"Rare_104_Dexterity_Side\": \"Honed\",\n        \"Rare_105_Strength_Main\": \"Law\",\n        \"Rare_106_Willpower_Side\": \"Arbiter \",\n        \"Rare_107_Strength_Main\": \"Resplendence\",\n        \"Rare_108_Intelligence_Side\": \"Judicator\",\n        \"Rare_109_Dexterity_Side\": \"Feverous\",\n        \"Rare_110_Strength_Main\": \"Apostle\",\n        \"Rare_Dex_Generic\": \"Headhunter\",\n        \"Rare_Int_Generic\": \"Eliminator\",\n        \"Rare_Str_Generic\": \"Challenger\",\n        \"Rare_Will_Generic\": \"Headhunter\"\n    }\n}\n"
  },
  {
    "path": "assets/lang/enUS/sigils.json",
    "content": "{\n    \"dungeons\": {\n        \"abandoned_mineworks\": \"abandoned mineworks\",\n        \"akkhans_grasp\": \"akkhans grasp\",\n        \"aldurwood\": \"aldurwood\",\n        \"ancient_reservoir\": \"ancient reservoir\",\n        \"ancients_lament\": \"ancients lament\",\n        \"anicas_claim\": \"anicas claim\",\n        \"basaltic_ascent\": \"basaltic ascent\",\n        \"bastion_of_faith\": \"bastion of faith\",\n        \"beast_graveyard\": \"beast graveyard\",\n        \"belfry_zakara\": \"belfry zakara\",\n        \"betrayed_tomb\": \"betrayed tomb\",\n        \"betrayers_row\": \"betrayers row\",\n        \"bewitched_grotto\": \"bewitched grotto\",\n        \"black_asylum\": \"black asylum\",\n        \"blind_burrows\": \"blind burrows\",\n        \"bloodsoaked_crag\": \"bloodsoaked crag\",\n        \"broken_bulwark\": \"broken bulwark\",\n        \"buried_halls\": \"buried halls\",\n        \"caldera_gate\": \"caldera gate\",\n        \"calibels_mine\": \"calibels mine\",\n        \"carrion_fields\": \"carrion fields\",\n        \"cataclysm\": \"cataclysm\",\n        \"cavern_of_the_sea_hag\": \"cavern of the sea hag\",\n        \"caves_of_kutokue\": \"caves of kutokue\",\n        \"champions_demise\": \"champions demise\",\n        \"charnel_house\": \"charnel house\",\n        \"collapsed_vault\": \"collapsed vault\",\n        \"conclave\": \"conclave\",\n        \"corrupted_grotto\": \"corrupted grotto\",\n        \"crumbling_hekma\": \"crumbling hekma\",\n        \"crusaders_cathedral\": \"crusaders cathedral\",\n        \"cultist_refuge\": \"cultist refuge\",\n        \"dark_ravine\": \"dark ravine\",\n        \"dark_refuge\": \"dark refuge\",\n        \"dead_mans_dredge\": \"dead mans dredge\",\n        \"defiled_catacomb\": \"defiled catacomb\",\n        \"demons_wake\": \"demons wake\",\n        \"derelict_lodge\": \"derelict lodge\",\n        \"deserted_underpass\": \"deserted underpass\",\n        \"domhainne_tunnels\": \"domhainne tunnels\",\n        \"earthen_wound\": \"earthen wound\",\n        \"endless_gates\": \"endless gates\",\n        \"faceless_shrine\": \"faceless shrine\",\n        \"fading_echo\": \"fading echo\",\n        \"farai_cliffs\": \"farai cliffs\",\n        \"feeding_grounds\": \"feeding grounds\",\n        \"ferals_den\": \"ferals den\",\n        \"fetid_mausoleum\": \"fetid mausoleum\",\n        \"flooded_depths\": \"flooded depths\",\n        \"forbidden_city\": \"forbidden city\",\n        \"forge_of_malice\": \"forge of malice\",\n        \"forgotten_depths\": \"forgotten depths\",\n        \"forgotten_remains\": \"forgotten remains\",\n        \"forgotten_ruins\": \"forgotten ruins\",\n        \"forsaken_quarry\": \"forsaken quarry\",\n        \"garan_hold\": \"garan hold\",\n        \"ghoa_ruins\": \"ghoa ruins\",\n        \"grim_haven\": \"grim haven\",\n        \"grinning_labyrinth\": \"grinning labyrinth\",\n        \"guulrahn_canals\": \"guulrahn canals\",\n        \"guulrahn_slums\": \"guulrahn slums\",\n        \"hakans_refuge\": \"hakans refuge\",\n        \"hallowed_ossuary\": \"hallowed ossuary\",\n        \"hallowed_stones\": \"hallowed stones\",\n        \"halls_of_the_damned\": \"halls of the damned\",\n        \"haunted_refuge\": \"haunted refuge\",\n        \"heart_of_the_mountain\": \"heart of the mountain\",\n        \"heathens_keep\": \"heathens keep\",\n        \"heretics_asylum\": \"heretics asylum\",\n        \"hidden_firstborn_ruins\": \"hidden firstborn ruins\",\n        \"hierophant_pyre\": \"hierophant pyre\",\n        \"hive\": \"hive\",\n        \"hoarfrost_demise\": \"hoarfrost demise\",\n        \"howling_warren\": \"howling warren\",\n        \"immortal_emanation\": \"immortal emanation\",\n        \"inferno\": \"inferno\",\n        \"iron_cenotaph\": \"iron cenotaph\",\n        \"iron_hold\": \"iron hold\",\n        \"jalals_vigil\": \"jalals vigil\",\n        \"komdor_temple\": \"komdor temple\",\n        \"kor_dragan_barracks\": \"kor dragan barracks\",\n        \"kor_valar_ramparts\": \"kor valar ramparts\",\n        \"leviathans_maw\": \"leviathans maw\",\n        \"lights_refuge\": \"lights refuge\",\n        \"lights_watch\": \"lights watch\",\n        \"lost_archives\": \"lost archives\",\n        \"lost_keep\": \"lost keep\",\n        \"lubans_rest\": \"lubans rest\",\n        \"maddux_watch\": \"maddux watch\",\n        \"mariners_refuge\": \"mariners refuge\",\n        \"maugans_works\": \"maugans works\",\n        \"maulwood\": \"maulwood\",\n        \"mercys_reach\": \"mercys reach\",\n        \"mournfield\": \"mournfield\",\n        \"murmuring_spiral\": \"murmuring spiral\",\n        \"nostrava_deepwood\": \"nostrava deepwood\",\n        \"oblivion\": \"oblivion\",\n        \"oldstones\": \"oldstones\",\n        \"onyx_hold\": \"onyx hold\",\n        \"pallid_delve\": \"pallid delve\",\n        \"path_of_the_blind\": \"path of the blind\",\n        \"penitent_cairns\": \"penitent cairns\",\n        \"prison_of_caldeum\": \"prison of caldeum\",\n        \"putrescent_larder\": \"putrescent larder\",\n        \"putrid_aquifer\": \"putrid aquifer\",\n        \"raethwind_wilds\": \"raethwind wilds\",\n        \"razaks_descent\": \"razaks descent\",\n        \"refuge_of_the_lost\": \"refuge of the lost\",\n        \"remnants_of_rage\": \"remnants of rage\",\n        \"renegades_retreat\": \"renegades retreat\",\n        \"rimescar_cavern\": \"rimescar cavern\",\n        \"ruined_wild\": \"ruined wild\",\n        \"ruins_of_eridu\": \"ruins of eridu\",\n        \"sanguine_chapel\": \"sanguine chapel\",\n        \"sarats_lair\": \"sarats lair\",\n        \"scorched_tunnels\": \"scorched tunnels\",\n        \"scoriaceous_path\": \"scoriaceous path\",\n        \"sealed_archives\": \"sealed archives\",\n        \"seaside_descent\": \"seaside descent\",\n        \"seers_reach\": \"seers reach\",\n        \"seething_underpass\": \"seething underpass\",\n        \"sepulcher_of_the_forsworn\": \"sepulcher of the forsworn\",\n        \"serpents_lair\": \"serpents lair\",\n        \"shadowed_plunge\": \"shadowed plunge\",\n        \"shifting_city\": \"shifting city\",\n        \"shivta_ruins\": \"shivta ruins\",\n        \"sirocco_caverns\": \"sirocco caverns\",\n        \"skatsimi_fane\": \"skatsimi fane\",\n        \"sleepless_hollow\": \"sleepless hollow\",\n        \"steadfast_barracks\": \"steadfast barracks\",\n        \"stockades\": \"stockades\",\n        \"submerged_ruins\": \"submerged ruins\",\n        \"sunken_library\": \"sunken library\",\n        \"sunken_ruins\": \"sunken ruins\",\n        \"the_aegoye\": \"the aegoye\",\n        \"the_hinterland\": \"the hinterland\",\n        \"the_swallowed_temple\": \"the swallowed temple\",\n        \"tomb_of_hallows\": \"tomb of hallows\",\n        \"tomb_of_the_saints\": \"tomb of the saints\",\n        \"tormented_forest\": \"tormented forest\",\n        \"tormented_ruins\": \"tormented ruins\",\n        \"twisted_hollow\": \"twisted hollow\",\n        \"ularian_sepulcher\": \"ularian sepulcher\",\n        \"uldurs_cave\": \"uldurs cave\",\n        \"underroot\": \"underroot\",\n        \"vault_of_the_forsaken\": \"vault of the forsaken\",\n        \"vile_hive\": \"vile hive\",\n        \"whispering_pines\": \"whispering pines\",\n        \"whispering_vault\": \"whispering vault\",\n        \"witchwater\": \"witchwater\",\n        \"wretched_delve\": \"wretched delve\",\n        \"yshari_sanctum\": \"yshari sanctum\",\n        \"zenith\": \"zenith\"\n    },\n    \"major\": {\n        \"anguished_souls_elites\": \"anguished souls elites elite monsters have the \\\"anguished souls\\\" affix and deal more damage.\",\n        \"astaroths_armageddon\": \"astaroths armageddon meteors will constantly rain from the sky.\",\n        \"astaroths_loyal_steed\": \"astaroths loyal steed astaroth is immune to damage upon reaching each health threshold until the amalgam of rage is killed.\",\n        \"avengers\": \"avengers killing a monster enrages monsters near it after a short delay, making them deal more damage.\",\n        \"berserker_elites\": \"berserker elites elite monsters have the \\\"berserker\\\" affix and deal more damage.\",\n        \"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.\",\n        \"cannibal_horde\": \"cannibal horde foul cannibals have defiled this place and additionally gain extra life.\",\n        \"cultist_horde\": \"cultist horde corrupted fanatics worship within this place and additionally gain extra life.\",\n        \"dark_omen\": \"dark omen this place is not right. someone, or something, stalks you here...\",\n        \"deathly_shadows\": \"deathly shadows killing a monster has a chance to unleash a volatile pulse after a short delay, dealing heavy area damage.\",\n        \"demon_horde\": \"demon horde vile hellspawn have conquered this place and additionally gain extra life.\",\n        \"drifting_shades\": \"drifting shades drifting shades chase players. on contact, they explode for heavy damage and create a nightmare field that dazes victims.\",\n        \"elemental_totems\": \"elemental totems while in combat, random elemental totems appear that buff nearby enemies until destroyed.\",\n        \"empowered_elites_cold_enchanted\": \"empowered elites cold enchanted elites always have the \\\"cold enchanted\\\" affix.\",\n        \"empowered_elites_shadow_enchanted\": \"empowered elites shadow enchanted elites always have the \\\"shadow enchanted\\\" affix.\",\n        \"empowered_elites_teleporter\": \"empowered elites teleporter elites always have the \\\"teleporter\\\" affix.\",\n        \"executioner_elites\": \"executioner elites elite monsters have the \\\"executioner\\\" affix and deal more damage.\",\n        \"explosive_elites\": \"explosive elites elite monsters have the \\\"explosive\\\" affix and deal more damage.\",\n        \"frenzied_elites\": \"frenzied elites elite monsters have the \\\"frenzied\\\" affix and deal more damage.\",\n        \"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.\",\n        \"illusory_elites\": \"illusory elites elite monsters have the \\\"illusory\\\" affix and deal more damage.\",\n        \"infested_elites\": \"infested elites elite monsters have the \\\"infested\\\" affix and deal more damage.\",\n        \"insect_horde\": \"insect horde pestilent bugs have infested this place and additionally gain extra life.\",\n        \"khazra_horde\": \"khazra horde braying goatmen have inhabited this place and additionally gain extra life.\",\n        \"lambs_to_slaughter\": \"lambs to slaughter lure captured enemies to the shrine to sacrifice them for additional rewards.\",\n        \"leaping_amalgam\": \"leaping amalgam the amalgam of rage will now continue its pounce attacks after astaroth dismounts.\",\n        \"lightning_storm\": \"lightning storm lightning gathers above the player. get into the protection dome to avoid severe outcomes.\",\n        \"meteor_storm\": \"meteor storm astaroth now has more meteor attacks.\",\n        \"nightmare_portal\": \"nightmare portal while in combat, nightmare portals open randomly near players, pouring out dangerous monsters.\",\n        \"poison_enchanted_elites\": \"poison enchanted elites elite monsters have the \\\"poison enchanted\\\" affix and deal more damage.\",\n        \"profane_aegis\": \"profane aegis monsters gain of their maximum life as a barrier.\",\n        \"rage_of_the_pack\": \"rage of the pack the amalgam of rage now summons elite werewolves.\",\n        \"raging\": \"raging monsters here are reckless, receiving and dealing more damage.\",\n        \"saw_blades_elites\": \"saw blades elites elite monsters have the \\\"saw blades\\\" affix and deal more damage.\",\n        \"shadow_enchanted_elites\": \"shadow enchanted elites elite monsters have the \\\"shadow enchanted\\\" affix and deal more damage.\",\n        \"shock_lance_elites\": \"shock lance elites elite monsters have the \\\"shock lance\\\" affix and deal more damage.\",\n        \"sinful_torment_elites\": \"sinful torment elites elite monsters have the \\\"sinful torment\\\" affix and deal more damage.\",\n        \"splitter_elites\": \"splitter elites elite monsters have the \\\"splitter\\\" affix and deal more damage.\",\n        \"stormbanes_wrath\": \"stormbanes wrath a dark monolith chases players, pulsing for heavy damage when nearby.\",\n        \"suppressor_elites\": \"suppressor elites elite monsters have the \\\"suppressor\\\" affix and deal more damage.\",\n        \"teleport_residue\": \"teleport residue astaroth now leaves behind a pool of persistent damage when teleporting.\",\n        \"the_beast_in_ice\": \"the beast in ice face a more challenging beast in ice at the end of this frozen cave.\",\n        \"undead_horde\": \"undead horde desecrated undead have risen within this place and additionally gain extra life.\",\n        \"vampiric_elites\": \"vampiric elites elite monsters have the \\\"vampiric\\\" affix and deal more damage.\",\n        \"veiled_elites\": \"veiled elites elite monsters have the \\\"veiled\\\" affix and deal more damage.\",\n        \"volcanic\": \"volcanic while in combat, gouts of flame periodically erupt near players.\",\n        \"waller_elites\": \"waller elites elite monsters have the \\\"waller\\\" affix and deal more damage.\"\n    },\n    \"minor\": {\n        \"armor_breakers\": \"armor breakers monster attacks from a distance reduce your armor by for seconds, stacking up to .\",\n        \"astaroth_blood_blisters\": \"astaroth blood blisters astaroth summons blood blisters that explode if not destroyed.\",\n        \"astaroth_deadly_pulse\": \"astaroth deadly pulse astaroth occasionally releases a deadly shadow pulse.\",\n        \"astaroth_life_steal\": \"astaroth life steal astaroth gains life steal.\",\n        \"backstabbers\": \"backstabbers close monster attacks from behind cause you to become vulnerable.\",\n        \"barrier_breakers\": \"barrier breakers monsters deal more damage to barriers.\",\n        \"bleeding_strikes\": \"bleeding strikes monsters deal an additional of their physical damage dealt as bleeding damage over seconds.\",\n        \"burning_strikes\": \"burning strikes monsters deal an additional of their physical damage dealt as burning damage over seconds.\",\n        \"chilling_wind_elites\": \"chilling wind elites elite monsters have the \\\"chilling wind\\\" affix and deal more damage.\",\n        \"cold_strikes\": \"cold strikes monsters deal an additional of their physical damage dealt as cold damage.\",\n        \"corrupting_strikes\": \"corrupting strikes monsters deal an additional of their physical damage dealt as corrupting damage over seconds.\",\n        \"dodge_breakers\": \"dodge breakers monster attacks from a distance reduce dodge chance by for seconds, stacking up to .\",\n        \"fire_strikes\": \"fire strikes monsters deal an additional of their physical damage dealt as fire damage.\",\n        \"fire_traps\": \"fire traps additional fire traps appear in this dungeon.\",\n        \"frostbiting_strikes\": \"frostbiting strikes monsters deal an additional of their physical damage dealt as frostbiting damage over seconds.\",\n        \"hellbound_elites\": \"hellbound elites elite monsters have the \\\"hellbound\\\" affix and deal more damage.\",\n        \"hunters\": \"hunters monsters have unnatural speed here, moving and attacking faster.\",\n        \"lesser_aegis\": \"lesser aegis some monsters here gain of their maximum life as a barrier.\",\n        \"lightning_strikes\": \"lightning strikes monsters deal an additional of their physical damage dealt as lightning damage.\",\n        \"melee_defenders\": \"melee defenders monsters take less damage from close targets\",\n        \"monster_barrier\": \"monster barrier monsters gain of their maximum life as a barrier.\",\n        \"monster_bleed_resist\": \"monster bleed resist monsters take less bleeding damage.\",\n        \"monster_burning_resist\": \"monster burning resist monsters take less burning damage.\",\n        \"monster_cold_resist\": \"monster cold resist monsters take less cold damage.\",\n        \"monster_critical_resist\": \"monster critical resist monster attacks reduce the damage of your critical strikes for seconds by , stacking up to .\",\n        \"monster_crowd_control_resist\": \"monster crowd control resist crowd control duration vs monsters is reduced by .\",\n        \"monster_fire_resist\": \"monster fire resist monsters take less fire damage.\",\n        \"monster_life\": \"monster life monsters gain extra life.\",\n        \"monster_life_steal\": \"monster life steal nonboss monsters gain life steal.\",\n        \"monster_overpower_resist\": \"monster overpower resist monster attacks reduce the damage of your next overpower attack by . stacking up to .\",\n        \"monster_physical_resist\": \"monster physical resist monsters take less physical damage.\",\n        \"monster_poison_resist\": \"monster poison resist monsters take less poison damage.\",\n        \"monster_regen\": \"monster regen nonboss monsters regen maximum life per second.\",\n        \"monster_shadow_damage_over_time_resist\": \"monster shadow damage over time resist monsters take reduced damage from your shadow damage over time effects.\",\n        \"monster_shadow_resist\": \"monster shadow resist monsters take less shadow damage.\",\n        \"monster_thorns\": \"monster thorns monsters reflect of damage.\",\n        \"monster_vulnerable_resist\": \"monster vulnerable resist duration of vulnerable effects vs monsters is reduced by .\",\n        \"monsters_lightning_resist\": \"monsters lightning resist monsters take less lightning damage.\",\n        \"poison_strikes\": \"poison strikes monsters deal an additional of their physical damage dealt as poison damage.\",\n        \"poisoning_strikes\": \"poisoning strikes monsters deal an additional of their physical damage dealt as poisoning damage over seconds.\",\n        \"potion_breakers\": \"potion breakers monster attacks from a distance increase the cooldown of your next potion by seconds, up to seconds.\",\n        \"ranged_defenders\": \"ranged defenders monsters take less damage from distant targets.\",\n        \"resistance_breakers\": \"resistance breakers monster attacks from a distance reduce resistance to all elements by for seconds, stacking up to .\",\n        \"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.\",\n        \"shadow_strikes\": \"shadow strikes monsters deal an additional of their physical damage dealt as shadow damage.\",\n        \"slowing_projectiles\": \"slowing projectiles monster attacks from a distance have a chance to slow targets.\",\n        \"sparking_strikes\": \"sparking strikes monsters deal an additional of their physical damage dealt as sparking damage over seconds.\",\n        \"summoner_elites\": \"summoner elites elite monsters have the \\\"summoner\\\" affix and deal more damage.\",\n        \"suppressor_elites\": \"suppressor elites elite monsters have the \\\"suppressor\\\" affix and deal more damage.\",\n        \"unstable_experiments\": \"unstable experiments monsters may explode on death, dealing heavy area damage around themselves after a delay.\",\n        \"unstoppable_monsters\": \"unstoppable monsters monsters become unstoppable when life drops below .\"\n    },\n    \"positive\": {\n        \"amethyst_reserve\": \"amethyst reserve many amethyst chests have been stashed here.\",\n        \"ancestors_favor\": \"ancestors favor extra lunar shrines and more glyph experience earned at the end of this dungeon.\",\n        \"ancestral_awakening\": \"ancestral awakening you earn more glyph experience at the end of this dungeon.\",\n        \"andariels_offering\": \"andariels offering astaroths rewards include pincushioned dolls, required to open andariels hoard.\",\n        \"artillery_shrines\": \"artillery shrines artillery shrines will appear throughout this place.\",\n        \"astral_prophecy\": \"astral prophecy astaroths rewards include a valuable horadric jewel.\",\n        \"battle_hardened\": \"battle hardened gain damage reduction for every health you are missing.\",\n        \"belials_apparitions\": \"belials apparitions elite apparitions have invaded this dungeon.\",\n        \"belials_offering\": \"belials offering astaroths rewards include betrayers husks, required to open belials hoard.\",\n        \"blast_wave_shrines\": \"blast wave shrines blast wave shrines will appear throughout this place.\",\n        \"bonus_chests_armor\": \"bonus chests armor armor chests spawn at an extremely high rate.\",\n        \"bonus_chests_boss_materials\": \"bonus chests boss materials summoning material chests spawn at an extremely high rate.\",\n        \"bonus_chests_elixirs\": \"bonus chests elixirs elixir chests spawn at an extremely high rate.\",\n        \"bonus_chests_herbs\": \"bonus chests herbs herb chests spawn at an extremely high rate.\",\n        \"bonus_chests_jewelry\": \"bonus chests jewelry jewelry chests spawn at an extremely high rate.\",\n        \"bonus_chests_runes\": \"bonus chests runes rune chests spawn at an extremely high rate.\",\n        \"bonus_chests_sigil_powder\": \"bonus chests sigil powder sigil powder chests spawn at an extremely high rate.\",\n        \"bonus_chests_weapons\": \"bonus chests weapons weapon chests spawn at an extremely high rate.\",\n        \"channeling_shrines\": \"channeling shrines channeling shrines will appear throughout this place.\",\n        \"chaos_rifts\": \"chaos rifts have opened in this place.\",\n        \"conduit_shrines\": \"conduit shrines conduit shrines will appear throughout this place.\",\n        \"control_impaired_explosions\": \"control impaired explosions being hit by control impairing effects creates an explosion around you.\",\n        \"diamond_reserve\": \"diamond reserve many diamond chests have been stashed here.\",\n        \"dungeon_delve\": \"dungeon delve completing the dungeon grants extra experience and rewards.\",\n        \"duriels_offering\": \"duriels offering astaroths rewards include shards of agony, required to open duriels hoard.\",\n        \"emerald_reserve\": \"emerald reserve many emerald chests have been stashed here.\",\n        \"equipment_delve\": \"equipment delve find horadric artifacts to earn an increasingly higher quality cache of gear.\",\n        \"extra_resplendent_chests\": \"extra resplendent chests extra resplendent chests will appear in the dungeon.\",\n        \"extra_shrines\": \"extra shrines extra shrines will appear in the dungeon.\",\n        \"fire_damage\": \"fire damage you deal more fire damage.\",\n        \"forgotten_altar\": \"forgotten altar this world will always spawn a forgotten altar.\",\n        \"forgotten_wisdom\": \"forgotten wisdom enemies hoard knowledge here, granting extra experience.\",\n        \"frost_damage\": \"frost damage you deal more frost damage.\",\n        \"gem_reserve\": \"gem reserve many gem fragment chests have been stashed here.\",\n        \"gold_find\": \"gold find you find more gold.\",\n        \"gold_reserve\": \"gold reserve many gold chests have been stashed here.\",\n        \"greater_lair_keys\": \"greater lair keys astaroths rewards include greater lair keys.\",\n        \"grigoires_offering\": \"grigoires offering astaroths rewards include living steel, required to open grigoires hoard.\",\n        \"grim_secrets\": \"grim secrets killing astaroth grants a large amount of horadric knowledge.\",\n        \"guaranteed_treasure_goblins\": \"guaranteed treasure goblins treasure goblins will appear in the dungeon.\",\n        \"harbinger_of_hatreds_offering\": \"harbinger of hatreds offering astaroths rewards include abhorrent hearts, required to open the harbinger of hatreds hoard.\",\n        \"hell_touched\": \"hell touched extra infernal shrines and more glyph experience earned at the end of this dungeon.\",\n        \"hidden_armory\": \"hidden armory exceptional items are kept here, granting elite monsters a powerful loot affix.\",\n        \"hidden_legendary_vendor\": \"hidden legendary vendor a secret vendor appears somewhere in the dungeon...\",\n        \"horadric_phials\": \"horadric phials monsters in this dungeon drop more horadric phials.\",\n        \"horadric_strongroom\": \"horadric strongroom this place will always contain a horadric strongroom.\",\n        \"increased_critical_strike\": \"increased critical strike your critical strike chance is increased by .\",\n        \"increased_healing\": \"increased healing your healing received is increased by .\",\n        \"infernal_warp_reserve\": \"infernal warp reserve many infernal warp chests have been stashed here.\",\n        \"lair_keys\": \"lair keys astaroths rewards include lair keys.\",\n        \"legendary_spoils\": \"legendary spoils astaroths rewards include a guaranteed ancestral item.\",\n        \"lethal_shrines\": \"lethal shrines lethal shrines will appear throughout this place.\",\n        \"lightning_caller\": \"lightning caller you occasionally call down lightning strikes that damage nearby enemies.\",\n        \"lightning_damage\": \"lightning damage you deal more lightning damage.\",\n        \"lord_zirs_offering\": \"lord zirs offering astaroths rewards include exquisite blood, required to open lord zirs hoard.\",\n        \"magic_find\": \"magic find you find more items from enemies.\",\n        \"materials_reserve\": \"materials reserve many materials chests have been stashed here.\",\n        \"mythic_prankster\": \"mythic prankster the resplendent treasures in this place have attracted a fancy, yet familiar nemesis...\",\n        \"nudging_evade\": \"nudging evade using evade pushes enemies back.\",\n        \"obducite_mine\": \"obducite mine monsters drop additional obducite in this place.\",\n        \"obols_reserve\": \"obols reserve many obols chests have been stashed here.\",\n        \"physical_damage\": \"physical damage you deal more physical damage.\",\n        \"poison_damage\": \"poison damage you deal more poison damage\",\n        \"poisonous_evade\": \"poisonous evade using evade leaves a damaging pool of poison under the first enemy you evade through or at your destination.\",\n        \"protection_shrines\": \"protection shrines protection shrines will appear throughout this place.\",\n        \"quick_killer\": \"quick killer killing a monster grants attack and move speed. stacking up to .\",\n        \"reduce_cooldowns_on_kill\": \"reduce cooldowns on kill killing a monster reduces your cooldowns by . seconds.\",\n        \"revered_site\": \"revered site monsters grant additional divine favor in this place.\",\n        \"riches_of_gold\": \"riches of gold astaroths rewards include a large amount of gold.\",\n        \"riches_of_keys\": \"riches of keys astaroths rewards include valuable dungeon keys.\",\n        \"riches_of_materials\": \"riches of materials astaroths rewards include a large amount of crafting materials.\",\n        \"riches_of_phials\": \"riches of phials astaroths rewards include a large amount of horadric phials.\",\n        \"ruby_reserve\": \"ruby reserve many ruby chests have been stashed here.\",\n        \"sapphire_reserve\": \"sapphire reserve many sapphire chests have been stashed here.\",\n        \"shadow_damage\": \"shadow damage you deal more shadow damage.\",\n        \"skull_reserve\": \"skull reserve many skull chests have been stashed here.\",\n        \"thorns\": \"thorns after attacking an enemy, your thorns are increased by for seconds, up to at max stacks.\",\n        \"topaz_reserve\": \"topaz reserve many topaz chests have been stashed here.\",\n        \"treasure_breach\": \"treasure breach a puzzling number of treasure goblins have overrun this place.\",\n        \"unique_spoils\": \"unique spoils astaroths rewards include more unique items.\",\n        \"urivars_offering\": \"urivars offering astaroths rewards include judicators masks, required to open urivars hoard.\",\n        \"varshans_offering\": \"varshans offering astaroths rewards include malignant hearts, required to open varshans hoard.\",\n        \"vile_splendor\": \"vile splendor opulent excess is enjoyed here, granting elite monsters the \\\"gilded\\\" affix.\"\n    }\n}\n"
  },
  {
    "path": "assets/lang/enUS/tooltips.json",
    "content": "{\n    \"ItemPower\": \"item power\"\n}\n"
  },
  {
    "path": "assets/lang/enUS/tributes.json",
    "content": "{\n    \"ancestral_tribute_of_armaments\": \"ancestral tribute of armaments\",\n    \"greater_tribute_of_armaments\": \"greater tribute of armaments\",\n    \"greater_tribute_of_harmony\": \"greater tribute of harmony\",\n    \"greater_tribute_of_ingenuity\": \"greater tribute of ingenuity\",\n    \"greater_tribute_of_refinement\": \"greater tribute of refinement\",\n    \"greater_tribute_of_the_horadrim\": \"greater tribute of the horadrim\",\n    \"lesser_tribute\": \"lesser tribute\",\n    \"lesser_tribute_of_harmony\": \"lesser tribute of harmony\",\n    \"lesser_tribute_of_ingenuity\": \"lesser tribute of ingenuity\",\n    \"lesser_tribute_of_the_horadrim\": \"lesser tribute of the horadrim\",\n    \"major_tribute_of_andariel\": \"major tribute of andariel\",\n    \"minor_tribute_of_andariel\": \"minor tribute of andariel\",\n    \"mythic_tribute_of_armaments\": \"mythic tribute of armaments\",\n    \"tribute_of_andariel\": \"tribute of andariel\",\n    \"tribute_of_armaments\": \"tribute of armaments\",\n    \"tribute_of_ascendance_resolute\": \"tribute of ascendance (resolute)\",\n    \"tribute_of_growth\": \"tribute of growth\",\n    \"tribute_of_harmony\": \"tribute of harmony\",\n    \"tribute_of_heritage\": \"tribute of heritage\",\n    \"tribute_of_ingenuity\": \"tribute of ingenuity\",\n    \"tribute_of_radiance_resolute\": \"tribute of radiance (resolute)\",\n    \"tribute_of_refinement\": \"tribute of refinement\",\n    \"tribute_of_the_horadrim\": \"tribute of the horadrim\",\n    \"tribute_of_titans\": \"tribute of titans\"\n}\n"
  },
  {
    "path": "assets/lang/enUS/uniques.json",
    "content": "{\n    \"100000_steps\": {\n        \"num_inherents\": 0\n    },\n    \"accord_of_the_wilds\": {\n        \"num_inherents\": 0\n    },\n    \"aegroms_schism\": {\n        \"num_inherents\": 0\n    },\n    \"ahavarion_spear_of_lycander\": {\n        \"num_inherents\": 0\n    },\n    \"airidahs_inexorable_will\": {\n        \"num_inherents\": 0\n    },\n    \"anathema_of_the_primes\": {\n        \"num_inherents\": 0\n    },\n    \"ancients_oath\": {\n        \"num_inherents\": 0\n    },\n    \"andariels_visage\": {\n        \"num_inherents\": 0\n    },\n    \"arcadia\": {\n        \"num_inherents\": 0\n    },\n    \"argent_veil\": {\n        \"num_inherents\": 0\n    },\n    \"arreats_bearing\": {\n        \"num_inherents\": 0\n    },\n    \"ashearas_khanjar\": {\n        \"num_inherents\": 0\n    },\n    \"assassins_stride\": {\n        \"num_inherents\": 0\n    },\n    \"autumnal_crown\": {\n        \"num_inherents\": 0\n    },\n    \"axial_conduit\": {\n        \"num_inherents\": 0\n    },\n    \"azurewrath\": {\n        \"num_inherents\": 0\n    },\n    \"balazans_maxtlatl\": {\n        \"num_inherents\": 0\n    },\n    \"band_of_first_breath\": {\n        \"num_inherents\": 0\n    },\n    \"bands_of_ichorous_rose\": {\n        \"num_inherents\": 0\n    },\n    \"bane_of_ahjad-den\": {\n        \"num_inherents\": 0\n    },\n    \"banished_lords_talisman\": {\n        \"num_inherents\": 0\n    },\n    \"bastion_of_sir_matthias\": {\n        \"num_inherents\": 2\n    },\n    \"battle_trance\": {\n        \"num_inherents\": 0\n    },\n    \"beastfall_boots\": {\n        \"num_inherents\": 0\n    },\n    \"bindings_of_attrition\": {\n        \"num_inherents\": 0\n    },\n    \"black_river\": {\n        \"num_inherents\": 0\n    },\n    \"blood-mad_idol\": {\n        \"num_inherents\": 0\n    },\n    \"blood_artisans_cuirass\": {\n        \"num_inherents\": 0\n    },\n    \"blood_moon_breeches\": {\n        \"num_inherents\": 0\n    },\n    \"blood_wake\": {\n        \"num_inherents\": 0\n    },\n    \"bloodless_scream\": {\n        \"num_inherents\": 0\n    },\n    \"blue_rose\": {\n        \"num_inherents\": 0\n    },\n    \"bridle_of_torbaalos\": {\n        \"num_inherents\": 0\n    },\n    \"cage_of_madness\": {\n        \"num_inherents\": 0\n    },\n    \"cassias_grace\": {\n        \"num_inherents\": 0\n    },\n    \"cathedrals_song\": {\n        \"num_inherents\": 2\n    },\n    \"chainscourged_mail\": {\n        \"num_inherents\": 0\n    },\n    \"cluckeye\": {\n        \"num_inherents\": 0\n    },\n    \"cluckonomicon\": {\n        \"num_inherents\": 0\n    },\n    \"condemnation\": {\n        \"num_inherents\": 0\n    },\n    \"coop_de_grâce\": {\n        \"num_inherents\": 0\n    },\n    \"cowl_of_malefic_torment\": {\n        \"num_inherents\": 0\n    },\n    \"cowl_of_the_nameless\": {\n        \"num_inherents\": 0\n    },\n    \"craze_of_the_dead_god\": {\n        \"num_inherents\": 0\n    },\n    \"crown_of_lucion\": {\n        \"num_inherents\": 0\n    },\n    \"cruors_embrace\": {\n        \"num_inherents\": 0\n    },\n    \"dark_howl\": {\n        \"num_inherents\": 0\n    },\n    \"dark_stalkers_medallion\": {\n        \"num_inherents\": 0\n    },\n    \"dawnfire\": {\n        \"num_inherents\": 0\n    },\n    \"deathgrip\": {\n        \"num_inherents\": 0\n    },\n    \"deathless_visage\": {\n        \"num_inherents\": 0\n    },\n    \"deathmask_of_nirmitruq\": {\n        \"num_inherents\": 0\n    },\n    \"deaths_pavane\": {\n        \"num_inherents\": 0\n    },\n    \"deathspeakers_pendant\": {\n        \"num_inherents\": 0\n    },\n    \"desperate_march\": {\n        \"num_inherents\": 0\n    },\n    \"dirge_of_airidah\": {\n        \"num_inherents\": 0\n    },\n    \"dirge_of_odium\": {\n        \"num_inherents\": 0\n    },\n    \"dolmen_stone\": {\n        \"num_inherents\": 0\n    },\n    \"doombringer\": {\n        \"num_inherents\": 0\n    },\n    \"drognans_anguish\": {\n        \"num_inherents\": 0\n    },\n    \"eaglehorn\": {\n        \"num_inherents\": 0\n    },\n    \"earthbreaker\": {\n        \"num_inherents\": 0\n    },\n    \"ebonpiercer\": {\n        \"num_inherents\": 0\n    },\n    \"echo_of_kwatli\": {\n        \"num_inherents\": 0\n    },\n    \"eggcecutioner\": {\n        \"num_inherents\": 0\n    },\n    \"eggis\": {\n        \"num_inherents\": 2\n    },\n    \"eldruin_sword_of_justice\": {\n        \"num_inherents\": 1\n    },\n    \"elegy\": {\n        \"num_inherents\": 0\n    },\n    \"emberfury\": {\n        \"num_inherents\": 0\n    },\n    \"emblem_of_staalbreak\": {\n        \"num_inherents\": 0\n    },\n    \"endurant_faith\": {\n        \"num_inherents\": 0\n    },\n    \"esadoras_overflowing_cameo\": {\n        \"num_inherents\": 0\n    },\n    \"esus_heirloom\": {\n        \"num_inherents\": 0\n    },\n    \"etnas_lost_dagger\": {\n        \"num_inherents\": 0\n    },\n    \"eye_of_baal\": {\n        \"num_inherents\": 0\n    },\n    \"eyes_in_the_dark\": {\n        \"num_inherents\": 0\n    },\n    \"fang_of_the_vipermagi\": {\n        \"num_inherents\": 0\n    },\n    \"fields_of_crimson\": {\n        \"num_inherents\": 0\n    },\n    \"fist_of_the_iron_rose\": {\n        \"num_inherents\": 0\n    },\n    \"fists_of_fate\": {\n        \"num_inherents\": 0\n    },\n    \"flamescar\": {\n        \"num_inherents\": 0\n    },\n    \"flameweaver\": {\n        \"num_inherents\": 0\n    },\n    \"fleshrender\": {\n        \"num_inherents\": 0\n    },\n    \"fleshwrit_carapace\": {\n        \"num_inherents\": 0\n    },\n    \"flickerstep\": {\n        \"num_inherents\": 0\n    },\n    \"footfalls_of_the_waning_world\": {\n        \"num_inherents\": 0\n    },\n    \"fractured_runestone\": {\n        \"num_inherents\": 0\n    },\n    \"fractured_winterglass\": {\n        \"num_inherents\": 0\n    },\n    \"frostburn\": {\n        \"num_inherents\": 0\n    },\n    \"fury_of_the_wilds\": {\n        \"num_inherents\": 0\n    },\n    \"galvanic_azurite\": {\n        \"num_inherents\": 0\n    },\n    \"gate_of_the_red_dawn\": {\n        \"num_inherents\": 2\n    },\n    \"gathlens_birthright\": {\n        \"num_inherents\": 0\n    },\n    \"gauntlets_of_sheol\": {\n        \"num_inherents\": 0\n    },\n    \"gift_of_frost\": {\n        \"num_inherents\": 0\n    },\n    \"gladiators_triumph\": {\n        \"num_inherents\": 0\n    },\n    \"gloves_of_the_illuminator\": {\n        \"num_inherents\": 0\n    },\n    \"godslayer_crown\": {\n        \"num_inherents\": 0\n    },\n    \"gohrs_devastating_grips\": {\n        \"num_inherents\": 0\n    },\n    \"gospel_of_the_devotee\": {\n        \"num_inherents\": 0\n    },\n    \"grasp_of_shadow\": {\n        \"num_inherents\": 0\n    },\n    \"gravewalkers_hand\": {\n        \"num_inherents\": 0\n    },\n    \"greatstaff_of_the_crone\": {\n        \"num_inherents\": 0\n    },\n    \"greaves_of_the_empty_tomb\": {\n        \"num_inherents\": 0\n    },\n    \"greenwalkers_oath\": {\n        \"num_inherents\": 0\n    },\n    \"greenwalkers_signet\": {\n        \"num_inherents\": 0\n    },\n    \"griswolds_opus\": {\n        \"num_inherents\": 0\n    },\n    \"hail_of_verglas\": {\n        \"num_inherents\": 0\n    },\n    \"hand_of_apotheosis\": {\n        \"num_inherents\": 0\n    },\n    \"hands_of_the_worldbreaker\": {\n        \"num_inherents\": 0\n    },\n    \"hangmans_hand\": {\n        \"num_inherents\": 0\n    },\n    \"harlequin_crest\": {\n        \"num_inherents\": 0\n    },\n    \"harmony_of_ebewaka\": {\n        \"num_inherents\": 0\n    },\n    \"heart_of_azgar\": {\n        \"num_inherents\": 0\n    },\n    \"hecaton_chasm\": {\n        \"num_inherents\": 0\n    },\n    \"heir_of_perdition\": {\n        \"num_inherents\": 0\n    },\n    \"hellbrand_signet\": {\n        \"num_inherents\": 0\n    },\n    \"hellhammer\": {\n        \"num_inherents\": 0\n    },\n    \"hellhounds_sabatons\": {\n        \"num_inherents\": 0\n    },\n    \"herald_of_zakarum\": {\n        \"num_inherents\": 3\n    },\n    \"heralds_morningstar\": {\n        \"num_inherents\": 0\n    },\n    \"hesha_e_kesungi\": {\n        \"num_inherents\": 0\n    },\n    \"hooves_of_the_mountain_god\": {\n        \"num_inherents\": 0\n    },\n    \"howl_from_below\": {\n        \"num_inherents\": 0\n    },\n    \"hunters_zenith\": {\n        \"num_inherents\": 0\n    },\n    \"iceheart_brais\": {\n        \"num_inherents\": 0\n    },\n    \"ifehs_dire_totem\": {\n        \"num_inherents\": 0\n    },\n    \"indiras_memory\": {\n        \"num_inherents\": 0\n    },\n    \"infernal_homunculus\": {\n        \"num_inherents\": 0\n    },\n    \"insatiable_fury\": {\n        \"num_inherents\": 0\n    },\n    \"jacinth_shell\": {\n        \"num_inherents\": 0\n    },\n    \"judgment_of_auriel\": {\n        \"num_inherents\": 0\n    },\n    \"judicants_glaivehelm\": {\n        \"num_inherents\": 0\n    },\n    \"kabraxis_will\": {\n        \"num_inherents\": 0\n    },\n    \"kessimes_legacy\": {\n        \"num_inherents\": 0\n    },\n    \"khamsin_steppewalkers\": {\n        \"num_inherents\": 0\n    },\n    \"kilt_of_blackwing\": {\n        \"num_inherents\": 0\n    },\n    \"levin_grasp\": {\n        \"num_inherents\": 0\n    },\n    \"lidless_wall\": {\n        \"num_inherents\": 2\n    },\n    \"lights_rebuke\": {\n        \"num_inherents\": 0\n    },\n    \"litany_of_sable\": {\n        \"num_inherents\": 0\n    },\n    \"locrans_talisman\": {\n        \"num_inherents\": 0\n    },\n    \"loyaltys_mantle\": {\n        \"num_inherents\": 0\n    },\n    \"lurid_pact\": {\n        \"num_inherents\": 0\n    },\n    \"mace_of_king_leoric\": {\n        \"num_inherents\": 0\n    },\n    \"mad_wolfs_glee\": {\n        \"num_inherents\": 0\n    },\n    \"malefic_crescent\": {\n        \"num_inherents\": 0\n    },\n    \"mantle_of_mountains_fury\": {\n        \"num_inherents\": 0\n    },\n    \"mantle_of_the_grey\": {\n        \"num_inherents\": 0\n    },\n    \"march_of_the_stalwart_soul\": {\n        \"num_inherents\": 0\n    },\n    \"mark_of_the_old_wolf\": {\n        \"num_inherents\": 0\n    },\n    \"melted_heart_of_selig\": {\n        \"num_inherents\": 0\n    },\n    \"might_of_qual-kehk\": {\n        \"num_inherents\": 0\n    },\n    \"might_of_the_ursine\": {\n        \"num_inherents\": 0\n    },\n    \"misericorde\": {\n        \"num_inherents\": 0\n    },\n    \"mjölnic_ryng\": {\n        \"num_inherents\": 0\n    },\n    \"molochs_beating_flame\": {\n        \"num_inherents\": 0\n    },\n    \"molten_band\": {\n        \"num_inherents\": 0\n    },\n    \"morlu_fleshward\": {\n        \"num_inherents\": 0\n    },\n    \"mothers_embrace\": {\n        \"num_inherents\": 0\n    },\n    \"mutilator_plate\": {\n        \"num_inherents\": 0\n    },\n    \"nails_of_the_gore-crowned\": {\n        \"num_inherents\": 0\n    },\n    \"nesekem_the_herald\": {\n        \"num_inherents\": 0\n    },\n    \"night_terror\": {\n        \"num_inherents\": 0\n    },\n    \"nomads_longing_heart\": {\n        \"num_inherents\": 0\n    },\n    \"okuns_catalyst\": {\n        \"num_inherents\": 1\n    },\n    \"omen_of_pain\": {\n        \"num_inherents\": 0\n    },\n    \"onyx_soul\": {\n        \"num_inherents\": 0\n    },\n    \"ophidian_iris\": {\n        \"num_inherents\": 0\n    },\n    \"orphan_maker\": {\n        \"num_inherents\": 0\n    },\n    \"orsivane\": {\n        \"num_inherents\": 0\n    },\n    \"overkill\": {\n        \"num_inherents\": 0\n    },\n    \"pact_of_bone\": {\n        \"num_inherents\": 0\n    },\n    \"paingorgers_gauntlets\": {\n        \"num_inherents\": 0\n    },\n    \"path_of_the_emissary\": {\n        \"num_inherents\": 0\n    },\n    \"path_of_tragoul\": {\n        \"num_inherents\": 0\n    },\n    \"peacemongers_signet\": {\n        \"num_inherents\": 0\n    },\n    \"penitent_greaves\": {\n        \"num_inherents\": 0\n    },\n    \"pitfighters_gull\": {\n        \"num_inherents\": 0\n    },\n    \"protean_heart\": {\n        \"num_inherents\": 0\n    },\n    \"protection_of_the_prime\": {\n        \"num_inherents\": 0\n    },\n    \"purified_lightbringer\": {\n        \"num_inherents\": 0\n    },\n    \"rage_of_harrogath\": {\n        \"num_inherents\": 0\n    },\n    \"raiment_of_the_infinite\": {\n        \"num_inherents\": 0\n    },\n    \"raiment_of_the_sea\": {\n        \"num_inherents\": 0\n    },\n    \"rakanoths_wake\": {\n        \"num_inherents\": 0\n    },\n    \"ramaladnis_magnum_opus\": {\n        \"num_inherents\": 0\n    },\n    \"razorplate\": {\n        \"num_inherents\": 0\n    },\n    \"red_blessing\": {\n        \"num_inherents\": 0\n    },\n    \"red_sermon\": {\n        \"num_inherents\": 0\n    },\n    \"rictus_of_terror\": {\n        \"num_inherents\": 0\n    },\n    \"rimeblood\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_mendeln\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_red_furor\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_starless_skies\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_the_midday_hunt\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_the_midnight_sun\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_the_ravenous\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_the_sacrilegious_soul\": {\n        \"num_inherents\": 0\n    },\n    \"ring_of_writhing_moon\": {\n        \"num_inherents\": 0\n    },\n    \"rod_of_kepeleke\": {\n        \"num_inherents\": 0\n    },\n    \"rotting_lightbringer\": {\n        \"num_inherents\": 0\n    },\n    \"rustbitten_dirk\": {\n        \"num_inherents\": 0\n    },\n    \"saboteurs_signet\": {\n        \"num_inherents\": 0\n    },\n    \"sabre_of_tsasgal\": {\n        \"num_inherents\": 0\n    },\n    \"sanctis_of_kethamar\": {\n        \"num_inherents\": 0\n    },\n    \"sanguivor_blade_of_zir\": {\n        \"num_inherents\": 0\n    },\n    \"sashes_of_the_wretched\": {\n        \"num_inherents\": 0\n    },\n    \"scepter_of_the_three\": {\n        \"num_inherents\": 0\n    },\n    \"scorn_of_the_earth\": {\n        \"num_inherents\": 0\n    },\n    \"scoundrels_kiss\": {\n        \"num_inherents\": 0\n    },\n    \"scoundrels_leathers\": {\n        \"num_inherents\": 0\n    },\n    \"scourge_of_duriel\": {\n        \"num_inherents\": 0\n    },\n    \"sea_lords_fine_gloves\": {\n        \"num_inherents\": 0\n    },\n    \"seal_of_the_ophanim\": {\n        \"num_inherents\": 0\n    },\n    \"seal_of_the_second_trumpet\": {\n        \"num_inherents\": 0\n    },\n    \"seed_of_horazon\": {\n        \"num_inherents\": 0\n    },\n    \"sepazontec\": {\n        \"num_inherents\": 0\n    },\n    \"shanars_resonance\": {\n        \"num_inherents\": 0\n    },\n    \"shard_of_verathiel\": {\n        \"num_inherents\": 0\n    },\n    \"shattered_vow\": {\n        \"num_inherents\": 0\n    },\n    \"shroud_of_false_death\": {\n        \"num_inherents\": 0\n    },\n    \"shroud_of_khanduras\": {\n        \"num_inherents\": 0\n    },\n    \"shrouded_gift\": {\n        \"num_inherents\": 0\n    },\n    \"sidhe_bindings\": {\n        \"num_inherents\": 0\n    },\n    \"signet_of_pelghain\": {\n        \"num_inherents\": 0\n    },\n    \"sire_of_sin\": {\n        \"num_inherents\": 0\n    },\n    \"skyhunter\": {\n        \"num_inherents\": 0\n    },\n    \"sliver_of_hate\": {\n        \"num_inherents\": 0\n    },\n    \"soulbrand\": {\n        \"num_inherents\": 0\n    },\n    \"spine_of_tathamet\": {\n        \"num_inherents\": 0\n    },\n    \"staff_of_endless_rage\": {\n        \"num_inherents\": 0\n    },\n    \"staff_of_lam_esen\": {\n        \"num_inherents\": 0\n    },\n    \"staff_of_zerae\": {\n        \"num_inherents\": 0\n    },\n    \"starfall_coronet\": {\n        \"num_inherents\": 0\n    },\n    \"stone_of_vehemen\": {\n        \"num_inherents\": 0\n    },\n    \"storms_companion\": {\n        \"num_inherents\": 0\n    },\n    \"strike_of_stormhorn\": {\n        \"num_inherents\": 0\n    },\n    \"sunbirds_gorget\": {\n        \"num_inherents\": 0\n    },\n    \"sunbrand\": {\n        \"num_inherents\": 0\n    },\n    \"sundered_night\": {\n        \"num_inherents\": 0\n    },\n    \"sunstained_war-crozier\": {\n        \"num_inherents\": 0\n    },\n    \"supplication\": {\n        \"num_inherents\": 0\n    },\n    \"tal_rashas_iridescent_loop\": {\n        \"num_inherents\": 0\n    },\n    \"tassets_of_the_dawning_sky\": {\n        \"num_inherents\": 0\n    },\n    \"temerity\": {\n        \"num_inherents\": 0\n    },\n    \"tempest_roar\": {\n        \"num_inherents\": 0\n    },\n    \"the_basilisk\": {\n        \"num_inherents\": 0\n    },\n    \"the_blade_of_sight_aflame\": {\n        \"num_inherents\": 0\n    },\n    \"the_butchers_cleaver\": {\n        \"num_inherents\": 0\n    },\n    \"the_eightfold_idol\": {\n        \"num_inherents\": 0\n    },\n    \"the_fecund_seal\": {\n        \"num_inherents\": 0\n    },\n    \"the_gloom_ward\": {\n        \"num_inherents\": 2\n    },\n    \"the_grandfather\": {\n        \"num_inherents\": 1\n    },\n    \"the_hand_of_naz\": {\n        \"num_inherents\": 0\n    },\n    \"the_hemat_stone\": {\n        \"num_inherents\": 0\n    },\n    \"the_maestro\": {\n        \"num_inherents\": 0\n    },\n    \"the_mortacrux\": {\n        \"num_inherents\": 0\n    },\n    \"the_oculus\": {\n        \"num_inherents\": 0\n    },\n    \"the_open_eye_of_gorgorra\": {\n        \"num_inherents\": 0\n    },\n    \"the_relentless_heart\": {\n        \"num_inherents\": 0\n    },\n    \"the_third_blade\": {\n        \"num_inherents\": 0\n    },\n    \"the_umbracrux\": {\n        \"num_inherents\": 0\n    },\n    \"the_undercrown\": {\n        \"num_inherents\": 0\n    },\n    \"the_unmaker\": {\n        \"num_inherents\": 0\n    },\n    \"thousand-eye_reaver\": {\n        \"num_inherents\": 0\n    },\n    \"thrice-woven_nightmare\": {\n        \"num_inherents\": 0\n    },\n    \"thundergods_blessing\": {\n        \"num_inherents\": 0\n    },\n    \"tibaults_will\": {\n        \"num_inherents\": 0\n    },\n    \"tuskhelm_of_joritz_the_mighty\": {\n        \"num_inherents\": 0\n    },\n    \"twin_strikes\": {\n        \"num_inherents\": 0\n    },\n    \"tyraels_might\": {\n        \"num_inherents\": 1\n    },\n    \"ugly_bastard_helm\": {\n        \"num_inherents\": 0\n    },\n    \"unbroken_chain\": {\n        \"num_inherents\": 0\n    },\n    \"unsung_ascetics_wraps\": {\n        \"num_inherents\": 0\n    },\n    \"vasilys_prayer\": {\n        \"num_inherents\": 0\n    },\n    \"vengeful_sinew\": {\n        \"num_inherents\": 0\n    },\n    \"vision_of_the_firestorm\": {\n        \"num_inherents\": 0\n    },\n    \"vox_omnium\": {\n        \"num_inherents\": 0\n    },\n    \"ward_of_the_white_dove\": {\n        \"num_inherents\": 2\n    },\n    \"waxing_gibbous\": {\n        \"num_inherents\": 0\n    },\n    \"wendigo_brand\": {\n        \"num_inherents\": 0\n    },\n    \"widows_web\": {\n        \"num_inherents\": 0\n    },\n    \"wildheart_hunger\": {\n        \"num_inherents\": 0\n    },\n    \"will_of_rathma\": {\n        \"num_inherents\": 0\n    },\n    \"will_of_stone\": {\n        \"num_inherents\": 0\n    },\n    \"windforce\": {\n        \"num_inherents\": 0\n    },\n    \"word_of_hakan\": {\n        \"num_inherents\": 0\n    },\n    \"wound_drinker\": {\n        \"num_inherents\": 0\n    },\n    \"wreath_of_auric_laurel\": {\n        \"num_inherents\": 0\n    },\n    \"writhing_band_of_trickery\": {\n        \"num_inherents\": 0\n    },\n    \"wushe_nak_pa\": {\n        \"num_inherents\": 0\n    },\n    \"wyrdskin\": {\n        \"num_inherents\": 0\n    },\n    \"xfals_corroded_signet\": {\n        \"num_inherents\": 0\n    },\n    \"yens_blessing\": {\n        \"num_inherents\": 0\n    }\n}\n"
  },
  {
    "path": "build.py",
    "content": "import os\nimport shutil\nfrom pathlib import Path\n\nfrom src import __version__\n\nEXE_NAME = \"d4lf.exe\"\n\n\ndef build(release_dir: Path):\n    installer_cmd = (\n        f\"pyinstaller --clean --onefile --icon=assets/logo.ico --distpath {release_dir} --paths src src\\\\main.py\"\n    )\n    os.system(installer_cmd)\n    (release_dir / \"main.exe\").rename(release_dir / EXE_NAME)\n\n\ndef clean_up():\n    if (build_dir := Path(\"build\")).exists():\n        shutil.rmtree(build_dir)\n    for p in Path.cwd().glob(\"*.spec\"):\n        p.unlink()\n\n\ndef copy_additional_resources(release_dir: Path):\n    (release_dir / \"tts\").mkdir()\n    shutil.copy(\"README.md\", release_dir)\n    shutil.copy(\"tts/saapi64.dll\", release_dir)\n    shutil.copytree(\"assets\", release_dir / \"assets\")\n    shutil.copy(\"tts/install_dll.cmd\", release_dir)\n\n\ndef create_batch_for_consoleonly(release_dir: Path, exe_name: str):\n    batch_file_path = release_dir / \"d4lf-consoleonly.bat\"\n    with Path(batch_file_path).open(\"w\", encoding=\"utf-8\") as f:\n        f.write(\"@echo off\\n\")\n        f.write('cd /d \"%~dp0\"\\n')\n        f.write(f'start \"\" {exe_name} --consoleonly\\n')\n\n\ndef create_batch_for_autoupdater(release_dir: Path, exe_name: str):\n    batch_file_path = release_dir / \"autoupdater.bat\"\n    Path(batch_file_path).write_text(\n        f\"\"\"@echo off\ncd /d \"%~dp0\"\necho Starting D4LF auto update preprocessing\nstart /WAIT {exe_name} --autoupdate\nif %errorlevel% == 1 (\n    echo Process did not complete successfully, check logs for more information.\n) else if %errorlevel% == 2 (\n    echo D4Lf is already up to date!\n) else (\n    echo Killing all existing d4lf processes to perform update\n    taskkill /f /im d4lf.exe\n    timeout /t 1 /nobreak\n    echo Updating files\n    robocopy \"./temp_update/d4lf\" \".\" /MIR /XF \"autoupdater.bat\" /XD \"temp_update\" \"logs\"\n    echo Running postprocessing to verify update and clean up files\n    start /WAIT {exe_name} --autoupdatepost\n)\"\"\",\n        encoding=\"utf-8\",\n    )\n\n\nif __name__ == \"__main__\":\n    os.chdir(Path(__file__).parent)\n    print(f\"Building version: {__version__}\")\n    release_dir = Path(\"d4lf\")\n    if release_dir.exists():\n        shutil.rmtree(release_dir.absolute())\n    release_dir.mkdir(exist_ok=True, parents=True)\n    clean_up()\n    build(release_dir=release_dir)\n    copy_additional_resources(release_dir)\n    create_batch_for_consoleonly(release_dir=release_dir, exe_name=EXE_NAME)\n    create_batch_for_autoupdater(release_dir=release_dir, exe_name=EXE_NAME)\n    clean_up()\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nbuild-backend = \"hatchling.build\"\nrequires = [\"hatchling\"]\n\n[dependency-groups]\ndev = [\n    \"prek\",\n    \"pyinstaller\",\n    \"pytest\",\n    \"pytest-env\",\n    \"pytest-mock\",\n    \"pytest-pythonpath\",\n    \"pytest-xdist\",\n    \"typing-extensions\"\n]\n\n[project]\ndependencies = [\n    \"beautifultable\",\n    \"colorama\",\n    \"httpx\",\n    \"jsonpath\",\n    \"keyboard\",\n    \"lxml\",\n    \"mouse\",\n    \"mss\",\n    \"natsort\",\n    \"numpy\",\n    \"opencv-python\",\n    \"pillow\",\n    \"psutil\",\n    \"pydantic\",\n    \"pydantic-numpy\",\n    \"pydantic-yaml\",\n    \"pyqt6\",\n    \"python-jsonpath\",\n    \"pytweening\",\n    \"pywin32; sys_platform == 'win32'\",\n    \"pyyaml\",\n    \"rapidfuzz\",\n    \"ruamel-yaml\",\n    \"ruff\",\n    \"selenium\",\n    \"seleniumbase\",\n    \"tk\",\n    \"webdriver-manager\"\n]\ndynamic = [\"version\"]\nname = \"d4lf\"\nrequires-python = \">=3.14\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\"]\n\n[tool.hatch.version]\npath = \"src/__init__.py\"\n\n[tool.ruff]\nindent-width = 4\nline-length = 120\npreview = true\n\n[tool.ruff.format]\ndocstring-code-format = true\ndocstring-code-line-length = \"dynamic\"\nindent-style = \"space\"\nline-ending = \"auto\"\nquote-style = \"double\"\nskip-magic-trailing-comma = true\n\n[tool.ruff.lint]\nignore = [\n    \"ANN001\",  # fix more in the future, this is typing\n    \"ANN002\",  # fix more in the future, this is typing\n    \"ANN003\",  # fix more in the future, this is typing\n    \"ANN201\",  # fix more in the future, this is typing\n    \"ANN202\",  # fix more in the future, this is typing\n    \"ANN204\",  # fix more in the future, this is typing\n    \"ANN205\",  # fix more in the future, this is typing\n    \"ANN401\",  # fix more in the future, this is typing\n    \"ARG001\",  # fix more in the future\n    \"ARG002\",  # fix more in the future\n    \"BLE001\",  # fix more in the future\n    \"C901\",  # fix more in the future\n    \"COM812\",\n    \"CPY\",\n    \"D100\",\n    \"D101\",\n    \"D102\",\n    \"D103\",\n    \"D104\",\n    \"D105\",\n    \"D106\",\n    \"D107\",\n    \"DOC201\",  # fix more in the future, this is doc\n    \"DOC501\",  # fix more in the future, this is docs\n    \"DOC502\",  # fix more in the future, this is docs\n    \"E501\",\n    \"ERA001\",  # fix more in the future\n    \"FBT001\",\n    \"FBT002\",\n    \"FBT003\",  # fix more in the future\n    \"FIX002\",  # fix more in the future\n    \"FIX004\",  # fix more in the future\n    \"FURB101\",  # fix more in the future\n    \"FURB118\",  # fix more in the future\n    \"FURB171\",  # fix more in the future\n    \"G004\",\n    \"LOG014\",\n    \"N801\",  # fix more in the future\n    \"N802\",  # fix more in the future\n    \"N803\",  # fix more in the future\n    \"N805\",  # fix more in the future\n    \"N806\",  # fix more in the future\n    \"N812\",  # fix more in the future\n    \"N815\",  # fix more in the future\n    \"N818\",  # fix more in the future\n    \"NPY002\",  # fix more in the future\n    \"PLC0206\",  # fix more in the future\n    \"PLR0904\",  # fix more in the future\n    \"PLR0911\",  # fix more in the future\n    \"PLR0912\",  # fix more in the future\n    \"PLR0913\",\n    \"PLR0914\",  # fix more in the future\n    \"PLR0915\",  # fix more in the future\n    \"PLR0916\",  # fix more in the future\n    \"PLR0917\",\n    \"PLR1702\",  # fix more in the future\n    \"PLR1704\",  # fix more in the future\n    \"PLR2004\",  # fix more in the future\n    \"PLR6104\",  # fix more in the future\n    \"PLR6201\",  # fix more in the future\n    \"PLR6301\",  # fix more in the future, staticmethods\n    \"PLW0108\",  # fix more in the future\n    \"PLW0602\",  # fix more in the future\n    \"PLW0603\",  # fix more in the future\n    \"PLW1641\",  # fix more in the future\n    \"PTH122\",  # fix more in the future\n    \"RUF001\",  # fix more in the future\n    \"RUF005\",  # fix more in the future\n    \"RUF012\",  # fix more in the future\n    \"RUF043\",  # fix more in the future\n    \"RUF045\",  # fix more in the future\n    \"RUF059\",  # fix more in the future\n    \"RUF067\",  # fix more in the future\n    \"S101\",  # fix more in the future\n    \"S311\",  # fix more in the future\n    \"S404\",\n    \"S506\",  # fix more in the future\n    \"S603\",\n    \"S605\",  # fix more in the future\n    \"S606\",  # fix more in the future\n    \"S607\",\n    \"SLF001\",  # fix more in the future\n    \"T201\",  # fix more in the future\n    \"TD002\",  # fix more in the future\n    \"TD003\",  # fix more in the future\n    \"TD004\",  # fix more in the future\n    \"TRY002\",  # fix more in the future\n    \"TRY003\",  # fix more in the future\n    \"TRY004\",  # fix more in the future\n    \"TRY400\",  # fix more in the future\n    \"UP047\"  # fix more in the future\n]\nselect = [\"ALL\"]\n\n[tool.ruff.lint.isort]\nsplit-on-trailing-comma = false\n\n[tool.ruff.lint.per-file-ignores]\n\"tests/*\" = [\n    \"FBT003\",\n    \"PLC2701\",\n    \"PLR2004\",\n    \"S101\",\n    \"S311\",\n    \"SLF001\"\n]\n\n[tool.ruff.lint.pydocstyle]\nconvention = \"google\"\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\naddopts = --strict-markers\nmarkers =\n    requests: mark a test using requests\n    selenium: mark a test using selenium\npythonpath = src\ntestpaths = tests\n"
  },
  {
    "path": "src/__init__.py",
    "content": "import concurrent.futures\n\nTP = concurrent.futures.ThreadPoolExecutor()\n\n__version__ = \"9.1.3\"\n"
  },
  {
    "path": "src/autoupdater.py",
    "content": "import logging\nimport shutil\nimport sys\nimport time\nimport zipfile\nfrom pathlib import Path\n\nimport requests\n\nimport src.logger\nfrom src import __version__\n\nLOGGER = logging.getLogger(__name__)\n\n\n# This autoupdater was almost entirely provided by iAmPilcrow\nclass D4LFUpdater:\n    def __init__(self):\n        self.repo_owner = \"d4lfteam\"\n        self.repo_name = \"d4lf\"\n        self.api_url = f\"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/releases/latest\"\n        self.changes_base_url = f\"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/compare/\"\n        self.current_dir = Path.cwd()\n        self.temp_dir = self.current_dir / \"temp_update\"\n        self.version_file = self.temp_dir / \"version\"\n\n    @staticmethod\n    def normalize_version(version):\n        \"\"\"Ensure version has 'v' prefix.\"\"\"\n        if version and not version.startswith(\"v\"):\n            return f\"v{version.strip()}\"\n        return version\n\n    def get_latest_release(self, silent=False):\n        \"\"\"Fetch latest release info from GitHub API.\"\"\"\n        if not silent:\n            LOGGER.info(\"Checking for latest release...\")\n        try:\n            response = requests.get(self.api_url, timeout=10)\n            response.raise_for_status()\n            return response.json()\n        except requests.exceptions.RequestException as e:\n            LOGGER.error(f\"Error fetching release info: {e}\")\n            return None\n\n    def print_changes_between_releases(self, current_version, latest_version):\n        try:\n            url = self.changes_base_url + current_version + \"...\" + latest_version\n            response = requests.get(url, timeout=10)\n            response.raise_for_status()\n\n            LOGGER.info(\"Changes since last update:\")\n            for commit in response.json()[\"commits\"]:\n                LOGGER.info(f\"- {commit['commit']['message']}\")\n        except requests.exceptions.RequestException as e:\n            LOGGER.error(f\"Error fetching changes since last update: {e}\")\n\n    @staticmethod\n    def download_file(url, filename):\n        \"\"\"Download file with progress indication.\"\"\"\n        LOGGER.info(f\"Downloading {filename}...\")\n        try:\n            response = requests.get(url, stream=True, timeout=30)\n            response.raise_for_status()\n\n            total_size = int(response.headers.get(\"content-length\", 0))\n            downloaded = 0\n\n            with Path(filename).open(\"wb\") as f:\n                for chunk in response.iter_content(chunk_size=8192):\n                    if chunk:\n                        f.write(chunk)\n                        downloaded += len(chunk)\n                        if total_size > 0:\n                            percent = (downloaded / total_size) * 100\n                            print(f\"\\rProgress: {percent:.1f}%\", end=\"\")\n                print(\"\\n\")\n        except requests.exceptions.RequestException as e:\n            LOGGER.error(f\"\\nError downloading file: {e}\")\n            return False\n        LOGGER.info(\"Download complete!\")\n        return True\n\n    def extract_release(self, zip_path, latest_version):\n        \"\"\"Extract zip so batch process can copy files.\"\"\"\n        LOGGER.info(\"Extracting files...\")\n\n        try:\n            # Extract zip\n            with zipfile.ZipFile(zip_path, \"r\") as zip_ref:\n                zip_ref.extractall(self.temp_dir)\n\n            # Also create an update file with information post processing will need\n            # with Path(self.update_file).open(\"w\") as f:\n            #     update_data = {\"version\": latest_version, \"zip_path\": zip_path}\n            #     json.dump(update_data, f)\n            Path(self.version_file).write_text(latest_version, encoding=\"utf-8\")\n        except Exception as e:\n            LOGGER.error(f\"Error during extraction: {e}\")\n            return False\n        LOGGER.info(\"Files extracted successfully!\")\n        return True\n\n    @staticmethod\n    def _get_major_version_number(version: str) -> int:\n        return int(version.replace(\"v\", \"\").split(\".\")[0])\n\n    def preprocess(self):\n        \"\"\"Main update process.\n\n        This will:\n        - Check if update is needed\n        - Download new release\n        - Extract files to a temp directory\n        Additional updating and cleanup will be handled by the post process\n        \"\"\"\n        self._print_header()\n\n        # Get current installed version\n        current_version = self.normalize_version(__version__)\n        LOGGER.info(f\"Current installed version: {current_version}\")\n\n        # Get latest release info\n        release_data = self.get_latest_release()\n        if not release_data:\n            LOGGER.warning(\"Unable to find latest release on github, can't automatically update.\")\n            return False\n\n        latest_version = self.normalize_version(release_data.get(\"tag_name\"))\n        LOGGER.info(f\"Latest release tag: {latest_version}\")\n\n        # Check if update needed\n        if current_version == latest_version:\n            LOGGER.info(\"✓ You're already on the latest version!\")\n            input(\"\\nPress Enter to exit...\")\n            sys.exit(2)\n\n        LOGGER.info(f\"→ Update available: {current_version} → {latest_version}\")\n        self.print_changes_between_releases(current_version, latest_version)\n\n        # Check if it's an update to a major version and warn of the consequences\n        if self._get_major_version_number(latest_version) > self._get_major_version_number(current_version):\n            LOGGER.warning(\n                \"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?\"\n            )\n            proceed = input(\"Enter yes or y to proceed, all other inputs will cancel: \")\n            if proceed.lower() not in [\"yes\", \"y\"]:\n                LOGGER.info(\"Cancelling update.\")\n                return False\n\n        # Find the d4lf zip asset\n        assets = release_data.get(\"assets\", [])\n        zip_asset = None\n\n        for asset in assets:\n            if asset[\"name\"].startswith(\"d4lf_\") and asset[\"name\"].endswith(\".zip\"):\n                zip_asset = asset\n                break\n\n        if not zip_asset:\n            LOGGER.error(\"Could not find d4lf zip file in release assets.\")\n            return False\n\n        # Create temp directory\n        self.temp_dir.mkdir(exist_ok=True)\n\n        download_url = zip_asset[\"browser_download_url\"]\n        zip_filename = self.temp_dir / zip_asset[\"name\"]\n\n        LOGGER.info(\"\")\n        # Download\n        if not self.download_file(download_url, zip_filename):\n            return False\n\n        # Extract the zip\n        if not self.extract_release(zip_filename, latest_version):\n            return False\n\n        LOGGER.info(\"=\" * 50)\n        LOGGER.info(\"✓ Preprocessing is done, shutting down to allow update to happen. A new window will open shortly.\")\n        LOGGER.info(\"=\" * 50)\n        return True\n\n    def postprocess(self):\n        \"\"\"Post process will handle the cleanup.\n\n        It will:\n        - Delete the temporary files that were extracted\n        - Verify the version is truly updated\n        \"\"\"\n        self._print_header()\n        with self.version_file.open(\"r\") as f:\n            updated_to_version = f.read().strip()\n\n        if not updated_to_version:\n            LOGGER.error(\n                \"Pre-processing update data was missing! Try to update manually by downloading the newest D4LF release.\"\n            )\n            return False\n\n        current_version = self.normalize_version(__version__)\n        if updated_to_version != current_version:\n            LOGGER.error(\n                f\"Current version is {current_version} but we attempted to update to {updated_to_version}. Check logs for errors and update manually.\"\n            )\n            return False\n\n        LOGGER.info(\"Cleaning up temporary files\")\n        if self.temp_dir.exists():\n            shutil.rmtree(self.temp_dir, ignore_errors=True)\n        LOGGER.info(\"Temporary files are removed\")\n\n        LOGGER.info(\"=\" * 50)\n        LOGGER.info(f\"✓ Successfully updated to {updated_to_version}!\")\n        LOGGER.info(\"=\" * 50)\n        return True\n\n    @staticmethod\n    def _print_header():\n        LOGGER.info(\"=\" * 50)\n        LOGGER.info(\"D4LF Auto-Updater\")\n        LOGGER.info(\"=\" * 50)\n        LOGGER.info(\"\")\n\n\ndef start_auto_update(postprocess=False):\n    updater = D4LFUpdater()\n    try:\n        success = updater.postprocess() if postprocess else updater.preprocess()\n        input(\"\\nPress Enter to exit...\")\n        sys.exit(0 if success else 1)\n    except KeyboardInterrupt:\n        LOGGER.warning(\"\\n\\nUpdate cancelled by user.\")\n        sys.exit(1)\n    except Exception as e:\n        LOGGER.error(f\"\\n\\nUnexpected error: {e}\")\n        input(\"\\nPress Enter to exit...\")\n        sys.exit(1)\n\n\ndef notify_if_update():\n    if not _should_check_for_update():\n        LOGGER.debug(\"Still within 4 hours of previous update check, skipping automatic update check.\")\n        return\n\n    updater = D4LFUpdater()\n    current_version = updater.normalize_version(__version__)\n\n    release = updater.get_latest_release(silent=True)\n    if not release:\n        LOGGER.warning(\"Unable to find latest release of d4lf on github, skipping check for updates.\")\n        return\n\n    latest_version = updater.normalize_version(release.get(\"tag_name\"))\n    if current_version != latest_version:\n        LOGGER.info(\"=\" * 50)\n        LOGGER.info(\n            f\"An update has been detected. Run d4lf_autoupdater.exe to automatically update. Version {current_version} → {latest_version}\"\n        )\n        updater.print_changes_between_releases(current_version=current_version, latest_version=latest_version)\n        LOGGER.info(\"=\" * 50)\n\n\ndef _should_check_for_update(check_interval_hours=4):\n    \"\"\"Check if it's time to check for updates based on a cooldown period.\"\"\"\n    check_file = Path.cwd() / \"assets\" / \"last_update\"\n    current_time = time.time()\n    last_check_time = 0\n\n    # Read the last check time from file if it exists\n    if Path.exists(check_file):\n        with Path(check_file).open(\"r\", encoding=\"utf-8\") as f:\n            last_check_time = float(f.read().strip())\n\n    # Calculate elapsed time since last check\n    elapsed_time = current_time - last_check_time\n\n    # Check if enough time has passed\n    if elapsed_time >= (check_interval_hours * 3600):\n        # Update the last check time\n        Path(check_file).write_text(str(current_time), encoding=\"utf-8\")\n        return True\n    return False\n\n\n# Main is only used for testing as files will not actually be copied\nif __name__ == \"__main__\":\n    src.logger.setup(log_level=\"debug\")\n    start_auto_update()\n    # start_auto_update(postprocess=True)\n"
  },
  {
    "path": "src/cam.py",
    "content": "import logging\nimport threading\nimport time\n\nimport mss\nimport mss.windows\nimport numpy as np\n\nfrom src.config.ui import ResManager\nfrom src.utils.misc import convert_args_to_numpy\n\nLOGGER = logging.getLogger(__name__)\n\nmss.windows.CAPTUREBLT = 0\ncached_img_lock = threading.Lock()\n\n\nclass Cam:\n    last_grab: int = None\n    cached_img: np.ndarray = None\n    window_offset_set: bool = False\n    window_roi: dict = {\"top\": 0, \"left\": 0, \"width\": 0, \"height\": 0}\n    monitor_x_range: tuple[int] = None\n    monitor_y_range: tuple[int] = None\n    res_key = \"\"\n\n    _initialized: bool = False\n    _instance = None\n\n    def __new__(cls):\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def update_window_pos(self, offset_x: int, offset_y: int, width: int, height: int):\n        if (\n            self.is_offset_set()\n            and self.window_roi[\"top\"] == offset_y\n            and self.window_roi[\"left\"] == offset_x\n            and self.window_roi[\"width\"] == width\n            and self.window_roi[\"height\"] == height\n        ):\n            return\n        self.res_key = f\"{width}x{height}\"\n        self.res_p = f\"{height}p\"\n        LOGGER.debug(f\"Found Window Res: {self.res_key}\")\n\n        self.window_roi[\"top\"] = offset_y\n        self.window_roi[\"left\"] = offset_x\n        self.window_roi[\"width\"] = width\n        self.window_roi[\"height\"] = height\n        self.monitor_x_range = (self.window_roi[\"left\"] + 10, self.window_roi[\"left\"] + self.window_roi[\"width\"] - 10)\n        self.monitor_y_range = (self.window_roi[\"top\"] + 10, self.window_roi[\"top\"] + self.window_roi[\"height\"] - 10)\n        self.window_offset_set = True\n        ResManager().set_resolution(self.res_key)\n        if self.window_roi[\"width\"] / self.window_roi[\"height\"] < 16 / 10:\n            LOGGER.warning(\"Aspect ratio is too narrow, please use a wider window. At least 16/10\")\n\n    def is_offset_set(self):\n        return self.window_offset_set\n\n    def grab(self, force_new: bool = False) -> np.ndarray:\n        if (\n            not force_new\n            and self.cached_img is not None\n            and self.last_grab is not None\n            and time.perf_counter() - self.last_grab < 0.04\n        ):\n            return self.cached_img\n\n        # wait for offsets to be found\n        if not self.is_offset_set():\n            LOGGER.debug(\"Wait for window detection\")\n            while not self.window_offset_set:\n                time.sleep(0.05)\n            LOGGER.debug(\"Found window, continue grabbing\")\n        with cached_img_lock:\n            self.last_grab = time.perf_counter()\n        with mss.mss() as sct:\n            img = np.array(sct.grab(self.window_roi))\n        with cached_img_lock:\n            self.cached_img = img[:, :, :3]\n        return self.cached_img\n\n    # Conversions\n    # ============================================================================\n    @convert_args_to_numpy\n    def monitor_to_window(self, monitor_coord: np.ndarray) -> np.ndarray:\n        return monitor_coord[:] - np.array([self.window_roi[\"left\"], self.window_roi[\"top\"]])\n\n    @convert_args_to_numpy\n    def window_to_monitor(self, window_coord: np.ndarray) -> np.ndarray:\n        # TODO: clip by monitor ranges\n        return window_coord[:] + np.array([self.window_roi[\"left\"], self.window_roi[\"top\"]])\n\n    @convert_args_to_numpy\n    def abs_window_to_window(self, abs_window_coord: np.ndarray) -> np.ndarray:\n        return abs_window_coord[:] + np.array([self.window_roi[\"width\"] // 2, self.window_roi[\"height\"] // 2])\n\n    @convert_args_to_numpy\n    def window_to_abs_window(self, window_coord: np.ndarray) -> np.ndarray:\n        return window_coord[:] - np.array([self.window_roi[\"width\"] // 2, self.window_roi[\"height\"] // 2])\n\n    @convert_args_to_numpy\n    def abs_window_to_monitor(self, abs_window_coord: np.ndarray) -> np.ndarray:\n        window_coord = self.abs_window_to_window(abs_window_coord)\n        return self.window_to_monitor(window_coord)\n"
  },
  {
    "path": "src/config/__init__.py",
    "content": "import sys\nfrom pathlib import Path\n\n\ndef get_base_dir(bundled: bool = False) -> Path:\n    if getattr(sys, \"frozen\", False) and not bundled:\n        return Path(sys.executable).parent\n    return Path(__file__).parent.parent.parent\n\n\nAFFIX_COMPARISON_CHARS = 60\n\nBASE_DIR = get_base_dir(False)\n"
  },
  {
    "path": "src/config/data.py",
    "content": "\"\"\"Everything is this file is based on UHD resolution (3840x2160).\"\"\"\n\nimport logging\nfrom dataclasses import dataclass\nfrom functools import lru_cache\nfrom pathlib import Path\n\nimport cv2\nimport numpy as np\n\nfrom src.config import BASE_DIR\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import ColorsModel, HSVRangeModel, UiOffsetsModel, UiPosModel, UiRoiModel\nfrom src.utils.image_operations import alpha_to_mask\n\nLOGGER = logging.getLogger(\"d4lf\")\n\nCOLORS = ColorsModel(\n    material_color=HSVRangeModel(h_s_v_min=np.array([86, 110, 190]), h_s_v_max=np.array([114, 220, 255])),\n    unique_gold=HSVRangeModel(h_s_v_min=np.array([4, 45, 125]), h_s_v_max=np.array([26, 155, 250])),\n    unusable_red=HSVRangeModel(h_s_v_min=np.array([0, 210, 110]), h_s_v_max=np.array([10, 255, 210])),\n)\n\nTAB_SLOTS_COORDS = {6: np.array([300, 284, 800, 102]), 7: np.array([240, 292, 930, 106])}\n\nPOSITIONS = (\n    (3840, 2160),\n    UiOffsetsModel(\n        find_bullet_points_width=150,\n        find_seperator_short_offset_top=500,\n        item_descr_line_height=50,\n        item_descr_off_bottom_edge=104,\n        item_descr_pad=30,\n        item_descr_width=780,\n        vendor_center_item_x=1232,\n    ),\n    UiPosModel(\n        possible_centers=[\n            (2994, 244),\n            (2994, 432),\n            (2994, 624),\n            (2994, 810),\n            (2994, 992),\n            (2994, 1196),\n            (3722, 428),\n            (3722, 618),\n            (3722, 808),\n            (3614, 1220),\n            (3730, 1220),\n            (3304, 1218),\n            (3416, 1218),\n        ],\n        window_dimensions=(3840, 2160),\n    ),\n    UiRoiModel(\n        rel_descr_search_left=np.array([-900, 0, 150, 1760]),\n        rel_descr_search_right=np.array([60, 0, 120, 1760]),\n        rel_fav_flag=np.array([8, 6, 16, 20]),\n        slots_8x1=np.array([1166, 165, 125, 1462]),\n        slots_3x11=np.array([2536, 1444, 1214, 486]),\n        slots_5x10=np.array([92, 538, 1224, 972]),\n        sort_icon=np.array([2440, 1332, 126, 124]),\n        stash_menu_icon=np.array([592, 144, 218, 96]),\n        tab_slots=TAB_SLOTS_COORDS[IniConfigLoader().general.max_stash_tabs],\n        vendor_menu_icon=np.array([182, 757, 220, 90]),\n    ),\n)\n\n\n@dataclass\nclass Template:\n    name: str = None\n    img_bgra: np.ndarray = None\n    img_bgr: np.ndarray = None\n    img_gray: np.ndarray = None\n    alpha_mask: np.ndarray = None\n\n\n@lru_cache\ndef load_templates() -> dict[str, Template]:\n    result = {}\n    template_paths = Path(BASE_DIR / \"assets/templates\").rglob(\"*.png\")\n    for template in template_paths:\n        try:\n            template_img = cv2.imread(str(template), cv2.IMREAD_UNCHANGED)\n        except cv2.error:\n            LOGGER.exception(f\"Could not load image: {template}\")\n            continue\n        result[template.stem.lower()] = Template(\n            name=template.stem.lower(),\n            img_bgra=template_img,\n            img_bgr=cv2.cvtColor(template_img, cv2.COLOR_BGRA2BGR),\n            img_gray=cv2.cvtColor(template_img, cv2.COLOR_BGRA2GRAY),\n            alpha_mask=alpha_to_mask(template_img),\n        )\n    return result\n"
  },
  {
    "path": "src/config/helper.py",
    "content": "import sys\nimport threading\n\nif sys.platform != \"darwin\":\n    import keyboard\n\n\ndef check_greater_than_zero(v: int) -> int:\n    if v < 0:\n        msg = \"must be greater than zero\"\n        raise ValueError(msg)\n    return v\n\n\ndef validate_percent(v: int) -> int:\n    check_greater_than_zero(v)\n    if v > 100:\n        msg = \"must be less than or equal to 100\"\n        raise ValueError(msg)\n    return v\n\n\ndef validate_hotkey(k: str) -> str:\n    keyboard.parse_hotkey(k)\n    return k\n\n\ndef singleton(cls):\n    instances = {}\n    lock = threading.Lock()\n\n    def get_instance(*args, **kwargs):\n        with lock:\n            if cls not in instances:\n                instances[cls] = cls(*args, **kwargs)\n        return instances[cls]\n\n    return get_instance\n\n\ndef str_to_int_list(s: str) -> list[int]:\n    if not s:\n        return []\n    return [int(x) for x in s.split(\",\")]\n"
  },
  {
    "path": "src/config/loader.py",
    "content": "\"\"\"Configuration loading, validation, persistence, and live change notifications.\"\"\"\n\nfrom __future__ import annotations\n\nimport configparser\nimport logging\nimport pathlib\nimport threading\nfrom collections.abc import Callable\nfrom pathlib import Path\nfrom typing import Any\n\nfrom src.config.helper import singleton\nfrom src.config.settings_models import AdvancedOptionsModel, CharModel, GeneralModel\n\nLOGGER = logging.getLogger(__name__)\nPARAMS_INI = \"params.ini\"\nMANUAL_RESTART_SETTING_KEYS = {\"general.vision_mode_type\"}\nConfigChangeListener = Callable[[frozenset[str]], None]\n\n\n@singleton\nclass IniConfigLoader:\n    \"\"\"Load, validate, persist, and broadcast config changes.\"\"\"\n\n    def __init__(self) -> None:\n        self._advanced_options = AdvancedOptionsModel()\n        self._char = CharModel()\n        self._general = GeneralModel()\n        self._parser: configparser.ConfigParser | None = None\n        self._user_dir = pathlib.Path.home() / \".d4lf\"\n        self._user_dir.mkdir(parents=True, exist_ok=True)\n        self._lock = threading.RLock()\n        self._change_listeners: list[ConfigChangeListener] = []\n        self._last_config_signature: tuple[int, int] | None = None\n        self._config_revision = 0\n        self._state_snapshot: dict[str, Any] = {}\n        self._deferred_cleanup_log_records: list[logging.LogRecord] = []\n        self._defer_cleanup_log_records = True\n        self.load(notify=False)\n\n    def _config_path(self) -> Path:\n        return self.user_dir / PARAMS_INI\n\n    def _get_config_signature(self) -> tuple[int, int] | None:\n        config_path = self._config_path()\n        if not config_path.exists():\n            return None\n\n        stat_result = config_path.stat()\n        return stat_result.st_mtime_ns, stat_result.st_size\n\n    def _section_models(self) -> dict[str, Any]:\n        return {\"advanced_options\": self._advanced_options, \"char\": self._char, \"general\": self._general}\n\n    def _model_for_section(self, section: str) -> Any | None:\n        return self._section_models().get(section)\n\n    def _capture_state_snapshot(self) -> dict[str, Any]:\n        snapshot: dict[str, Any] = {}\n        for section_name, model in self._section_models().items():\n            for key, value in model.model_dump(mode=\"python\").items():\n                snapshot[f\"{section_name}.{key}\"] = value\n        return snapshot\n\n    def _changed_keys(self, previous_snapshot: dict[str, Any], current_snapshot: dict[str, Any]) -> set[str]:\n        return {\n            key\n            for key in previous_snapshot.keys() | current_snapshot.keys()\n            if previous_snapshot.get(key) != current_snapshot.get(key)\n        }\n\n    def _write_parser(self) -> None:\n        if self._parser is None:\n            msg = \"Config parser has not been initialized\"\n            raise RuntimeError(msg)\n\n        with self._config_path().open(\"w\", encoding=\"utf-8\") as config_file:\n            self._parser.write(config_file)\n\n    def _remove_defunct_model_keys(self) -> bool:\n        if self._parser is None:\n            msg = \"Config parser has not been initialized\"\n            raise RuntimeError(msg)\n\n        removed_key = False\n        for section, model in self._section_models().items():\n            if section not in self._parser:\n                continue\n\n            valid_keys = type(model).model_fields\n            for key in list(self._parser[section]):\n                if key in valid_keys:\n                    continue\n\n                self._log_defunct_model_key(section, key)\n                self._parser.remove_option(section, key)\n                removed_key = True\n\n        return removed_key\n\n    def _log_defunct_model_key(self, section: str, key: str) -> None:\n        path_name, line_number, _, _ = LOGGER.findCaller(stacklevel=2)\n        record = LOGGER.makeRecord(\n            LOGGER.name,\n            logging.WARNING,\n            path_name,\n            line_number,\n            \"Deprecated key=%s found in [%s]. Removing it from %s.\",\n            (key, section, PARAMS_INI),\n            None,\n        )\n        if self._defer_cleanup_log_records:\n            self._deferred_cleanup_log_records.append(record)\n        if LOGGER.isEnabledFor(logging.WARNING):\n            LOGGER.handle(record)\n\n    def consume_deferred_cleanup_log_records(self) -> list[logging.LogRecord]:\n        with self._lock:\n            records = self._deferred_cleanup_log_records.copy()\n            self._deferred_cleanup_log_records.clear()\n            self._defer_cleanup_log_records = False\n            return records\n\n    def _format_value_for_log(self, value: Any) -> str:\n        if isinstance(value, bool):\n            return \"on\" if value else \"off\"\n        return str(value)\n\n    def _log_changed_values(self, changed_keys: set[str]) -> None:\n        if not changed_keys:\n            return\n\n        snapshot = self._state_snapshot.copy()\n        formatted_entries = [f\"{key}={self._format_value_for_log(snapshot.get(key))}\" for key in sorted(changed_keys)]\n        noun = \"change\" if len(formatted_entries) == 1 else \"changes\"\n        LOGGER.info(\"Applied setting %s: %s\", noun, \", \".join(formatted_entries))\n\n        if any(key in MANUAL_RESTART_SETTING_KEYS for key in changed_keys):\n            LOGGER.warning(\"Please restart d4lf manually to apply vision mode changes.\")\n\n    def _notify_listeners(self, changed_keys: set[str]) -> None:\n        if not changed_keys:\n            return\n\n        listeners = list(self._change_listeners)\n        frozen_keys = frozenset(changed_keys)\n        for listener in listeners:\n            try:\n                listener(frozen_keys)\n            except Exception:\n                LOGGER.exception(\"Failed to notify config listener\")\n\n    def register_change_listener(self, listener: ConfigChangeListener) -> None:\n        with self._lock:\n            if listener not in self._change_listeners:\n                self._change_listeners.append(listener)\n\n    def unregister_change_listener(self, listener: ConfigChangeListener) -> None:\n        with self._lock:\n            self._change_listeners = [existing for existing in self._change_listeners if existing != listener]\n\n    def register_listener(self, listener: ConfigChangeListener) -> None:\n        \"\"\"Backward-compatible alias for older call sites.\"\"\"\n        self.register_change_listener(listener)\n\n    def unregister_listener(self, listener: ConfigChangeListener) -> None:\n        \"\"\"Backward-compatible alias for older call sites.\"\"\"\n        self.unregister_change_listener(listener)\n\n    def load(self, clear: bool = False, notify: bool = True) -> None:\n        with self._lock:\n            previous_snapshot = self._state_snapshot.copy()\n            config_path = self._config_path()\n            if not config_path.exists() or clear:\n                config_path.write_text(\"\", encoding=\"utf-8\")\n\n            self._parser = configparser.ConfigParser()\n            self._parser.read(config_path, encoding=\"utf-8\")\n\n            defunct_keys_removed = self._remove_defunct_model_keys()\n            if defunct_keys_removed:\n                self._write_parser()\n\n            if \"advanced_options\" in self._parser:\n                self._advanced_options = AdvancedOptionsModel(**self._parser[\"advanced_options\"])\n            else:\n                self._advanced_options = AdvancedOptionsModel()\n\n            if \"char\" in self._parser:\n                self._char = CharModel(**self._parser[\"char\"])\n            else:\n                self._char = CharModel()\n\n            if \"general\" in self._parser:\n                self._general = GeneralModel(**self._parser[\"general\"])\n            else:\n                self._general = GeneralModel()\n\n            self._last_config_signature = self._get_config_signature()\n            self._config_revision += 1\n            self._state_snapshot = self._capture_state_snapshot()\n            changed_keys = self._changed_keys(previous_snapshot, self._state_snapshot)\n\n        if notify:\n            self._log_changed_values(changed_keys)\n            self._notify_listeners(changed_keys)\n\n    def reload_if_changed(self) -> bool:\n        with self._lock:\n            current_signature = self._get_config_signature()\n            if current_signature == self._last_config_signature:\n                return False\n\n        LOGGER.debug(\"Detected external params.ini change. Reloading configuration.\")\n        self.load(notify=True)\n        return True\n\n    @property\n    def advanced_options(self) -> AdvancedOptionsModel:\n        self.reload_if_changed()\n        return self._advanced_options\n\n    @property\n    def char(self) -> CharModel:\n        self.reload_if_changed()\n        return self._char\n\n    @property\n    def general(self) -> GeneralModel:\n        self.reload_if_changed()\n        return self._general\n\n    @property\n    def user_dir(self) -> Path:\n        return self._user_dir\n\n    @property\n    def config_revision(self) -> int:\n        with self._lock:\n            return self._config_revision\n\n    def save_value(self, section: str, key: str, value: Any) -> None:\n        changed_keys: set[str] = set()\n\n        with self._lock:\n            if self._parser is None:\n                self.load(notify=False)\n\n            previous_snapshot = self._state_snapshot.copy()\n            model = self._model_for_section(section)\n            if model is not None:\n                setattr(model, key, value)\n\n            if section not in self._parser.sections():\n                self._parser.add_section(section)\n\n            new_serialized_value = str(value)\n            old_serialized_value = self._parser.get(section, key, fallback=None)\n            if old_serialized_value == new_serialized_value:\n                return\n\n            self._parser.set(section, key, new_serialized_value)\n            self._write_parser()\n            self._last_config_signature = self._get_config_signature()\n            self._config_revision += 1\n            self._state_snapshot = self._capture_state_snapshot()\n            changed_keys = self._changed_keys(previous_snapshot, self._state_snapshot)\n\n        self._log_changed_values(changed_keys)\n        self._notify_listeners(changed_keys)\n\n\nif __name__ == \"__main__\":\n    loader = IniConfigLoader()\n    loader.load()\n"
  },
  {
    "path": "src/config/profile_models.py",
    "content": "\"\"\"New config loading and verification using pydantic. For now, both will exist in parallel hence _new.\"\"\"\n\nimport enum\nimport logging\nimport sys\n\nfrom pydantic import BaseModel, ConfigDict, RootModel, field_validator, model_validator\n\nfrom src.config.helper import check_greater_than_zero, validate_percent\nfrom src.item.data.item_type import ItemType  # noqa: TC001\nfrom src.item.data.rarity import ItemRarity\n\nMODULE_LOGGER = logging.getLogger(__name__)\n\n\ndef _parse_item_type_or_rarities(data: str | list[str]) -> list[str]:\n    if isinstance(data, str):\n        return [data]\n    return data\n\n\nclass AffixAspectFilterModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    name: str\n    value: float | None = None\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def parse_data(cls, data: str | list[str] | list[str | float] | dict[str, str | float]) -> dict[str, str | float]:\n        if isinstance(data, dict):\n            return data\n        if isinstance(data, str):\n            return {\"name\": data}\n        if isinstance(data, list):\n            if not data or len(data) > 2:\n                msg = \"list, cannot be empty or larger than 2 items\"\n                raise ValueError(msg)\n            result = {}\n            if len(data) >= 1:\n                result[\"name\"] = data[0]\n            if len(data) >= 2:\n                result[\"value\"] = data[1]\n            return result\n        msg = \"must be str or list\"\n        raise ValueError(msg)\n\n\nclass AffixFilterModel(AffixAspectFilterModel):\n    want_greater: bool = False\n    minPercentOfAffix: int = 0\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_must_exist(cls, name: str) -> str:\n        # This on module level would be a circular import, so we do it lazy for now\n        from src.dataloader import Dataloader  # noqa: PLC0415\n\n        if name not in Dataloader().affix_dict:\n            msg = f\"affix {name} does not exist\"\n            raise ValueError(msg)\n        return name\n\n    @field_validator(\"minPercentOfAffix\")\n    @classmethod\n    def percent_validator(cls, v: int) -> int:\n        return validate_percent(v)\n\n    @model_validator(mode=\"after\")\n    def value_and_percent_are_mutually_exclusive(self) -> AffixFilterModel:\n        if self.value and self.minPercentOfAffix:\n            msg = \"value and minPercentOfAffix cannot both be set\"\n            raise ValueError(msg)\n        return self\n\n\nclass AffixFilterCountModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    count: list[AffixFilterModel] = []\n    maxCount: int = sys.maxsize\n    minCount: int = 0\n\n    @field_validator(\"minCount\", \"maxCount\")\n    @classmethod\n    def count_validator(cls, v: int) -> int:\n        return check_greater_than_zero(v)\n\n    @model_validator(mode=\"after\")\n    def model_validator(self) -> AffixFilterCountModel:\n        # If minCount and maxCount are not set, we assume that the lengths of the count list is the only thing that matters.\n        # To not show up in the model.dict() we need to remove them from the model_fields_set property\n        if \"minCount\" not in self.model_fields_set and \"maxCount\" not in self.model_fields_set:\n            self.minCount = len(self.count)\n            self.maxCount = len(self.count)\n            self.model_fields_set.remove(\"minCount\")\n            self.model_fields_set.remove(\"maxCount\")\n        if self.minCount > self.maxCount:\n            msg = \"minCount must be smaller than maxCount\"\n            raise ValueError(msg)\n        if not self.count:\n            msg = \"count must not be empty\"\n            raise ValueError(msg)\n        return self\n\n\nclass AspectUniqueFilterModel(AffixAspectFilterModel):\n    minPercentOfAspect: int = 0\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_must_exist(cls, name: str) -> str:\n        # This on module level would be a circular import, so we do it lazy for now\n        from src.dataloader import Dataloader  # noqa: PLC0415\n\n        # Ensure name is in format we expect\n        name = name.lower().replace(\"'\", \"\").replace(\" \", \"_\").replace(\",\", \"\")\n\n        if name not in Dataloader().aspect_unique_dict:\n            msg = f\"aspect {name} does not exist\"\n            raise ValueError(msg)\n        return name\n\n    @field_validator(\"minPercentOfAspect\")\n    @classmethod\n    def percent_validator(cls, v: int) -> int:\n        return validate_percent(v)\n\n    @model_validator(mode=\"after\")\n    def value_and_percent_are_mutually_exclusive(self) -> AspectUniqueFilterModel:\n        if self.value and self.minPercentOfAspect:\n            msg = \"value and minPercentOfAspect cannot both be set\"\n            raise ValueError(msg)\n        return self\n\n\nclass GlobalUniqueModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    profileAlias: str = \"\"\n    minGreaterAffixCount: int = 0\n    minPercentOfAspect: int = 0\n    minPower: int = 0\n\n    @field_validator(\"minPower\")\n    @classmethod\n    def check_min_power(cls, v: int) -> int:\n        return check_greater_than_zero(v)\n\n    @field_validator(\"minGreaterAffixCount\")\n    @classmethod\n    def count_validator(cls, v: int) -> int:\n        if not 0 <= v <= 4:\n            msg = \"must be in [0, 4]\"\n            raise ValueError(msg)\n        return v\n\n    @field_validator(\"minPercentOfAspect\")\n    @classmethod\n    def percent_validator(cls, v: int) -> int:\n        return validate_percent(v)\n\n\nclass ItemFilterModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    affixPool: list[AffixFilterCountModel] = []\n    inherentPool: list[AffixFilterCountModel] = []\n    itemType: list[ItemType] = []\n    minGreaterAffixCount: int = 0\n    minPower: int = 0\n    uniqueAspect: AspectUniqueFilterModel = None\n\n    @field_validator(\"minPower\")\n    @classmethod\n    def check_min_power(cls, v: int) -> int:\n        return check_greater_than_zero(v)\n\n    @field_validator(\"minGreaterAffixCount\")\n    @classmethod\n    def min_greater_affix_in_range(cls, v: int) -> int:\n        if not 0 <= v <= 4:\n            msg = \"must be in [0, 4]\"\n            raise ValueError(msg)\n        return v\n\n    @field_validator(\"itemType\", mode=\"before\")\n    @classmethod\n    def parse_item_type(cls, data: str | list[str]) -> list[str]:\n        return _parse_item_type_or_rarities(data)\n\n\nDynamicItemFilterModel = RootModel[dict[str, ItemFilterModel]]\n\n\nclass SigilPriority(enum.StrEnum):\n    blacklist = enum.auto()\n    whitelist = enum.auto()\n\n\nclass SigilConditionModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    name: str\n    condition: list[str] = []\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def parse_data(cls, data: str | list[str] | list[str | float] | dict[str, str | float]) -> dict[str, str | float]:\n        if isinstance(data, dict):\n            return data\n        if isinstance(data, str):\n            return {\"name\": data}\n        if isinstance(data, list):\n            if not data:\n                msg = \"list cannot be empty\"\n                raise ValueError(msg)\n            result = {}\n            if len(data) >= 1:\n                result[\"name\"] = data[0]\n            if len(data) >= 2:\n                result[\"condition\"] = data[1:]\n            return result\n        msg = \"must be str or list\"\n        raise ValueError(msg)\n\n    @field_validator(\"condition\", \"name\")\n    @classmethod\n    def name_must_exist(cls, names_in: str | list[str]) -> str | list[str]:\n        # This on module level would be a circular import, so we do it lazy for now\n        from src.dataloader import Dataloader  # noqa: PLC0415\n\n        names = [names_in] if isinstance(names_in, str) else names_in\n        errors = [name for name in names if name not in Dataloader().affix_sigil_dict]\n        if errors:\n            msg = f\"The following affixes/dungeons do not exist: {errors}\"\n            raise ValueError(msg)\n        return names_in\n\n\nclass SigilFilterModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    blacklist: list[SigilConditionModel] = []\n    priority: SigilPriority = SigilPriority.blacklist\n    whitelist: list[SigilConditionModel] = []\n\n    @model_validator(mode=\"after\")\n    def data_integrity(self) -> SigilFilterModel:\n        errors = [item for item in self.blacklist if item in self.whitelist]\n        if errors:\n            msg = f\"blacklist and whitelist must not overlap: {errors}\"\n            raise ValueError(msg)\n        return self\n\n\nclass TributeFilterModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    name: str = None\n    rarities: list[ItemRarity] = []\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_must_exist(cls, name: str) -> str:\n        # This on module level would be a circular import, so we do it lazy for now\n        from src.dataloader import Dataloader  # noqa: PLC0415\n\n        if not name:\n            return name\n\n        tribute_dict = Dataloader().tribute_dict\n        # Allow people to shorthand and leave off \"tribute_of_\"\n        name_with_tribute = \"tribute_of_\" + name\n        if name not in tribute_dict and name_with_tribute not in tribute_dict:\n            msg = f\"No tribute named {name} or {name_with_tribute} exists\"\n            raise ValueError(msg)\n\n        if name_with_tribute in tribute_dict:\n            name = name_with_tribute\n\n        return name\n\n    @model_validator(mode=\"before\")\n    @classmethod\n    def parse_data(cls, data: str | list[str] | dict[str, str | list[str]]) -> dict[str, str | list[str]]:\n        if isinstance(data, dict):\n            return data\n        if isinstance(data, str):\n            if any(rarity.value.lower() == data.lower() for rarity in ItemRarity):\n                return {\"rarities\": [data]}\n            return {\"name\": data}\n        if isinstance(data, list):\n            if not data:\n                msg = \"list cannot be empty\"\n                raise ValueError(msg)\n            return {\"rarities\": data}\n        msg = \"must be str or list\"\n        raise ValueError(msg)\n\n    @field_validator(\"rarities\", mode=\"before\")\n    @classmethod\n    def parse_rarities(cls, data: str | list[str]) -> list[str]:\n        return _parse_item_type_or_rarities(data)\n\n\nclass ProfileModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    Affixes: list[DynamicItemFilterModel] = []\n    AspectUpgrades: list[str] = []\n    GlobalUniques: list[GlobalUniqueModel] = []\n    name: str\n    Sigils: SigilFilterModel = SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist)\n    Tributes: list[TributeFilterModel] = []\n    Paragon: dict[str, object] | list[dict[str, object]] | None = None\n\n    @model_validator(mode=\"before\")\n    def aspects_must_exist(self) -> ProfileModel:\n        # This on module level would be a circular import, so we do it lazy for now\n        from src.dataloader import Dataloader  # noqa: PLC0415\n\n        if \"AspectUpgrades\" not in self:\n            return self\n\n        all_aspects_list = Dataloader().aspect_list\n        aspects_not_in_all_aspects = [x for x in self[\"AspectUpgrades\"] if x not in all_aspects_list]\n        if aspects_not_in_all_aspects:\n            msg = f\"The following aspects in AspectUpgrades do not exist in our data: {', '.join(aspects_not_in_all_aspects)}\"\n            raise ValueError(msg)\n\n        return self\n"
  },
  {
    "path": "src/config/settings_models.py",
    "content": "import enum\nimport logging\nfrom typing import TYPE_CHECKING, Any\n\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator\nfrom pydantic_numpy import np_array_pydantic_annotated_typing  # noqa: TC002\nfrom pydantic_numpy.model import NumpyModel\n\nfrom src.config.helper import check_greater_than_zero, validate_hotkey\n\nif TYPE_CHECKING:\n    import numpy as np\n\nMODULE_LOGGER = logging.getLogger(__name__)\nHIDE_FROM_GUI_KEY = \"hide_from_gui\"\nIS_HOTKEY_KEY = \"is_hotkey\"\nLIVE_RELOAD_GROUP_KEY = \"live_reload_group\"\n\n\nclass AspectFilterType(enum.StrEnum):\n    all = enum.auto()\n    none = enum.auto()\n    upgrade = enum.auto()\n\n\nclass BrowserType(enum.StrEnum):\n    edge = enum.auto()\n    chrome = enum.auto()\n    firefox = enum.auto()\n\n\nclass CosmeticFilterType(enum.StrEnum):\n    junk = enum.auto()\n    ignore = enum.auto()\n\n\nclass ItemRefreshType(enum.StrEnum):\n    force_with_filter = enum.auto()\n    force_without_filter = enum.auto()\n    no_refresh = enum.auto()\n\n\nclass LogLevels(enum.StrEnum):\n    debug = enum.auto()\n    info = enum.auto()\n    warning = enum.auto()\n    error = enum.auto()\n    critical = enum.auto()\n\n\nclass MoveItemsType(enum.StrEnum):\n    everything = enum.auto()\n    favorites = enum.auto()\n    junk = enum.auto()\n    unmarked = enum.auto()\n\n\nclass JunkRaresType(enum.StrEnum):\n    disabled = \"disabled\"\n    three_affixes = \"3 affixes\"\n    all = \"all\"\n\n\nclass ThemeType(enum.StrEnum):\n    dark = enum.auto()\n    light = enum.auto()\n\n\nclass UnfilteredUniquesType(enum.StrEnum):\n    favorite = enum.auto()\n    ignore = enum.auto()\n    junk = enum.auto()\n\n\nclass VisionModeType(enum.StrEnum):\n    highlight_matches = enum.auto()\n    fast = enum.auto()\n\n\nclass _IniBaseModel(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\", str_strip_whitespace=True, validate_assignment=True)\n\n\nclass AdvancedOptionsModel(_IniBaseModel):\n    disable_tts_warning: bool = Field(\n        default=False,\n        description=\"If TTS is working for you but you are still receiving the warning, check this box to disable it.\",\n    )\n    exit_key: str = Field(default=\"f12\", description=\"Hotkey to exit d4lf\", json_schema_extra={IS_HOTKEY_KEY: \"True\"})\n    fast_vision_mode_coordinates: tuple[int, int] | None = Field(\n        default=None,\n        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.\",\n    )\n    force_refresh_only: str = Field(\n        default=\"ctrl+shift+f11\",\n        description=\"Hotkey to refresh the junk/favorite status of all items in your inventory/stash. A filter is not run after.\",\n        json_schema_extra={IS_HOTKEY_KEY: \"True\"},\n    )\n    log_lvl: LogLevels = Field(\n        default=LogLevels.info,\n        description=\"The level at which logs are written\",\n        json_schema_extra={LIVE_RELOAD_GROUP_KEY: \"log_level\"},\n    )\n    move_to_chest: str = Field(\n        default=\"f8\",\n        description=\"Hotkey to move configured items from inventory to stash\",\n        json_schema_extra={IS_HOTKEY_KEY: \"True\"},\n    )\n    move_to_inv: str = Field(\n        default=\"f7\",\n        description=\"Hotkey to move configured items from stash to inventory\",\n        json_schema_extra={IS_HOTKEY_KEY: \"True\"},\n    )\n    process_name: str = Field(\n        default=\"Diablo IV.exe\",\n        description=\"The process that is running Diablo 4. Could help usage when playing through a streaming service like GeForce Now\",\n    )\n    run_filter: str = Field(\n        default=\"f11\",\n        description=\"Hotkey to run the filter process. If the item matches no profiles, it is marked as junk.\",\n        json_schema_extra={IS_HOTKEY_KEY: \"True\"},\n    )\n    run_filter_drop: str = Field(\n        default=\"ctrl+f11\",\n        description=\"Hotkey to run the filter process. If the item matches no profiles, it is dropped.\",\n        json_schema_extra={IS_HOTKEY_KEY: \"True\"},\n    )\n    run_filter_force_refresh: str = Field(\n        default=\"shift+f11\",\n        description=\"Hotkey to run the filter process with a force refresh. The status of all junk/favorite items will be reset\",\n        json_schema_extra={IS_HOTKEY_KEY: \"True\"},\n    )\n    run_vision_mode: str = Field(\n        default=\"f9\", description=\"Hotkey to enable/disable the vision mode\", json_schema_extra={IS_HOTKEY_KEY: \"True\"}\n    )\n    toggle_paragon_overlay: str = Field(\n        default=\"f10\", description=\"Hotkey to open/close the Paragon overlay\", json_schema_extra={IS_HOTKEY_KEY: \"True\"}\n    )\n    vision_mode_only: bool = Field(\n        default=False,\n        description=\"Only allow vision mode to run. All hotkeys and actions that click will be disabled.\",\n        json_schema_extra={LIVE_RELOAD_GROUP_KEY: \"hotkeys\"},\n    )\n\n    @model_validator(mode=\"after\")\n    def key_must_be_unique(self) -> AdvancedOptionsModel:\n        keys = [\n            self.exit_key,\n            self.toggle_paragon_overlay,\n            self.force_refresh_only,\n            self.move_to_chest,\n            self.move_to_inv,\n            self.run_filter,\n            self.run_filter_drop,\n            self.run_filter_force_refresh,\n            self.run_vision_mode,\n        ]\n        if len(set(keys)) != len(keys):\n            msg = \"hotkeys must be unique\"\n            raise ValueError(msg)\n        return self\n\n    @field_validator(\n        \"exit_key\",\n        \"toggle_paragon_overlay\",\n        \"force_refresh_only\",\n        \"move_to_chest\",\n        \"move_to_inv\",\n        \"run_filter\",\n        \"run_filter_drop\",\n        \"run_filter_force_refresh\",\n        \"run_vision_mode\",\n    )\n    @classmethod\n    def key_must_exist(cls, k: str) -> str:\n        return validate_hotkey(k)\n\n    @field_validator(\"fast_vision_mode_coordinates\", mode=\"before\")\n    @classmethod\n    def convert_fast_vision_mode_coordinates(cls, v: str) -> tuple[int, int] | None:\n        if not v:\n            return None\n        if isinstance(v, str):\n            v = v.strip(\"()\")\n            parts = [int(part.strip()) for part in v.replace(\",\", \" \").split()]\n            if len(parts) != 2:\n                msg = \"Expected two integers for coordinates.\"\n                raise ValueError(msg)\n            for x in parts:\n                check_greater_than_zero(x)\n            return parts[0], parts[1]\n        if isinstance(v, tuple) and len(v) == 2 and all(isinstance(x, int) for x in v):\n            for x in v:\n                check_greater_than_zero(x)\n            return v[0], v[1]\n        msg = \"vision_mode_coordinates must be a tuple of two integers or blank\"\n        raise ValueError(msg)\n\n\nclass CharModel(_IniBaseModel):\n    inventory: str = Field(\n        default=\"i\", description=\"Hotkey in Diablo IV to open inventory\", json_schema_extra={IS_HOTKEY_KEY: \"True\"}\n    )\n\n    @field_validator(\"inventory\")\n    @classmethod\n    def key_must_exist(cls, k: str) -> str:\n        return validate_hotkey(k)\n\n\nclass ColorsModel(_IniBaseModel):\n    material_color: HSVRangeModel\n    unique_gold: HSVRangeModel\n    unusable_red: HSVRangeModel\n\n\nclass GeneralModel(_IniBaseModel):\n    auto_use_temper_manuals: bool = Field(\n        default=True,\n        description=\"When using the loot filter, should found temper manuals be automatically used? Note: Will not work with stash open.\",\n    )\n    browser: BrowserType = Field(default=BrowserType.chrome, description=\"Which browser to use to get builds\")\n    check_chest_tabs: list[int] = Field(\n        default=[0, 1], description=\"Which stash tabs to check. Note: All tabs available (6 or 7) must be unlocked!\"\n    )\n    do_not_junk_ancestral_legendaries: bool = Field(\n        default=False, description=\"Do not mark ancestral legendaries as junk for seasonal challenge\"\n    )\n    full_dump: bool = Field(\n        default=False,\n        description=\"When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not\",\n    )\n    handle_cosmetics: CosmeticFilterType = Field(\n        default=CosmeticFilterType.ignore,\n        description=\"What should be done with cosmetic upgrades that do not match any filter\",\n    )\n    handle_uniques: UnfilteredUniquesType = Field(\n        default=UnfilteredUniquesType.favorite,\n        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.\",\n    )\n    ignore_escalation_sigils: bool = Field(\n        default=True, description=\"When filtering Sigils, should escalation sigils be ignored?\"\n    )\n    keep_aspects: AspectFilterType = Field(\n        default=AspectFilterType.upgrade, description=\"Whether to keep aspects that didn't match a filter\"\n    )\n    language: str = Field(\n        default=\"enUS\",\n        description=\"Do not change. Only English is supported at this time\",\n        json_schema_extra={HIDE_FROM_GUI_KEY: \"True\", LIVE_RELOAD_GROUP_KEY: \"language\"},\n    )\n    mark_as_favorite: bool = Field(default=True, description=\"Whether to favorite matched items or not\")\n    max_stash_tabs: int = Field(\n        default=6,\n        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.\",\n    )\n    minimum_overlay_font_size: int = Field(\n        default=12,\n        description=\"The minimum font size for the vision overlay, specifically the green text that shows which filter(s) are matching.\",\n    )\n    move_to_inv_item_type: list[MoveItemsType] = Field(\n        default=[MoveItemsType.everything],\n        description=\"When doing stash/inventory transfer, what types of items should be moved\",\n    )\n    move_to_stash_item_type: list[MoveItemsType] = Field(\n        default=[MoveItemsType.everything],\n        description=\"When doing stash/inventory transfer, what types of items should be moved\",\n    )\n    profiles: list[str] = Field(\n        default=[],\n        description='Which filter profiles should be run. All .yaml files with \"AspectUpgrades\", '\n        '\"Affixes\", \"Uniques\", \"Sigils\", etc sections will be used from '\n        \"C:/Users/USERNAME/.d4lf/profiles/*.yaml\",\n    )\n    run_vision_mode_on_startup: bool = Field(default=True, description=\"Whether to run vision mode on startup or not\")\n    theme: ThemeType = Field(default=ThemeType.dark, description=\"Choose between light and dark theme for the GUI\")\n    colorblind_mode: bool = Field(\n        default=False, description=\"Enable a colorblind friendly palette for loot filter and paragon overlays\"\n    )\n    vision_mode_type: VisionModeType = Field(\n        default=VisionModeType.highlight_matches,\n        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.\",\n        json_schema_extra={LIVE_RELOAD_GROUP_KEY: \"restart_app\"},\n    )\n\n    @field_validator(\"check_chest_tabs\", mode=\"before\")\n    @classmethod\n    def check_chest_tabs_index(cls, v: str) -> list[int]:\n        if isinstance(v, str):\n            v = v.split(\",\")\n        elif not isinstance(v, list):\n            msg = \"must be a list or a string\"\n            raise ValueError(msg)\n        return sorted([int(x) - 1 for x in v])\n\n    @field_validator(\"max_stash_tabs\")\n    @classmethod\n    def check_max_stash_tabs(cls, v: int) -> int:\n        if not 6 <= v <= 7:\n            msg = \"must be 6 or 7\"\n            raise ValueError(msg)\n        return v\n\n    @field_validator(\"profiles\", mode=\"before\")\n    @classmethod\n    def check_profiles_is_list(cls, v: str) -> list[str]:\n        if isinstance(v, str):\n            v = v.split(\",\")\n        elif not isinstance(v, list):\n            msg = \"must be a list or a string\"\n            raise ValueError(msg)\n        return [profile_name for profile_name in (item.strip() for item in v) if profile_name]\n\n    @field_validator(\"language\")\n    @classmethod\n    def language_must_exist(cls, v: str) -> str:\n        if v not in [\"enUS\"]:\n            msg = \"language not supported\"\n            raise ValueError(msg)\n        return v\n\n    @field_validator(\"minimum_overlay_font_size\")\n    @classmethod\n    def font_size_in_range(cls, v: int) -> int:\n        if not 10 <= v <= 20:\n            msg = \"Font size must be between 10 and 20, inclusive\"\n            raise ValueError(msg)\n        return v\n\n    @field_validator(\"move_to_inv_item_type\", \"move_to_stash_item_type\", mode=\"before\")\n    @classmethod\n    def convert_move_item_type(cls, v: str) -> list[type[MoveItemsType[Any]]]:\n        if isinstance(v, str):\n            v = v.split(\",\")\n        elif not isinstance(v, list):\n            msg = \"must be a list or a string\"\n            raise ValueError(msg)\n        return [MoveItemsType[v.strip()] for v in v]\n\n\nclass HSVRangeModel(_IniBaseModel):\n    h_s_v_min: np_array_pydantic_annotated_typing(dimensions=1)\n    h_s_v_max: np_array_pydantic_annotated_typing(dimensions=1)\n\n    def __getitem__(self, index):\n        # TODO added this to not have to change much of the other code. should be fixed some time\n        if index == 0:\n            return self.h_s_v_min\n        if index == 1:\n            return self.h_s_v_max\n        msg = \"Index out of range\"\n        raise IndexError(msg)\n\n    @model_validator(mode=\"after\")\n    def check_interval_sanity(self) -> HSVRangeModel:\n        if self.h_s_v_min[0] > self.h_s_v_max[0]:\n            msg = f\"invalid hue range [{self.h_s_v_min[0]}, {self.h_s_v_max[0]}]\"\n            raise ValueError(msg)\n        if self.h_s_v_min[1] > self.h_s_v_max[1]:\n            msg = f\"invalid saturation range [{self.h_s_v_min[1]}, {self.h_s_v_max[1]}]\"\n            raise ValueError(msg)\n        if self.h_s_v_min[2] > self.h_s_v_max[2]:\n            msg = f\"invalid value range [{self.h_s_v_min[2]}, {self.h_s_v_max[2]}]\"\n            raise ValueError(msg)\n        return self\n\n    @field_validator(\"h_s_v_min\", \"h_s_v_max\")\n    @classmethod\n    def values_in_range(cls, v: np.ndarray) -> np.ndarray:\n        if len(v) != 3:\n            msg = \"must be h,s,v\"\n            raise ValueError(msg)\n        if not -179 <= v[0] <= 179:\n            msg = \"must be in [-179, 179]\"\n            raise ValueError(msg)\n        if not all(0 <= x <= 255 for x in v[1:3]):\n            msg = \"must be in [0, 255]\"\n            raise ValueError(msg)\n        return v\n\n\nclass UiOffsetsModel(_IniBaseModel):\n    find_bullet_points_width: int\n    find_seperator_short_offset_top: int\n    item_descr_line_height: int\n    item_descr_off_bottom_edge: int\n    item_descr_pad: int\n    item_descr_width: int\n    vendor_center_item_x: int\n\n\nclass UiPosModel(_IniBaseModel):\n    possible_centers: list[tuple[int, int]]\n    window_dimensions: tuple[int, int]\n\n\nclass UiRoiModel(NumpyModel):\n    rel_descr_search_left: np_array_pydantic_annotated_typing(dimensions=1)\n    rel_descr_search_right: np_array_pydantic_annotated_typing(dimensions=1)\n    rel_fav_flag: np_array_pydantic_annotated_typing(dimensions=1)\n    slots_8x1: np_array_pydantic_annotated_typing(dimensions=1)\n    slots_3x11: np_array_pydantic_annotated_typing(dimensions=1)\n    slots_5x10: np_array_pydantic_annotated_typing(dimensions=1)\n    sort_icon: np_array_pydantic_annotated_typing(dimensions=1)\n    stash_menu_icon: np_array_pydantic_annotated_typing(dimensions=1)\n    tab_slots: np_array_pydantic_annotated_typing(dimensions=1)\n    vendor_menu_icon: np_array_pydantic_annotated_typing(dimensions=1)\n"
  },
  {
    "path": "src/config/ui.py",
    "content": "import logging\n\nimport cv2\nimport numpy as np\n\nfrom src.config.data import POSITIONS, Template, load_templates\nfrom src.config.helper import singleton\nfrom src.config.settings_models import UiOffsetsModel, UiPosModel, UiRoiModel\n\nLOGGER = logging.getLogger(\"d4lf\")\n\n\nclass _ResTransformer:\n    def __init__(self, resolution: str):\n        self._target_width, self._target_height = map(int, resolution.split(\"x\"))\n        self._scale_x = self._target_width / POSITIONS[0][0]\n        self._scale_y = self._target_height / POSITIONS[0][1]\n        self._highest_ratio = 27 / 9\n\n    def _resize_image(self, src: np.ndarray) -> np.ndarray:\n        height, width = src.shape[:2]\n        return cv2.resize(src=src, dsize=(int(width * self._scale_y), int(height * self._scale_y)))\n\n    def _transform(self, value: int) -> int:\n        return int(value * self._scale_y)\n\n    def _transform_array(self, value: np.ndarray, scale_only=False) -> np.ndarray:\n        new_value = value * self._scale_y\n        if scale_only:\n            return new_value.astype(int)\n\n        # handle widescreen stretching\n        width_org = int(self._scale_y * POSITIONS[0][0])\n        is_right_side = new_value[0] > width_org / 2\n        if is_right_side:\n            new_value[0] += self._target_width - width_org\n\n        # handle black bars\n        aspect_ratio = self._target_width / self._target_height\n        if aspect_ratio > self._highest_ratio:\n            new_width = int(self._target_height * self._highest_ratio)\n            black_bar = (self._target_width - new_width) // 2\n            new_value[0] = new_value[0] - black_bar if is_right_side else new_value[0] + black_bar\n\n        return new_value.astype(int)\n\n    def _transform_list_of_tuples(self, value: list[tuple[int, int]]) -> list[tuple[int, int]]:\n        return [self._transform_tuples(value=v) for v in value]\n\n    def _transform_templates(self, templates: dict[str, Template]) -> dict[str, Template]:\n        result = {}\n        for key, value in templates.items():\n            if key.endswith(\"_special\"):  # do not transform templates that end with _special\n                result[key] = value\n            else:\n                result[key] = Template(\n                    name=value.name,\n                    img_bgra=self._resize_image(src=value.img_bgra),\n                    img_bgr=self._resize_image(src=value.img_bgr),\n                    img_gray=self._resize_image(src=value.img_gray),\n                    alpha_mask=self._resize_image(src=value.alpha_mask) if value.alpha_mask is not None else None,\n                )\n        return result\n\n    def _transform_tuples(self, value: tuple[int, int]) -> tuple[int, int]:\n        values = self._transform_array(value=np.array(value, dtype=int))\n        return int(values[0]), int(values[1])\n\n    def fromUHD(self) -> tuple[UiOffsetsModel, UiPosModel, UiRoiModel, dict[str, Template]]:\n        offsets = UiOffsetsModel(\n            find_bullet_points_width=self._transform(value=POSITIONS[1].find_bullet_points_width),\n            find_seperator_short_offset_top=self._transform(value=POSITIONS[1].find_seperator_short_offset_top),\n            item_descr_line_height=self._transform(value=POSITIONS[1].item_descr_line_height),\n            item_descr_off_bottom_edge=self._transform(value=POSITIONS[1].item_descr_off_bottom_edge),\n            item_descr_pad=self._transform(value=POSITIONS[1].item_descr_pad),\n            item_descr_width=self._transform(value=POSITIONS[1].item_descr_width),\n            vendor_center_item_x=self._transform(value=POSITIONS[1].vendor_center_item_x),\n        )\n        pos = UiPosModel(\n            possible_centers=self._transform_list_of_tuples(value=POSITIONS[2].possible_centers),\n            window_dimensions=self._transform_tuples(value=POSITIONS[2].window_dimensions),\n        )\n        roi = UiRoiModel(\n            rel_descr_search_left=self._transform_array(value=POSITIONS[3].rel_descr_search_left, scale_only=True),\n            rel_descr_search_right=self._transform_array(value=POSITIONS[3].rel_descr_search_right, scale_only=True),\n            rel_fav_flag=self._transform_array(value=POSITIONS[3].rel_fav_flag, scale_only=True),\n            slots_8x1=self._transform_array(value=POSITIONS[3].slots_8x1),\n            slots_3x11=self._transform_array(value=POSITIONS[3].slots_3x11),\n            slots_5x10=self._transform_array(value=POSITIONS[3].slots_5x10),\n            sort_icon=self._transform_array(value=POSITIONS[3].sort_icon),\n            stash_menu_icon=self._transform_array(value=POSITIONS[3].stash_menu_icon),\n            tab_slots=self._transform_array(value=POSITIONS[3].tab_slots),\n            vendor_menu_icon=self._transform_array(value=POSITIONS[3].vendor_menu_icon),\n        )\n        templates = self._transform_templates(load_templates())\n        return offsets, pos, roi, templates\n\n\n@singleton\nclass ResManager:\n    def __init__(self):\n        self._current_resolution = \"3840x2160\"\n        self._offsets = POSITIONS[1]\n        self._pos = POSITIONS[2]\n        self._roi = POSITIONS[3]\n        self._templates = load_templates()\n\n    @property\n    def offsets(self) -> UiOffsetsModel:\n        return self._offsets\n\n    @property\n    def pos(self) -> UiPosModel:\n        return self._pos\n\n    @property\n    def resolution(self) -> tuple[int, ...]:\n        return tuple(map(int, self._current_resolution.split(\"x\")))\n\n    @property\n    def roi(self) -> UiRoiModel:\n        return self._roi\n\n    @property\n    def templates(self) -> dict[str, Template]:\n        return self._templates\n\n    def set_resolution(self, res: str):\n        if res == self._current_resolution:\n            return\n        self._current_resolution = res\n        LOGGER.info(f\"Setting ui resolution to {res}\")\n        self._offsets, self._pos, self._roi, self._templates = _ResTransformer(resolution=res).fromUHD()\n"
  },
  {
    "path": "src/dataloader.py",
    "content": "import json\nimport logging\nimport pathlib\nimport threading\n\nfrom src.config import BASE_DIR\nfrom src.config.loader import IniConfigLoader\nfrom src.item.data.item_type import ItemType\n\nLOGGER = logging.getLogger(__name__)\n\nDATALOADER_LOCK = threading.Lock()\n\n\nclass Dataloader:\n    affix_dict = {}\n    affix_sigil_dict = {}\n    affix_sigil_dict_all = {}\n    aspect_list = []\n    aspect_unique_dict = {}\n    bad_tts_uniques = {}\n    error_map = {}\n    filter_after_keyword = []\n    filter_words = []\n    item_types_dict = {}\n    tooltips = {}\n    tribute_dict = {}\n\n    _instance = None\n    data_loaded = False\n\n    def __new__(cls):\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n            with DATALOADER_LOCK:\n                if not cls._instance.data_loaded:\n                    cls._instance.data_loaded = True\n                    cls._instance.load_data()\n        return cls._instance\n\n    def load_data(self):\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/affixes.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            self.affix_dict: dict = json.load(f)\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/aspects.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            self.aspect_list = json.load(f)\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/corrections.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            data = json.load(f)\n            self.error_map = data[\"error_map\"]\n            self.filter_after_keyword = data[\"filter_after_keyword\"]\n            self.filter_words = data[\"filter_words\"]\n            self.bad_tts_uniques = data[\"bad_tts_uniques\"]\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/item_types.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            data = json.load(f)\n            self.item_types_dict = data\n            for item, value in data.items():\n                if item in ItemType.__members__:\n                    enum_member = ItemType[item]\n                    enum_member._value_ = value\n                else:\n                    LOGGER.warning(f\"{item} type not in item_type.py\")\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/sigils.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            self.affix_sigil_dict_all = json.load(f)\n            self.affix_sigil_dict = {\n                **self.affix_sigil_dict_all[\"dungeons\"],\n                **self.affix_sigil_dict_all[\"minor\"],\n                **self.affix_sigil_dict_all[\"major\"],\n                **self.affix_sigil_dict_all[\"positive\"],\n            }\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/tributes.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            self.tribute_dict: dict = json.load(f)\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/tooltips.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            self.tooltips = json.load(f)\n\n        with pathlib.Path(BASE_DIR / f\"assets/lang/{IniConfigLoader().general.language}/uniques.json\").open(\n            encoding=\"utf-8\"\n        ) as f:\n            self.aspect_unique_dict = json.load(f)\n"
  },
  {
    "path": "src/gui/__init__.py",
    "content": ""
  },
  {
    "path": "src/gui/activity_log_widget.py",
    "content": "from PyQt6.QtCore import Qt, QUrl\nfrom PyQt6.QtGui import QDesktopServices\nfrom PyQt6.QtWidgets import QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget\n\nfrom src.config.loader import IniConfigLoader\n\n\nclass ActivityLogWidget(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setContentsMargins(10, 10, 10, 10)\n        self.main_layout.setSpacing(10)\n\n        # === LOG VIEWER ===\n        self.log_viewer = QPlainTextEdit()\n        self.log_viewer.setReadOnly(True)\n        self.log_viewer.setMaximumBlockCount(1000)\n        self.log_viewer.setPlaceholderText(\"Waiting for d4lf to start scanning...\")\n\n        self.log_viewer.appendPlainText(\"═\" * 80)\n        self.log_viewer.appendPlainText(\"D4LF - Diablo 4 Loot Filter\")\n        self.log_viewer.appendPlainText(\"═\" * 80)\n        self.log_viewer.appendPlainText(\"\")\n\n        self.main_layout.addWidget(self.log_viewer, stretch=1)\n\n        # === HOTKEYS PANEL ===\n        hotkeys_label = QLabel(\"Hotkeys:\")\n        hotkeys_label.setStyleSheet(\"font-weight: bold; margin-top: 10px;\")\n        self.main_layout.addWidget(hotkeys_label)\n\n        config = IniConfigLoader()\n\n        hotkey_text = QLabel()\n        hotkey_text.setMaximumHeight(105)\n        hotkey_text.setWordWrap(True)\n        hotkey_text.setTextFormat(Qt.TextFormat.RichText)\n        hotkey_text.setStyleSheet(\"margin-left: 5px;\")\n\n        hotkeys_html = \"<div style='font-size: 9pt; line-height: 1.5; font-weight: normal;'>\"\n\n        if not config.advanced_options.vision_mode_only:\n            hotkeys_html += f\"<u><b>{config.advanced_options.run_vision_mode.upper()}</b></u>: Run/Stop Vision Mode&nbsp;&nbsp;&nbsp;\"\n            hotkeys_html += (\n                f\"<u><b>{config.advanced_options.run_filter.upper()}</b></u>: Run/Stop Auto Filter&nbsp;&nbsp;&nbsp;\"\n            )\n            hotkeys_html += f\"<u><b>{config.advanced_options.run_filter_drop.upper()}</b></u>: Run/Stop Auto Filter with Item Drop&nbsp;&nbsp;&nbsp;\"\n            hotkeys_html += (\n                f\"<u><b>{config.advanced_options.move_to_inv.upper()}</b></u>: Move Chest → Inventory&nbsp;&nbsp;&nbsp;\"\n            )\n            hotkeys_html += f\"<u><b>{config.advanced_options.move_to_chest.upper()}</b></u>: Move Inventory → Chest<br>\"\n            hotkeys_html += f\"<u><b>{config.advanced_options.run_filter_force_refresh.upper()}</b></u>: Force Filter (Reset Item Status)&nbsp;&nbsp;&nbsp;\"\n            hotkeys_html += f\"<u><b>{config.advanced_options.force_refresh_only.upper()}</b></u>: Reset Items (No Filter)&nbsp;&nbsp;&nbsp;\"\n        else:\n            hotkeys_html += f\"<u><b>{config.advanced_options.run_vision_mode.upper()}</b></u>: Run/Stop Vision Mode<br>\"\n            hotkeys_html += \"<span style='font-style: italic;'>Vision Mode Only - clicking functionality disabled</span>&nbsp;&nbsp;&nbsp;\"\n\n        hotkeys_html += f\"<u><b>{config.advanced_options.toggle_paragon_overlay.upper()}</b></u>: Toggle Paragon Overlay&nbsp;&nbsp;&nbsp;\"\n        hotkeys_html += f\"<u><b>{config.advanced_options.exit_key.upper()}</b></u>: Exit D4LF\"\n        hotkeys_html += \"</div>\"\n\n        hotkey_text.setText(hotkeys_html)\n        self.main_layout.addWidget(hotkey_text)\n\n        # === CONTROL BUTTONS ===\n        button_layout = QHBoxLayout()\n        button_layout.setSpacing(10)\n\n        self.import_btn = QPushButton(\"Import Profile\")\n        self.import_btn.setMinimumHeight(40)\n        button_layout.addWidget(self.import_btn)\n\n        self.settings_btn = QPushButton(\"Settings\")\n        self.settings_btn.setMinimumHeight(40)\n        button_layout.addWidget(self.settings_btn)\n\n        self.editor_btn = QPushButton(\"Edit Profile\")\n        self.editor_btn.setMinimumHeight(40)\n        button_layout.addWidget(self.editor_btn)\n\n        self.user_dir_btn = QPushButton(\"Open User Config Directory\")\n        self.user_dir_btn.setMinimumHeight(40)\n        self.user_dir_btn.setToolTip(\"Open the D4LF user config directory\")\n        button_layout.addWidget(self.user_dir_btn)\n\n        # === CONNECT BUTTONS TO UnifiedMainWindow ===\n        self.import_btn.clicked.connect(self.parent().open_import_dialog)\n        self.settings_btn.clicked.connect(self.parent().open_settings_dialog)\n        self.editor_btn.clicked.connect(self.parent().open_profile_editor)\n        self.user_dir_btn.clicked.connect(self._open_user_dir)\n\n        self.main_layout.addLayout(button_layout)\n\n    def _open_user_dir(self) -> None:\n        user_dir = IniConfigLoader().user_dir\n        QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_dir)))\n"
  },
  {
    "path": "src/gui/collapsible_widget.py",
    "content": "from PyQt6.QtCore import pyqtSignal\nfrom PyQt6.QtGui import QFont\nfrom PyQt6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QStackedLayout, QVBoxLayout, QWidget\n\n\nclass Header(QWidget):\n    firstExpansion = pyqtSignal()  # Signal emitted on first expansion\n\n    def __init__(self, name, content_widget):\n        super().__init__()\n        self.content = content_widget\n        self.name = name\n        self.expand_ico = \">\"  # Use text instead of image\n        self.collapse_ico = \"v\"  # Use text instead of image\n        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)\n\n        # Create a stacked layout to hold the background and widget\n        stacked = QStackedLayout(self)\n        stacked.setStackingMode(QStackedLayout.StackingMode.StackAll)\n        # Create a background label with a specific style sheet\n        background = QLabel()\n        background.setStyleSheet(\"QLabel{ background-color: rgb(93, 93, 93); padding-top: -20px; border-radius:2px}\")\n\n        # Create a widget and a layout to hold the icon and label\n        widget = QWidget()\n        layout = QHBoxLayout(widget)\n\n        # Create an icon label and set its text and style sheet\n        self.icon = QLabel()\n        self.icon.setText(self.expand_ico)\n        self.icon.setStyleSheet(\"QLabel { font-weight: bold; font-size: 20px; color: #ffffff }\")\n        layout.addWidget(self.icon)\n\n        # Add the icon and the label to the layout and set margins\n        layout.addWidget(self.icon)\n        layout.addWidget(self.icon)\n        layout.setContentsMargins(11, 0, 11, 0)\n\n        # Create a font and a label for the header name\n        font = QFont()\n        font.setBold(True)\n        self.label = QLabel(name)\n        self.label.setStyleSheet(\"QLabel { margin-top: 5px; }\")\n        self.label.setFont(font)\n\n        # Add the label to the layout and add a spacer for expanding\n        layout.addWidget(self.label)\n        layout.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding))\n\n        # Add the widget and the background to the stacked layout\n        stacked.addWidget(widget)\n        stacked.addWidget(background)\n        # Set the minimum height of the background based on the layout height\n        background.setMinimumHeight(int(layout.sizeHint().height() * 1.5))\n        self.collapse()\n        self.first_expansion = True\n\n    def mousePressEvent(self, *args):\n        \"\"\"Handle mouse events, call the function to toggle groups.\"\"\"\n        # Toggle between expand and collapse based on the visibility of the content widget\n        self.expand() if not self.content.isVisible() else self.collapse()\n\n    def expand(self):\n        \"\"\"Expand the collapsible group.\"\"\"\n        if self.first_expansion:\n            self.firstExpansion.emit()\n            self.first_expansion = False\n        self.content.setVisible(True)\n        self.icon.setText(self.collapse_ico)  # Set text instead of pixmap\n\n    def collapse(self):\n        \"\"\"Collapse the collapsible group.\"\"\"\n        self.content.setVisible(False)\n        self.icon.setText(self.expand_ico)\n\n    def set_name(self, name):\n        self.name = name\n        self.label.setText(name)\n\n\nclass Container(QWidget):\n    firstExpansion = pyqtSignal()  # Signal emitted on first expansion\n\n    def __init__(self, name, color_background=False):\n        super().__init__()  # Call the constructor of the parent class\n\n        layout = QVBoxLayout(self)  # Create a QVBoxLayout instance and pass the current object as the parent\n        layout.setContentsMargins(0, 0, 0, 0)  # Set the margins of the layout to 0\n\n        self._content_widget = (\n            QWidget()\n        )  # Create a QWidget instance and assign it to the instance variable _content_widget\n        if color_background:\n            # If color_background is True, set the stylesheet of _content_widget to have a lighter background color\n            self._content_widget.setStyleSheet(\n                \".QWidget{background-color: rgb(73, 73, 73); \"\n                \"margin-left: 2px; padding-top: 20px; margin-right: 2px}\"\n                \".QLabel{background-color: rgb(73, 73, 73)}\"\n            )\n\n        self.header = Header(\n            name, self._content_widget\n        )  # Create a Header instance and pass the name and _content_widget as arguments\n        layout.addWidget(self.header)  # Add the header to the layout\n        layout.addWidget(self._content_widget)  # Add the _content_widget to the layout\n\n        self._content_initialized = False  # Track initialization state\n        self.header.firstExpansion.connect(self.first_expansion)\n        # assign header methods to instance attributes so they can be called outside of this class\n        self.collapse = (\n            self.header.collapse\n        )  # Assign the collapse method of the header to the instance attribute collapse\n        self.expand = self.header.expand  # Assign the expand method of the header to the instance attribute expand\n        self.toggle = (\n            self.header.mousePressEvent\n        )  # Assign the mousePressEvent method of the header to the instance attribute toggle\n\n    @property\n    def contentWidget(self):\n        \"\"\"Getter for the content widget.\n\n        Returns: Content widget\n        \"\"\"\n        return self._content_widget  # Return the _content_widget when the contentWidget property is accessed\n\n    def first_expansion(self):\n        \"\"\"Handle first expansion event.\"\"\"\n        self.firstExpansion.emit()  # Notify about first expansion\n"
  },
  {
    "path": "src/gui/config_tab.py",
    "content": "import enum\nimport os\nimport subprocess\nimport sys\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import BaseModel, ValidationError\nfrom PyQt6.QtCore import QCoreApplication, Qt, QTimer\nfrom PyQt6.QtWidgets import (\n    QAbstractItemView,\n    QCheckBox,\n    QComboBox,\n    QDialog,\n    QDialogButtonBox,\n    QFormLayout,\n    QGridLayout,\n    QGroupBox,\n    QHBoxLayout,\n    QLabel,\n    QLineEdit,\n    QListWidget,\n    QListWidgetItem,\n    QMessageBox,\n    QPushButton,\n    QScrollArea,\n    QTextBrowser,\n    QTextEdit,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import HIDE_FROM_GUI_KEY, IS_HOTKEY_KEY, MoveItemsType\nfrom src.gui.open_user_config_button import OpenUserConfigButton\n\nCONFIG_TABNAME = \"config\"\n\n\ndef _validate_and_save_changes(\n    model,\n    header,\n    key,\n    value,\n    method_to_reset_value: typing.Callable | None = None,\n    post_save_callback: typing.Callable[[], None] | None = None,\n):\n    current_value = getattr(model, key)\n    try:\n        validated_values = model.model_dump(mode=\"python\")\n        validated_values[key] = value\n        type(model)(**validated_values)\n        IniConfigLoader().save_value(header, key, value)\n    except ValidationError as e:\n        msg = QMessageBox()\n        msg.setIcon(QMessageBox.Icon.Critical)\n\n        message = f\"There was an error setting {key} to {value}. See error below.\\n\\n\"\n\n        # Only reset the widget if the field is NOT an enum\n        if method_to_reset_value and key != \"theme\":\n            message = message + \"Your value has been reset to its previous version.\\n\\n\"\n            method_to_reset_value(str(current_value))\n\n        message = message + str(e)\n        msg.setText(message)\n        msg.setWindowTitle(\"Error validating value\")\n        msg.setStandardButtons(QMessageBox.StandardButton.Ok)\n        msg.exec()\n        return False\n\n    if post_save_callback and str(current_value) != str(value):\n        post_save_callback()\n    return True\n\n\nclass ConfigTab(QWidget):\n    def __init__(self, theme_changed_callback=None):\n        self._initializing = True\n        super().__init__()\n        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)\n        self.theme_changed_callback = theme_changed_callback\n        self.model_to_parameter_value_map = {}\n        layout = QVBoxLayout(self)\n        scrollable_layout = QVBoxLayout()\n        scroll_widget = QWidget()\n        scroll_area = QScrollArea(self)\n        scroll_area.setWidgetResizable(True)\n\n        button_hbox = QHBoxLayout()\n        button_hbox.addWidget(self._setup_reset_button())\n        button_hbox.addWidget(OpenUserConfigButton())\n\n        scrollable_layout.addLayout(button_hbox)\n        scrollable_layout.addWidget(self._generate_params_section(IniConfigLoader().general, \"General\", \"general\"))\n        scrollable_layout.addWidget(self._generate_params_section(IniConfigLoader().char, \"Character\", \"char\"))\n        scrollable_layout.addWidget(\n            self._generate_params_section(IniConfigLoader().advanced_options, \"Advanced\", \"advanced_options\")\n        )\n        scroll_widget.setLayout(scrollable_layout)\n        scroll_area.setWidget(scroll_widget)\n        layout.addWidget(scroll_area)\n\n        instructions_label = QLabel(\"Instructions\")\n        layout.addWidget(instructions_label)\n\n        instructions_text = QTextBrowser()\n        instructions_text.setOpenExternalLinks(True)\n        instructions_text.append(\n            \"All values are saved automatically immediately upon changing. Hover over any label/field to see a brief \"\n            \"description of what it is for. To read more about each parameter, please view \"\n            \"<a href='https://github.com/d4lfteam/d4lf?tab=readme-ov-file#configs' style='color: #1E90FF;'>the config portion of the readme</a>\"\n        )\n        instructions_text.setFixedHeight(80)\n        layout.addWidget(instructions_text)\n\n        self.setLayout(layout)\n        QTimer.singleShot(0, self._finish_init)\n\n    def _finish_init(self):\n        self._initializing = False\n\n    def _prompt_restart_for_vision_mode_change(self) -> None:\n        msg = QMessageBox(self)\n        msg.setIcon(QMessageBox.Icon.Question)\n        msg.setWindowTitle(\"Restart required\")\n        msg.setText(\"Vision mode changes require restarting d4lf. Restart now?\")\n        restart_button = msg.addButton(\"Restart now\", QMessageBox.ButtonRole.AcceptRole)\n        msg.addButton(\"Later\", QMessageBox.ButtonRole.RejectRole)\n        msg.exec()\n\n        if msg.clickedButton() is restart_button:\n            self._restart_application()\n\n    def _restart_application(self) -> None:\n        command = [sys.executable, *sys.argv[1:]] if getattr(sys, \"frozen\", False) else [sys.executable, *sys.argv]\n\n        creationflags = 0\n        if os.name == \"nt\":\n            creationflags = getattr(subprocess, \"CREATE_NEW_PROCESS_GROUP\", 0)\n\n        try:\n            subprocess.Popen(command, cwd=Path.cwd(), creationflags=creationflags)\n        except OSError:\n            msg = QMessageBox(self)\n            msg.setIcon(QMessageBox.Icon.Critical)\n            msg.setWindowTitle(\"Restart failed\")\n            msg.setText(\"d4lf could not be restarted automatically. Please restart it manually.\")\n            msg.setStandardButtons(QMessageBox.StandardButton.Ok)\n            msg.exec()\n            return\n\n        if app := QCoreApplication.instance():\n            app.quit()\n\n    def _generate_params_section(self, model: BaseModel, section_readable_header: str, section_config_header: str):\n        group_box = QGroupBox(section_readable_header)\n        form_layout = QFormLayout()\n\n        all_parameter_metadata = model.model_json_schema()[\"properties\"]\n\n        for parameter in model:\n            config_key, config_value = parameter\n            parameter_metadata = all_parameter_metadata[config_key]\n\n            hide_from_gui = parameter_metadata.get(HIDE_FROM_GUI_KEY)\n            if hide_from_gui:\n                continue\n            description_text = parameter_metadata.get(\"description\")\n            is_hotkey = parameter_metadata.get(IS_HOTKEY_KEY)\n            parameter_value_widget = self._generate_parameter_value_widget(\n                model, section_config_header, config_key, config_value, is_hotkey\n            )\n            self.model_to_parameter_value_map[section_config_header + \".\" + config_key] = parameter_value_widget\n            config_with_desc = QLabel(config_key)\n            if description_text:\n                # The span is a hack to make the tooltip wordwrap\n                config_with_desc.setToolTip(\"<span>\" + description_text + \"</span>\")\n                parameter_value_widget.setToolTip(\"<span>\" + description_text + \"</span>\")\n            form_layout.addRow(config_with_desc, parameter_value_widget)\n\n        group_box.setLayout(form_layout)\n        return group_box\n\n    def _generate_parameter_value_widget(\n        self, model: BaseModel, section_config_header, config_key, config_value, is_hotkey\n    ):\n        if config_key == \"check_chest_tabs\":\n            parameter_value_widget = QChestTabWidget(\n                model, section_config_header, config_key, config_value, IniConfigLoader().general.max_stash_tabs\n            )\n        elif config_key == \"max_stash_tabs\":\n            parameter_value_widget = IgnoreScrollWheelComboBox()\n            parameter_value_widget.addItems([\"6\", \"7\"])\n            parameter_value_widget.setCurrentText(str(config_value))\n            parameter_value_widget.currentTextChanged.connect(\n                lambda: _validate_and_save_changes(\n                    model, section_config_header, config_key, parameter_value_widget.currentText()\n                )\n            )\n        elif config_key == \"profiles\":\n            parameter_value_widget = QProfilesWidget(model, section_config_header, config_key, config_value)\n        elif config_key in {\"move_to_inv_item_type\", \"move_to_stash_item_type\"}:\n            parameter_value_widget = QMoveItemsWidget(model, section_config_header, config_key, config_value)\n        elif is_hotkey:\n            parameter_value_widget = QHotkeyWidget(model, section_config_header, config_key, config_value)\n        elif isinstance(config_value, enum.StrEnum):\n            parameter_value_widget = IgnoreScrollWheelComboBox()\n            enum_type = type(config_value)\n\n            # Block signals during initialization so we don't fire theme change with the old value\n            parameter_value_widget.blockSignals(True)\n            parameter_value_widget.addItems(list(enum_type))\n            parameter_value_widget.setCurrentText(config_value)\n            parameter_value_widget.blockSignals(False)\n\n            def make_on_enum_changed(key):\n                def on_enum_changed():\n                    _validate_and_save_changes(\n                        model,\n                        section_config_header,\n                        key,\n                        parameter_value_widget.currentText(),\n                        post_save_callback=(\n                            self._prompt_restart_for_vision_mode_change\n                            if key == \"vision_mode_type\" and not self._initializing\n                            else None\n                        ),\n                    )\n\n                    if key == \"theme\" and self.theme_changed_callback and not self._initializing:\n                        self.theme_changed_callback()\n\n                return on_enum_changed\n\n            parameter_value_widget.currentTextChanged.connect(make_on_enum_changed(config_key))\n\n        elif isinstance(config_value, bool):\n            parameter_value_widget = QCheckBox()\n            parameter_value_widget.setChecked(config_value)\n            parameter_value_widget.stateChanged.connect(\n                lambda: _validate_and_save_changes(\n                    model, section_config_header, config_key, str(parameter_value_widget.isChecked())\n                )\n            )\n        else:\n            parameter_value_widget = QLineEdit(str(config_value))\n            parameter_value_widget.editingFinished.connect(\n                lambda: _validate_and_save_changes(\n                    model,\n                    section_config_header,\n                    config_key,\n                    parameter_value_widget.text(),\n                    method_to_reset_value=parameter_value_widget.setText,\n                )\n            )\n\n        return parameter_value_widget\n\n    def show_tab(self):\n        self._reset_values_for_model(IniConfigLoader().general, \"general\")\n        self._reset_values_for_model(IniConfigLoader().char, \"char\")\n        self._reset_values_for_model(IniConfigLoader().advanced_options, \"advanced_options\")\n\n    def reset_button_click(self):\n        msg = QMessageBox()\n        msg.setIcon(QMessageBox.Icon.Warning)\n        message = \"This will reset all custom values in your params.ini to their default value. Are you sure you want to continue?\"\n        msg.setText(message)\n        msg.setWindowTitle(\"Reset to default values?\")\n        msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)\n\n        result = msg.exec()  # Store the result of msg.exec()\n\n        if result == QMessageBox.StandardButton.Ok:\n            IniConfigLoader().load(clear=True)\n            self._reset_values_for_model(IniConfigLoader().general, \"general\")\n            self._reset_values_for_model(IniConfigLoader().char, \"char\")\n            self._reset_values_for_model(IniConfigLoader().advanced_options, \"advanced_options\")\n\n    def _reset_values_for_model(self, model, section_config_header):\n        for parameter in model:\n            config_key, config_value = parameter\n            parameter_value_widget = self.model_to_parameter_value_map.get(section_config_header + \".\" + config_key)\n            # Should always exist but just being safe\n            if parameter_value_widget is None:\n                continue\n\n            if isinstance(parameter_value_widget, QChestTabWidget | QProfilesWidget | QHotkeyWidget | QMoveItemsWidget):\n                parameter_value_widget.reset_values(config_value)\n            elif isinstance(parameter_value_widget, IgnoreScrollWheelComboBox):\n                parameter_value_widget.blockSignals(True)\n                parameter_value_widget.reset_values(config_value)\n                parameter_value_widget.blockSignals(False)\n            elif isinstance(parameter_value_widget, QCheckBox):\n                parameter_value_widget.setChecked(config_value)\n            else:\n                parameter_value_widget.setText(str(config_value))\n\n    def _setup_reset_button(self) -> QPushButton:\n        reset_button = QPushButton(\"Reset to defaults\")\n        reset_button.clicked.connect(self.reset_button_click)\n        return reset_button\n\n\nclass IgnoreScrollWheelComboBox(QComboBox):\n    def __init__(self):\n        super().__init__()\n        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)\n\n    def wheelEvent(self, event):\n        if self.hasFocus():\n            return QComboBox.wheelEvent(self, event)\n\n        return event.ignore()\n\n    def reset_values(self, value):\n        self.blockSignals(True)\n        self.setCurrentText(str(value))\n        self.blockSignals(False)\n\n\nclass QChestTabWidget(QWidget):\n    def __init__(self, model, section_header, config_key, chest_tab_config: list[int], max_chest_tabs):\n        super().__init__()\n        self.all_checkboxes: list[QCheckBox] = []\n        stash_checkbox_layout = QHBoxLayout()\n        stash_checkbox_layout.setContentsMargins(0, 0, 0, 0)\n        for x in range(max_chest_tabs):\n            stash_checkbox = QCheckBox(self)\n            stash_checkbox.setText(str(x + 1))\n            self.all_checkboxes.append(stash_checkbox)\n            if x in chest_tab_config:\n                stash_checkbox.setChecked(True)\n            stash_checkbox.stateChanged.connect(\n                lambda: self._save_changes_on_box_change(model, section_header, config_key)\n            )\n            stash_checkbox_layout.addWidget(stash_checkbox)\n\n        self.setLayout(stash_checkbox_layout)\n\n    def reset_values(self, chest_tab_config: list[int]):\n        for check_box in self.all_checkboxes:\n            check_box.setChecked(int(check_box.text()) - 1 in chest_tab_config)\n\n    def _save_changes_on_box_change(self, model, section_header, config_key):\n        active_tabs = [check_box.text() for check_box in self.all_checkboxes if check_box.isChecked()]\n        _validate_and_save_changes(model, section_header, config_key, \",\".join(active_tabs), self.reset_values)\n\n\nclass QMoveItemsWidget(QWidget):\n    def __init__(self, model, section_header, config_key, move_selections: list[MoveItemsType]):\n        super().__init__()\n\n        layout = QHBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n\n        self.current_move_selections_line_edit = QLineEdit()\n        self.reset_values(move_selections)\n        self.current_move_selections_line_edit.setReadOnly(True)\n        layout.addWidget(self.current_move_selections_line_edit)\n\n        open_picker_button = QPushButton()\n        open_picker_button.setText(\"...\")\n        open_picker_button.setMinimumWidth(20)\n        open_picker_button.clicked.connect(\n            lambda: self._launch_picker(\n                model, section_header, config_key, self.current_move_selections_line_edit.text().split(\", \")\n            )\n        )\n        layout.addWidget(open_picker_button)\n\n        self.setLayout(layout)\n\n    def reset_values(self, move_selections: list[MoveItemsType]):\n        self.current_move_selections_line_edit.setText(\", \".join([item_type.name for item_type in move_selections]))\n\n    def _launch_picker(self, model, section_header, config_key, move_selections):\n        move_item_type_picker = QMoveItemsPicker(self, move_selections)\n        if move_item_type_picker.exec():\n            move_types = move_item_type_picker.get_selected_move_types()\n            move_types_string = \", \".join([item_type.name for item_type in move_types])\n            _validate_and_save_changes(\n                model, section_header, config_key, move_types_string, self.current_move_selections_line_edit.setText\n            )\n            self.reset_values(move_types)\n\n\nclass QMoveItemsPicker(QDialog):\n    def __init__(self, parent, move_selections):\n        super().__init__(parent)\n        self.setWindowTitle(\"Select item types\")\n        layout = QVBoxLayout()\n\n        label = QLabel(\"Select which item types you would like to move when hotkey is pressed.\")\n        self.move_favorite_box = QCheckBox(\"Favorite\")\n        self.move_junk_box = QCheckBox(\"Junk\")\n        self.move_unmarked_box = QCheckBox(\"Unmarked\")\n\n        self.move_favorite_box.setChecked(\n            MoveItemsType.everything.name in move_selections or MoveItemsType.favorites.name in move_selections\n        )\n        self.move_junk_box.setChecked(\n            MoveItemsType.everything.name in move_selections or MoveItemsType.junk.name in move_selections\n        )\n        self.move_unmarked_box.setChecked(\n            MoveItemsType.everything.name in move_selections or MoveItemsType.unmarked.name in move_selections\n        )\n\n        layout.addWidget(label)\n        layout.addWidget(self.move_favorite_box)\n        layout.addWidget(self.move_junk_box)\n        layout.addWidget(self.move_unmarked_box)\n\n        ok_cancel_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel\n        self.buttonBox = QDialogButtonBox(ok_cancel_buttons)\n        self.buttonBox.accepted.connect(self.accept)\n        self.buttonBox.rejected.connect(self.reject)\n        layout.addWidget(self.buttonBox)\n\n        self.setLayout(layout)\n\n    def get_selected_move_types(self) -> list[MoveItemsType]:\n        result = []\n\n        if self.move_favorite_box.isChecked():\n            result.append(MoveItemsType.favorites)\n        if self.move_junk_box.isChecked():\n            result.append(MoveItemsType.junk)\n        if self.move_unmarked_box.isChecked():\n            result.append(MoveItemsType.unmarked)\n\n        if not result or len(result) == 3:\n            return [MoveItemsType.everything]\n\n        return result\n\n\nclass QProfilesWidget(QWidget):\n    def __init__(self, model, section_header, config_key, current_profiles):\n        super().__init__()\n\n        layout = QHBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n\n        self.current_profile_line_edit = QLineEdit()\n        self.reset_values(current_profiles)\n        self.current_profile_line_edit.setReadOnly(True)\n        layout.addWidget(self.current_profile_line_edit)\n\n        open_picker_button = QPushButton()\n        open_picker_button.setText(\"...\")\n        open_picker_button.setMinimumWidth(20)\n        open_picker_button.clicked.connect(\n            lambda: self._launch_picker(\n                model, section_header, config_key, self.current_profile_line_edit.text().split(\", \")\n            )\n        )\n        layout.addWidget(open_picker_button)\n\n        self.setLayout(layout)\n\n    def reset_values(self, current_profiles):\n        self.current_profile_line_edit.setText(\", \".join(current_profiles))\n\n    def _launch_picker(self, model, section_header, config_key, current_profiles):\n        profile_picker = QProfilePicker(self, current_profiles)\n        if profile_picker.exec():\n            selected_profiles = \", \".join(profile_picker.get_selected_profiles())\n            _validate_and_save_changes(\n                model, section_header, config_key, selected_profiles, self.current_profile_line_edit.setText\n            )\n            self.current_profile_line_edit.setText(selected_profiles)\n\n\nclass QProfilePicker(QDialog):\n    def __init__(self, parent, current_profiles):\n        super().__init__(parent)\n        self.setWindowTitle(\"Select profiles\")\n\n        overall_layout = QVBoxLayout()\n        self.setGeometry(0, 0, 700, 500)\n\n        profile_folder = IniConfigLoader().user_dir / \"profiles\"\n        if not Path.exists(profile_folder):\n            Path.mkdir(profile_folder)\n\n        all_profile_files = profile_folder.iterdir()\n        all_profiles = [\n            os.path.splitext(profile_file.name)[0] for profile_file in all_profile_files if profile_file.is_file()\n        ]\n        all_profiles.sort(key=str.lower)\n\n        self.disabled_profiles_list_widget = QListWidget()\n        self.disabled_profiles_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)\n        self.disabled_profiles_list_widget.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)\n        self.disabled_profiles_list_widget.setDefaultDropAction(Qt.DropAction.MoveAction)\n\n        self.enabled_profiles_list_widget = QListWidget()\n        self.enabled_profiles_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)\n        self.enabled_profiles_list_widget.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)\n        self.enabled_profiles_list_widget.setDefaultDropAction(Qt.DropAction.MoveAction)\n\n        for profile_name in all_profiles:\n            if profile_name not in current_profiles:\n                QListWidgetItem(profile_name, self.disabled_profiles_list_widget)\n\n        for profile_name in current_profiles:\n            if profile_name in all_profiles:\n                QListWidgetItem(profile_name, self.enabled_profiles_list_widget)\n\n        list_widget_layout = QGridLayout()\n        list_widget_layout.addWidget(QLabel(\"Disabled Profiles\"), 0, 0)\n        list_widget_layout.addWidget(self.disabled_profiles_list_widget, 1, 0)\n\n        # Create buttons for moving profiles between lists\n        enable_button = QPushButton(\"Enable\")\n        enable_button.clicked.connect(\n            lambda: self.move_items(self.disabled_profiles_list_widget, self.enabled_profiles_list_widget)\n        )\n        disable_button = QPushButton(\"Disable\")\n        disable_button.clicked.connect(\n            lambda: self.move_items(self.enabled_profiles_list_widget, self.disabled_profiles_list_widget)\n        )\n\n        list_widget_layout.addWidget(enable_button, 2, 0)\n        list_widget_layout.addWidget(disable_button, 2, 1)\n\n        list_widget_layout.addWidget(QLabel(\"Enabled Profiles\"), 0, 1)\n        list_widget_layout.addWidget(self.enabled_profiles_list_widget, 1, 1)\n\n        overall_layout.addLayout(list_widget_layout)\n\n        message = QTextEdit(\n            \"Enable/Disable profiles by selecting and then using drag&drop or the buttons.\\n\"\n            \"Multi select is supported.\\n\"\n            \"You can change order by dragging a profile up and down in the right list.\"\n        )\n        message.setReadOnly(True)\n        message.setFixedHeight(70)\n        overall_layout.addWidget(message)\n\n        ok_cancel_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel\n        self.buttonBox = QDialogButtonBox(ok_cancel_buttons)\n        self.buttonBox.accepted.connect(self.accept)\n        self.buttonBox.rejected.connect(self.reject)\n        overall_layout.addWidget(self.buttonBox)\n        self.setLayout(overall_layout)\n\n    def move_items(self, source_list, destination_list):\n        for item in source_list.selectedItems():\n            source_list.takeItem(source_list.row(item))\n            destination_list.addItem(item)\n\n    def get_selected_profiles(self):\n        return [\n            self.enabled_profiles_list_widget.item(x).text() for x in range(self.enabled_profiles_list_widget.count())\n        ]\n\n\nclass QHotkeyWidget(QWidget):\n    def __init__(self, model, section_header, config_key, current_value):\n        super().__init__()\n\n        layout = QHBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n\n        self.open_picker_button = QPushButton()\n        self.reset_values(current_value)\n        self.open_picker_button.clicked.connect(lambda: self._launch_hotkey_dialog(model, section_header, config_key))\n        self.open_picker_button.setProperty(\"hotkeyButton\", True)\n        layout.addWidget(self.open_picker_button)\n\n        self.setLayout(layout)\n\n    def reset_values(self, current_value):\n        self.open_picker_button.setText(current_value)\n\n    def _launch_hotkey_dialog(self, model, section_header, config_key):\n        hotkey_dialog = HotkeyListenerDialog(self)\n        if hotkey_dialog.exec():\n            new_hotkey = hotkey_dialog.get_hotkey()\n            if new_hotkey and _validate_and_save_changes(model, section_header, config_key, new_hotkey):\n                self.open_picker_button.setText(new_hotkey)\n\n\nclass HotkeyListenerDialog(QDialog):\n    def __init__(self, parent=None, hotkey=\"\"):\n        super().__init__(parent)\n        self.setWindowTitle(\"Set Hotkey\")\n        self.hotkey = hotkey\n\n        self.layout = QVBoxLayout(self)\n\n        self.label = QLabel(\"Press the key or combination of keys you\\nwant to use as a hotkey, then click save.\", self)\n        self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)\n\n        self.hotkey_label = QLabel(self.hotkey, self)\n        self.hotkey_label.setAlignment(Qt.AlignmentFlag.AlignCenter)\n\n        self.layout.addWidget(self.label)\n        self.layout.addWidget(self.hotkey_label)\n\n        self.button_layout = QHBoxLayout()\n        self.save_button = QPushButton(\"Save\", self)\n        self.cancel_button = QPushButton(\"Cancel\", self)\n\n        self.save_button.clicked.connect(self.accept)\n        self.cancel_button.clicked.connect(self.reject)\n\n        self.button_layout.addWidget(self.save_button)\n        self.button_layout.addWidget(self.cancel_button)\n\n        self.layout.addLayout(self.button_layout)\n\n    def keyPressEvent(self, event):\n        modifiers = []\n\n        if event.modifiers() & Qt.KeyboardModifier.ControlModifier:\n            modifiers.append(\"ctrl\")\n        if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:\n            modifiers.append(\"shift\")\n        if event.modifiers() & Qt.KeyboardModifier.AltModifier:\n            modifiers.append(\"alt\")\n\n        key = event.key()\n\n        # Handle function keys\n        if Qt.Key.Key_F1 <= key <= Qt.Key.Key_F35:\n            non_mod_key = f\"f{key - Qt.Key.Key_F1 + 1}\"\n\n        # Handle regular keys\n        else:\n            text = event.text().lower()\n            non_mod_key = text or \"\"\n\n        # Build final hotkey string\n        parts = modifiers + ([non_mod_key] if non_mod_key else [])\n        hotkey_str = \"+\".join(parts)\n\n        self.hotkey = hotkey_str\n        self.hotkey_label.setText(hotkey_str)\n\n    def get_hotkey(self):\n        return self.hotkey\n"
  },
  {
    "path": "src/gui/config_window.py",
    "content": "import logging\nimport sys\nfrom pathlib import Path\n\nfrom PyQt6.QtCore import QPoint, QSettings, QSize, Qt\nfrom PyQt6.QtGui import QIcon\nfrom PyQt6.QtWidgets import QMainWindow\n\nfrom src.gui.config_tab import ConfigTab\n\nBASE_DIR = Path(sys.executable).parent if getattr(sys, \"frozen\", False) else Path(__file__).resolve().parent.parent\n\nICON_PATH = BASE_DIR / \"assets\" / \"logo.png\"\nLOGGER = logging.getLogger(__name__)\n\n\nclass ConfigWindow(QMainWindow):\n    \"\"\"Standalone window for Config/Settings.\"\"\"\n\n    def __init__(self, parent=None, theme_changed_callback=None):\n        super().__init__(parent)\n\n        if ICON_PATH.exists():\n            self.setWindowIcon(QIcon(str(ICON_PATH)))\n\n        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)\n        self.theme_changed_callback = theme_changed_callback\n        self.settings = QSettings(\"d4lf\", \"config\")\n\n        self.setWindowTitle(\"Settings\")\n\n        self.resize(self.settings.value(\"size\", QSize(650, 800)))\n        self.move(self.settings.value(\"pos\", QPoint(0, 0)))\n\n        if self.settings.value(\"maximized\", \"false\") == \"true\":\n            self.showMaximized()\n\n        # Create initial config tab\n        self.config_tab = ConfigTab(theme_changed_callback=self._on_theme_changed)\n        self.setCentralWidget(self.config_tab)\n\n    def _on_theme_changed(self):\n        if self.theme_changed_callback:\n            self.theme_changed_callback()\n\n        # Rebuild the tab so the settings window updates visually too\n        self._rebuild_tab()\n\n    def _rebuild_tab(self):\n        old_tab = self.config_tab\n        self.config_tab = ConfigTab(theme_changed_callback=self._on_theme_changed)\n        self.setCentralWidget(self.config_tab)\n        old_tab.deleteLater()\n\n    def closeEvent(self, event):\n        \"\"\"Save window size/position.\"\"\"\n        if not self.isMaximized():\n            self.settings.setValue(\"size\", self.size())\n            self.settings.setValue(\"pos\", self.pos())\n        self.settings.setValue(\"maximized\", self.isMaximized())\n        event.accept()\n"
  },
  {
    "path": "src/gui/d4lfitem.py",
    "content": "from PyQt6.QtCore import Qt\nfrom PyQt6.QtWidgets import (\n    QComboBox,\n    QCompleter,\n    QFormLayout,\n    QGroupBox,\n    QHeaderView,\n    QLabel,\n    QMessageBox,\n    QSizePolicy,\n    QTableView,\n    QVBoxLayout,\n)\n\nfrom src.config.profile_models import AffixFilterCountModel, AffixFilterModel, DynamicItemFilterModel, ItemFilterModel\nfrom src.gui.dialog import IgnoreScrollWheelComboBox, IgnoreScrollWheelSpinBox\nfrom src.gui.importer.gui_common import MAX_POWER\n\n\nclass D4LFItem(QGroupBox):\n    def __init__(self, item: DynamicItemFilterModel, affixesNames, allItemTypes):\n        super().__init__()\n        self.item_name = next(iter(item.root.keys()))\n        self.item = item\n        self.item_types = self.item.root[self.item_name].itemType\n        self.affix_pool = self.item.root[self.item_name].affixPool\n        self.inherent_pool = self.item.root[self.item_name].inherentPool\n        self.min_power = self.item.root[self.item_name].minPower\n\n        self.changed = False\n        self.affixesNames = affixesNames\n        self.allItemTypes = allItemTypes\n\n        self.setTitle(self.item_name)\n        self.setStyleSheet(\n            \"QGroupBox {font-size: 10pt;} QLabel {font-size: 10pt;} IgnoreScrollWheelComboBox {font-size: 10pt;} IgnoreScrollWheelSpinBox {font-size: 10pt;}\"\n        )\n        self.setMaximumSize(300, 500)\n\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        self.form_layout = QFormLayout()\n\n        self.item_type_label = QLabel(\"Item Types:\")\n        self.item_type_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.item_type_label_info = QLabel(\n            \", \".join([self.find_item_from_value(item_type.value) for item_type in self.item_types])\n        )\n        self.item_type_label_info.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.form_layout.addRow(self.item_type_label, self.item_type_label_info)\n\n        self.minPowerEdit = IgnoreScrollWheelSpinBox()\n        self.minPowerEdit.setMaximum(MAX_POWER)\n        self.minPowerEdit.setMaximumWidth(75)\n        self.minPowerEdit.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.form_layout.addRow(QLabel(\"minPower:\"), self.minPowerEdit)\n        self.main_layout.addLayout(self.form_layout)\n        self.affixListLayout = None\n        self.inherentListLayout = None\n        if self.affix_pool:\n            self.affixes_label = QLabel(\"Affixes:\")\n            self.affixes_label.setMaximumSize(200, 50)\n            self.affixes_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n            self.main_layout.addWidget(self.affixes_label)\n            self.affixListLayout = QVBoxLayout()\n            self.main_layout.addLayout(self.affixListLayout)\n\n        if self.inherent_pool:\n            self.inherent_label = QLabel(\"Inherent:\")\n            self.inherent_label.setMaximumSize(200, 50)\n            self.inherent_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n            self.main_layout.addWidget(self.inherent_label)\n            self.inherentListLayout = QVBoxLayout()\n            self.main_layout.addLayout(self.inherentListLayout)\n\n        self.load_item()\n        self.setLayout(self.main_layout)\n\n        self.minPowerEdit.valueChanged.connect(self.item_changed)\n\n    def load_item(self):\n        self.minPowerEdit.setValue(self.min_power)\n        for pool in self.affix_pool:\n            for affix in pool.count:\n                affixComboBox = self.create_affix_combobox(affix.name)\n                self.affixListLayout.addWidget(affixComboBox)\n            if pool.minCount is not None and pool.minGreaterAffixCount is not None:\n                layout = self.create_form_layout(pool.minCount, pool.minGreaterAffixCount)\n                self.affixListLayout.addLayout(layout)\n\n        for pool in self.inherent_pool:\n            for affix in pool.count:\n                affixComboBox = self.create_affix_combobox(affix.name)\n                self.inherentListLayout.addWidget(affixComboBox)\n\n    def create_affix_combobox(self, affix_name):\n        affixComboBox = IgnoreScrollWheelComboBox()\n        affixComboBox.setEditable(True)\n        affixComboBox.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        affixComboBox.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n\n        table_view = QTableView()\n        table_view.horizontalHeader().setVisible(False)\n        table_view.verticalHeader().setVisible(False)\n        table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)\n\n        affixComboBox.setView(table_view)\n        affixComboBox.addItems(self.affixesNames.values())\n        for i, affixes in enumerate(self.affixesNames.values()):\n            affixComboBox.setItemData(i, affixes, Qt.ItemDataRole.ToolTipRole)\n\n        key_list = list(self.affixesNames.keys())\n        try:\n            idx = key_list.index(affix_name)\n        except ValueError:\n            self.create_alert(f\"{affix_name} is not a valid affix.\")\n            return affixComboBox\n        affixComboBox.setCurrentIndex(idx)\n        affixComboBox.setMaximumWidth(250)\n        affixComboBox.currentTextChanged.connect(self.item_changed)\n        return affixComboBox\n\n    def create_alert(self, msg: str):\n        reply = QMessageBox.warning(self, \"Alert\", msg, QMessageBox.StandardButton.Ok)\n        return reply == QMessageBox.StandardButton.Ok\n\n    def create_form_layout(self, minCount, minGreaterAffixCount):\n        ret = QFormLayout()\n        mincount_label = QLabel(\"minCount:\")\n        mincount_spinBox = IgnoreScrollWheelSpinBox()\n        mincount_spinBox.setMaximum(3)\n        mincount_spinBox.setValue(minCount)\n        mincount_spinBox.setMaximumWidth(60)\n        mincount_spinBox.valueChanged.connect(self.item_changed)\n        ret.addRow(mincount_label, mincount_spinBox)\n        mingreater_label = QLabel(\"minGreaterAffixCount:\")\n        mingreater_spinBox = IgnoreScrollWheelSpinBox()\n        mingreater_spinBox.setMaximum(3)\n        mingreater_spinBox.setValue(minGreaterAffixCount)\n        mingreater_spinBox.setMaximumWidth(60)\n        mingreater_spinBox.valueChanged.connect(self.item_changed)\n        ret.addRow(mingreater_label, mingreater_spinBox)\n        return ret\n\n    def set_minPower(self, minPower):\n        self.minPowerEdit.setValue(minPower)\n\n    def set_minGreaterAffix(self, minGreaterAffix):\n        for i in range(self.affixListLayout.count()):\n            layout = self.affixListLayout.itemAt(i).layout()\n            if layout is not None and isinstance(layout, QFormLayout):\n                layout.itemAt(3).widget().setValue(minGreaterAffix)\n\n    def set_minCount(self, minCount):\n        for i in range(self.affixListLayout.count()):\n            layout = self.affixListLayout.itemAt(i).layout()\n            if layout is not None and isinstance(layout, QFormLayout):\n                layout.itemAt(1).widget().setValue(minCount)\n\n    def find_affix_from_value(self, target_value):\n        for key, value in self.affixesNames.items():\n            if value == target_value:\n                return key\n        return None\n\n    def find_item_from_value(self, target_value):\n        for key, value in self.allItemTypes.items():\n            if value == target_value:\n                return key\n        return None\n\n    def save_item(self):\n        self.min_power = self.minPowerEdit.value()\n        for pool in self.affix_pool:\n            for i in range(self.affixListLayout.count()):\n                widget = self.affixListLayout.itemAt(i).widget()\n                layout = self.affixListLayout.itemAt(i).layout()\n                if widget is not None:\n                    if isinstance(widget, IgnoreScrollWheelComboBox):\n                        pool.count[i] = AffixFilterModel(name=self.find_affix_from_value(widget.currentText()))\n                elif layout is not None and isinstance(layout, QFormLayout):\n                    pool.minCount = layout.itemAt(1).widget().value()\n                    pool.minGreaterAffixCount = layout.itemAt(3).widget().value()\n\n        for pool in self.inherent_pool:\n            for i in range(self.inherentListLayout.count()):\n                widget = self.inherentListLayout.itemAt(i).widget()\n                if isinstance(widget, IgnoreScrollWheelComboBox):\n                    pool.count[i] = AffixFilterModel(name=self.find_affix_from_value(widget.currentText()))\n        self.changed = False\n        self.item.root[self.item_name].affixPool = self.affix_pool\n        if self.inherent_pool:\n            self.item.root[self.item_name].inherentPool = self.inherent_pool\n        self.item.root[self.item_name].minPower = self.min_power\n        return self.item\n\n    def save_item_create(self):\n        new_item = ItemFilterModel()\n        new_item.itemType = self.item_types\n        new_item.minPower = self.minPowerEdit.value()\n        new_item.affixPool = []\n        new_item.inherentPool = []\n        affix_filter_count_list = []\n        minCount = 0\n        minGreaterAffixCount = 0\n\n        for i in range(self.affixListLayout.count()):\n            widget = self.affixListLayout.itemAt(i).widget()\n            layout = self.affixListLayout.itemAt(i).layout()\n            if widget is not None:\n                if isinstance(widget, IgnoreScrollWheelComboBox):\n                    affix_filter_count_list.append(\n                        AffixFilterModel(name=self.find_affix_from_value(widget.currentText()))\n                    )\n            elif layout is not None and isinstance(layout, QFormLayout):\n                minCount = layout.itemAt(1).widget().value()\n                minGreaterAffixCount = layout.itemAt(3).widget().value()\n        affix_filter_count = AffixFilterCountModel(\n            minCount=minCount, minGreaterAffixCount=minGreaterAffixCount, count=affix_filter_count_list\n        )\n        new_item.affixPool.append(affix_filter_count)\n\n        if self.inherentListLayout:\n            inherent_filter_count_list = []\n            for i in range(self.inherentListLayout.count()):\n                widget = self.inherentListLayout.itemAt(i).widget()\n                if isinstance(widget, IgnoreScrollWheelComboBox):\n                    inherent_filter_count_list.append(\n                        AffixFilterModel(name=self.find_affix_from_value(widget.currentText()))\n                    )\n            inherent_filter_count = AffixFilterCountModel(count=inherent_filter_count_list)\n            new_item.inherentPool.append(inherent_filter_count)\n\n        return DynamicItemFilterModel(**{self.item_name: new_item})\n\n    def item_changed(self):\n        self.changed = True\n\n    def has_changes(self):\n        return self.changed\n"
  },
  {
    "path": "src/gui/dialog.py",
    "content": "from PyQt6.QtCore import Qt\nfrom PyQt6.QtWidgets import (\n    QCheckBox,\n    QComboBox,\n    QCompleter,\n    QDialog,\n    QFormLayout,\n    QGroupBox,\n    QHBoxLayout,\n    QLabel,\n    QLineEdit,\n    QMessageBox,\n    QPushButton,\n    QScrollArea,\n    QSizePolicy,\n    QSpinBox,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.profile_models import (\n    AffixFilterCountModel,\n    AffixFilterModel,\n    DynamicItemFilterModel,\n    ItemFilterModel,\n    ItemRarity,\n    TributeFilterModel,\n)\nfrom src.dataloader import Dataloader\nfrom src.gui.config_tab import IgnoreScrollWheelComboBox\nfrom src.gui.importer.gui_common import MAX_POWER\nfrom src.item.data.item_type import ItemType\n\n\nclass IgnoreScrollWheelSpinBox(QSpinBox):\n    def __init__(self):\n        super().__init__()\n        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)\n\n    def wheelEvent(self, event):\n        if self.hasFocus():\n            return QSpinBox.wheelEvent(self, event)\n\n        return event.ignore()\n\n\nclass MinPowerDialog(QDialog):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"Set Min Power\")\n        self.setFixedSize(250, 150)\n        self.main_layout = QVBoxLayout()\n\n        self.form_layout = QFormLayout()\n        self.label = QLabel(\"Min Power:\")\n        self.spinBox = IgnoreScrollWheelSpinBox()\n        self.spinBox.setRange(0, MAX_POWER)\n        self.spinBox.setValue(MAX_POWER)\n        self.form_layout.addRow(self.label, self.spinBox)\n        self.main_layout.addLayout(self.form_layout)\n\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.buttonLayout)\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return self.spinBox.value()\n\n\nclass MinGreaterDialog(QDialog):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"Set Min Greater Affix\")\n        self.setFixedSize(250, 150)\n        self.main_layout = QVBoxLayout()\n\n        self.form_layout = QFormLayout()\n        self.label = QLabel(\"Min Greater Affix:\")\n        self.spinBox = IgnoreScrollWheelSpinBox()\n        self.spinBox.setRange(0, 4)\n        self.spinBox.setValue(0)\n        self.form_layout.addRow(self.label, self.spinBox)\n        self.main_layout.addLayout(self.form_layout)\n\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.buttonLayout)\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return self.spinBox.value()\n\n\nclass MinPercentDialog(QDialog):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"Set Min Percent Of Affix\")\n        self.setFixedSize(250, 150)\n        self.main_layout = QVBoxLayout()\n\n        self.form_layout = QFormLayout()\n        self.label = QLabel(\"Min Percent Of Affix:\")\n        self.spinBox = IgnoreScrollWheelSpinBox()\n        self.spinBox.setRange(0, 100)\n        self.spinBox.setValue(70)\n        self.form_layout.addRow(self.label, self.spinBox)\n        self.main_layout.addLayout(self.form_layout)\n\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.buttonLayout)\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return self.spinBox.value()\n\n\nclass CreateItem(QDialog):\n    def __init__(self, item_list: list[str], parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"Create Item\")\n        self.setFixedSize(300, 150)\n        self.main_layout = QVBoxLayout()\n\n        self.form_layout = QFormLayout()\n\n        self.name_label = QLabel(\"Item Name:\")\n        self.name_input = QLineEdit()\n        self.form_layout.addRow(self.name_label, self.name_input)\n        self.item_list = item_list\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.form_layout)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def accept(self):\n        if not self.name_input.text():\n            QMessageBox.warning(self, \"Warning\", \"Item name cannot be empty\")\n            return\n        if self.name_input.text() in self.item_list:\n            QMessageBox.warning(self, \"Warning\", \"Item name already exist\")\n            return\n        super().accept()\n\n    def get_value(self):\n        item_name = self.name_input.text()\n        item_type = ItemType.Amulet\n\n        item = ItemFilterModel()\n        item.itemType = [item_type]\n        item.affixPool = [\n            AffixFilterCountModel(count=[AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())))], minCount=2)\n        ]\n        item.minPower = 100\n        return DynamicItemFilterModel(**{item_name: item})\n\n\nclass DeleteItem(QDialog):\n    def __init__(self, item_names, parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"Delete Items\")\n        self.setFixedSize(300, 200)\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        self.groupbox = QGroupBox(\"Items\")\n        scroll_area = QScrollArea(self)\n        scroll_widget = QWidget(scroll_area)\n        scrollable_layout = QVBoxLayout(scroll_widget)\n        self.groupbox_layout = QVBoxLayout()\n\n        label = QLabel(\"Select items to delete:\")\n        label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.groupbox_layout.addWidget(label)\n\n        self.checkbox_list = []\n        for name in item_names:\n            checkbox = QCheckBox(name)\n            scrollable_layout.addWidget(checkbox)\n            self.checkbox_list.append(checkbox)\n        scroll_widget.setLayout(scrollable_layout)\n        scroll_area.setWidget(scroll_widget)\n        self.groupbox_layout.addWidget(scroll_area)\n        self.groupbox.setLayout(self.groupbox_layout)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addWidget(self.groupbox)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]\n\n\nclass DeleteAffixPool(QDialog):\n    def __init__(self, nb_affix_pool, inherent: bool = False, parent=None):\n        super().__init__(parent)\n        if inherent:\n            self.setWindowTitle(\"Delete Inherent Pool\")\n            self.groupbox = QGroupBox(\"Inherent Pool\")\n        else:\n            self.setWindowTitle(\"Delete Affix Pool\")\n            self.groupbox = QGroupBox(\"Affix Pool\")\n        self.setFixedSize(300, 200)\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        scroll_area = QScrollArea(self)\n        scroll_widget = QWidget(scroll_area)\n        scrollable_layout = QVBoxLayout(scroll_widget)\n        self.groupbox_layout = QVBoxLayout()\n\n        label = QLabel(\"Select items to delete:\")\n        label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.groupbox_layout.addWidget(label)\n\n        self.checkbox_list = []\n        for i in range(nb_affix_pool):\n            checkbox = QCheckBox(f\"Count {i}\")\n            scrollable_layout.addWidget(checkbox)\n            self.checkbox_list.append(checkbox)\n        scroll_widget.setLayout(scrollable_layout)\n        scroll_area.setWidget(scroll_widget)\n        self.groupbox_layout.addWidget(scroll_area)\n        self.groupbox.setLayout(self.groupbox_layout)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addWidget(self.groupbox)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]\n\n\nclass CreateSigil(QDialog):\n    def __init__(self, whitelist_sigils: list[str], blacklist_sigils: list[str], parent=None):\n        super().__init__(parent)\n\n        self.whitelist_sigils = whitelist_sigils\n        self.blacklist_sigils = blacklist_sigils\n\n        self.setWindowTitle(\"Create Sigil\")\n        self.setFixedSize(300, 150)\n\n        self.main_layout = QVBoxLayout()\n        self.form_layout = QFormLayout()\n\n        self.name_label = QLabel(\"Dungeon:\")\n        self.name_input = IgnoreScrollWheelComboBox()\n        self.name_input.setEditable(True)\n        self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.name_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.name_input.addItems(sorted(Dataloader().affix_sigil_dict_all[\"dungeons\"].values()))\n        self.type_label = QLabel(\"Type: \")\n        self.type_input = IgnoreScrollWheelComboBox()\n        self.type_input.setEditable(True)\n        self.type_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.type_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.type_input.addItems([\"whitelist\", \"blacklist\"])\n        self.form_layout.addRow(self.name_label, self.name_input)\n        self.form_layout.addRow(self.type_label, self.type_input)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.form_layout)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def accept(self):\n        if self.type_input.currentText() == \"whitelist\" and self.name_input.currentText() in self.whitelist_sigils:\n            QMessageBox.warning(self, \"Warning\", \"Sigil already exist in whitelist. You can modify the existing one.\")\n            return\n        if self.type_input.currentText() == \"blacklist\" and self.name_input.currentText() in self.blacklist_sigils:\n            QMessageBox.warning(self, \"Warning\", \"Sigil already exist in whitelist. You can modify the existing one.\")\n            return\n        super().accept()\n\n    def get_value(self):\n        sigil_name = self.name_input.currentText()\n        type_name = self.type_input.currentText()\n        return sigil_name, type_name\n\n\nclass RemoveSigil(QDialog):\n    def __init__(self, sigils: list[str], blacklist: bool = False, parent=None):\n        super().__init__(parent)\n        self.sigils = sigils\n        if blacklist:\n            self.setWindowTitle(\"Delete Blacklist Sigil\")\n            self.groupbox = QGroupBox(\"Blacklist\")\n        else:\n            self.setWindowTitle(\"Delete Whitelist Sigil\")\n            self.groupbox = QGroupBox(\"Whitelist\")\n        self.setFixedSize(300, 300)\n\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        scroll_area = QScrollArea(self)\n        scroll_widget = QWidget(scroll_area)\n        scrollable_layout = QVBoxLayout(scroll_widget)\n        self.groupbox_layout = QVBoxLayout()\n\n        label = QLabel(\"Select Sigils to delete:\")\n        label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.groupbox_layout.addWidget(label)\n\n        self.checkbox_list = []\n        for sigil in self.sigils:\n            checkbox = QCheckBox(sigil)\n            scrollable_layout.addWidget(checkbox)\n            self.checkbox_list.append(checkbox)\n        scroll_widget.setLayout(scrollable_layout)\n        scroll_area.setWidget(scroll_widget)\n        self.groupbox_layout.addWidget(scroll_area)\n        self.groupbox.setLayout(self.groupbox_layout)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addWidget(self.groupbox)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]\n\n\nclass CreateTribute(QDialog):\n    def __init__(self, tributes: list[str], parent=None):\n        super().__init__(parent)\n\n        self.tributes = tributes\n\n        self.setWindowTitle(\"Create Tribute\")\n        self.setFixedSize(300, 150)\n\n        self.main_layout = QVBoxLayout()\n        self.form_layout = QFormLayout()\n\n        self.name_label = QLabel(\"Tribute:\")\n        self.name_input = IgnoreScrollWheelComboBox()\n        self.name_input.setEditable(True)\n        self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.name_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.name_input.addItems(sorted(Dataloader().tribute_dict.values()))\n        self.form_layout.addRow(self.name_label, self.name_input)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.form_layout)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def accept(self):\n        reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()}\n        tribute_name = reverse_dict.get(self.name_input.currentText())\n        if tribute_name is None:\n            QMessageBox.warning(self, \"Warning\", \"Select a valid tribute from the list.\")\n            return\n        if tribute_name in self.tributes:\n            QMessageBox.warning(self, \"Warning\", \"Tribute already exist. You can modify the existing one.\")\n            return\n        super().accept()\n\n    def get_value(self):\n        reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()}\n        tribute_name = reverse_dict.get(self.name_input.currentText())\n        return TributeFilterModel(name=tribute_name, rarities=[])\n\n\nclass AddTributeRarity(QDialog):\n    def __init__(self, rarities: list[ItemRarity], parent=None):\n        super().__init__(parent)\n\n        self.rarities = {ItemRarity(rarity) for rarity in rarities}\n\n        self.setWindowTitle(\"Add Tribute Rarity\")\n        self.setFixedSize(300, 150)\n\n        self.main_layout = QVBoxLayout()\n        self.form_layout = QFormLayout()\n\n        self.rarity_label = QLabel(\"Rarity:\")\n        self.rarity_input = IgnoreScrollWheelComboBox()\n        self.rarity_input.setEditable(True)\n        self.rarity_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.rarity_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.rarity_input.addItems([rarity.name for rarity in ItemRarity])\n        self.form_layout.addRow(self.rarity_label, self.rarity_input)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.form_layout)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def accept(self):\n        rarity_name = self.rarity_input.currentText()\n        if rarity_name not in ItemRarity.__members__:\n            QMessageBox.warning(self, \"Warning\", \"Select a valid rarity from the list.\")\n            return\n\n        rarity = ItemRarity[rarity_name]\n        if rarity in self.rarities:\n            QMessageBox.warning(self, \"Warning\", \"Rarity already exists in this tribute filter.\")\n            return\n\n        super().accept()\n\n    def get_value(self):\n        rarity = ItemRarity[self.rarity_input.currentText()]\n        return TributeFilterModel(rarities=[rarity])\n\n\nclass RemoveTribute(QDialog):\n    def __init__(self, tributes: list[str], parent=None):\n        super().__init__(parent)\n        self.tributes = tributes\n        self.setWindowTitle(\"Delete Tributes\")\n        self.groupbox = QGroupBox(\"Tributes\")\n        self.setFixedSize(300, 300)\n\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        scroll_area = QScrollArea(self)\n        scroll_widget = QWidget(scroll_area)\n        scrollable_layout = QVBoxLayout(scroll_widget)\n        self.groupbox_layout = QVBoxLayout()\n\n        label = QLabel(\"Select Tributes to delete:\")\n        label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.groupbox_layout.addWidget(label)\n\n        self.checkbox_list = []\n        for tribute in self.tributes:\n            checkbox = QCheckBox(Dataloader().tribute_dict[tribute]) if tribute else QCheckBox(\"None\")\n            scrollable_layout.addWidget(checkbox)\n            self.checkbox_list.append(checkbox)\n        scroll_widget.setLayout(scrollable_layout)\n        scroll_area.setWidget(scroll_widget)\n        self.groupbox_layout.addWidget(scroll_area)\n        self.groupbox.setLayout(self.groupbox_layout)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addWidget(self.groupbox)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()}\n        return [reverse_dict.get(checkbox.text()) for checkbox in self.checkbox_list if checkbox.isChecked()]\n\n\nclass AddAspectUpgrade(QDialog):\n    def __init__(self, aspect_upgrades: list[str], parent=None):\n        super().__init__(parent)\n\n        self.aspect_upgrades = aspect_upgrades\n\n        self.setWindowTitle(\"Add Aspect\")\n        self.setFixedSize(300, 150)\n\n        self.main_layout = QVBoxLayout()\n        self.form_layout = QFormLayout()\n\n        unchosen_aspect_ugprades = [x for x in Dataloader().aspect_list if x not in aspect_upgrades]\n\n        self.name_label = QLabel(\"Aspect:\")\n        self.name_input = IgnoreScrollWheelComboBox()\n        self.name_input.setEditable(True)\n        self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.name_input.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.name_input.completer().setFilterMode(Qt.MatchFlag.MatchContains)\n        self.name_input.addItems(unchosen_aspect_ugprades)\n        self.form_layout.addRow(self.name_label, self.name_input)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addLayout(self.form_layout)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return self.name_input.currentText()\n\n\nclass CreateUnique(QDialog):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"Create Unique\")\n        self.groupbox = QGroupBox(\"Unique Infos\")\n        self.setFixedSize(300, 300)\n\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        self.groupbox_layout = QVBoxLayout()\n\n        label = QLabel(\"Select info to add to the Unique:\")\n        label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n        self.groupbox_layout.addWidget(label)\n\n        self.checkbox_list = []\n\n        checkbox_aspect = QCheckBox(\"Aspect\")\n        checkbox_affixe = QCheckBox(\"Affixes\")\n        self.groupbox_layout.addWidget(checkbox_aspect)\n        self.groupbox_layout.addWidget(checkbox_affixe)\n        self.checkbox_list.append(checkbox_aspect)\n        self.checkbox_list.append(checkbox_affixe)\n\n        self.groupbox.setLayout(self.groupbox_layout)\n        self.buttonLayout = QHBoxLayout()\n        self.okButton = QPushButton(\"OK\")\n        self.okButton.clicked.connect(self.accept)\n        self.cancelButton = QPushButton(\"Cancel\")\n        self.cancelButton.clicked.connect(self.reject)\n\n        self.buttonLayout.addWidget(self.okButton)\n        self.buttonLayout.addWidget(self.cancelButton)\n\n        self.main_layout.addWidget(self.groupbox)\n        self.main_layout.addLayout(self.buttonLayout)\n\n        self.setLayout(self.main_layout)\n\n    def get_value(self):\n        return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]\n"
  },
  {
    "path": "src/gui/importer/__init__.py",
    "content": ""
  },
  {
    "path": "src/gui/importer/d4builds.py",
    "content": "import logging\nimport re\nimport time\nfrom typing import TYPE_CHECKING\n\nimport lxml.html\nfrom selenium.webdriver.common.by import By\nfrom selenium.webdriver.support import expected_conditions as EC\nfrom selenium.webdriver.support.wait import WebDriverWait\n\nimport src.logger\nfrom src.config.profile_models import (\n    AffixFilterCountModel,\n    AffixFilterModel,\n    AspectUniqueFilterModel,\n    ItemFilterModel,\n    ProfileModel,\n)\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.gui_common import (\n    add_to_profiles,\n    build_default_profile_file_name,\n    fix_offhand_type,\n    fix_weapon_type,\n    get_class_name,\n    match_to_enum,\n    retry_importer,\n    save_as_profile,\n    sort_profile_filters,\n    update_mingreateraffixcount,\n)\nfrom src.gui.importer.importer_config import ImportConfig\nfrom src.gui.importer.paragon_export import build_paragon_profile_payload, extract_d4builds_paragon_steps\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.item_type import WEAPON_TYPES, ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.descr.text import clean_str, closest_match\nfrom src.scripts import correct_name\n\nif TYPE_CHECKING:\n    from selenium.webdriver.chromium.webdriver import ChromiumDriver\n\nLOGGER = logging.getLogger(__name__)\n\nBASE_URL = \"https://d4builds.gg/builds\"\nBUILD_OVERVIEW_XPATH = \"//*[@class='builder__stats__list']\"\nCLASS_XPATH = \"//*[contains(@class, 'builder__header__name')]\"\nBUILD_DESCRIPTION_XPATH = \"//*[contains(@class, 'builder__header__description')]\"\nBUILD_HEADER_INPUT_XPATH = \"//*[contains(@class, 'builder__header__input')]\"\nVARIANT_INPUT_XPATH = \"//*[contains(@class, 'builder__variant__input')]\"\nSEASON_DROPDOWN_XPATH = (\n    \"//*[contains(@class, 'builder__gear')]/*[contains(@class, 'builder__dropdown__wrapper')]\"\n    \"//*[contains(@class, 'dropdown__button') and starts-with(normalize-space(), 'Season ')]\"\n)\nITEM_GROUP_XPATH = \".//*[contains(@class, 'builder__stats__group')]\"\nITEM_SLOT_XPATH = \".//*[contains(@class, 'builder__stats__slot')]\"\nITEM_STATS_XPATH = \".//*[contains(@class, 'dropdown__button__wrapper')]\"\nGA_XPATH = \".//*[contains(@class, 'greater__affix__button--filled')]\"\nPAPERDOLL_ITEM_SLOT_XPATH = \".//*[contains(@class, 'builder__gear__slot')]\"\nPAPERDOLL_ITEM_UNIQUE_NAME_XPATH = \".//*[contains(@class, 'builder__gear__name--')]\"\nPAPERDOLL_ITEM_XPATH = \".//*[contains(@class, 'builder__gear__item') and not(contains(@class, 'disabled'))]\"\nPAPERDOLL_LEGENDARY_ASPECT_XPATH = (\n    \"//*[@class='builder__gear__name' and not(contains(@class, 'builder__gear__name--'))]\"\n)\nPAPERDOLL_XPATH = \"//*[contains(@class, 'builder__gear__items')]\"\nTEMPERING_ICON_XPATH = \".//*[contains(@src, 'tempering_02.png')]\"\nSANCTIFIED_ICON_XPATH = \".//*[contains(@src, 'sanctified_icon.png')]\"\nUNIQUE_ICON_XPATH = \".//*[contains(@src, '/Uniques/')]\"\n\n\nclass D4BuildsException(Exception):\n    pass\n\n\n@retry_importer(inject_webdriver=True)\ndef import_d4builds(config: ImportConfig, driver: ChromiumDriver = None):\n    url = config.url.strip().replace(\"\\n\", \"\")\n    if BASE_URL not in url:\n        LOGGER.error(\"Invalid url, please use a d4builds url\")\n        return\n    LOGGER.info(f\"Loading {url}\")\n    driver.get(url)\n    wait = WebDriverWait(driver, 10)\n    wait.until(EC.presence_of_element_located((By.XPATH, BUILD_OVERVIEW_XPATH)))\n    wait.until(EC.presence_of_element_located((By.XPATH, PAPERDOLL_XPATH)))\n    time.sleep(\n        5\n    )  # super hacky but I didn't find anything else. The page is not fully loaded when the above wait is done\n    data = lxml.html.fromstring(driver.page_source)\n    class_name, build_header, season_number, variant_name = _extract_build_metadata(data=data)\n    build_name = build_header or class_name\n    if not (items := data.xpath(BUILD_OVERVIEW_XPATH)):\n        LOGGER.error(msg := \"No items found\")\n        raise D4BuildsException(msg)\n    slot_to_unique_name_map = _get_item_slots(data=data)\n    finished_filters = []\n    aspect_upgrade_filters = _get_legendary_aspects(data=data)\n    for item in items[0]:\n        item_filter = ItemFilterModel()\n        if not (slot := item.xpath(ITEM_SLOT_XPATH)[1].tail):\n            LOGGER.error(\"No item_type found\")\n            continue\n        if slot not in slot_to_unique_name_map:\n            LOGGER.warning(f\"Empty slots are not supported. Skipping: {slot}\")\n            continue\n        if not (stats := item.xpath(ITEM_STATS_XPATH)):\n            LOGGER.error(f\"No stats found for {slot=}\")\n            continue\n        item_type = None\n        rarity = None\n        affixes = []\n        inherents = []\n\n        if slot_to_unique_name_map[slot]:\n            unique_name, rarity = slot_to_unique_name_map[slot]\n            try:\n                item_filter.uniqueAspect = AspectUniqueFilterModel(name=unique_name)\n            except Exception:\n                LOGGER.exception(\n                    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.\"\n                )\n\n        is_weapon = \"weapon\" in slot.lower()\n        for stat in stats:\n            if stat.xpath(TEMPERING_ICON_XPATH) or stat.xpath(SANCTIFIED_ICON_XPATH):\n                continue\n            if \"filled\" not in stat.xpath(\"../..\")[0].attrib[\"class\"]:\n                continue\n            affix_name = _get_affix_name(stat)\n            if not affix_name:\n                LOGGER.warning(f\"Slot {slot} is missing an affix, skipping import of that affix.\")\n                continue\n            if is_weapon and (x := fix_weapon_type(input_str=affix_name)) is not None:\n                item_type = x\n                continue\n            if (\n                \"offhand\" in slot.lower()\n                and (x := fix_offhand_type(input_str=affix_name, class_str=class_name)) is not None\n            ):\n                item_type = x\n                if any(\n                    substring in affix_name.lower() for substring in [\"focus\", \"offhand\", \"shield\", \"totem\"]\n                ):  # special line indicating the item type\n                    continue\n            affix_obj = Affix(\n                name=closest_match(clean_str(_corrections(input_str=affix_name)), Dataloader().affix_dict)\n            )\n            if affix_obj.name is None:\n                LOGGER.error(f\"Couldn't match {affix_name=}\")\n                continue\n            if config.import_greater_affixes and stat.xpath(\"../../../..\")[0].xpath(GA_XPATH):\n                affix_obj.type = AffixType.greater\n            affixes.append(affix_obj)\n\n        if not affixes:\n            continue\n\n        item_type = (\n            match_to_enum(enum_class=ItemType, target_string=re.sub(r\"\\d+\", \"\", slot.replace(\" \", \"\")))\n            if item_type is None\n            else item_type\n        )\n        if item_type is None:\n            if is_weapon:\n                LOGGER.warning(\n                    f\"Couldn't find an item_type for weapon slot {slot}, defaulting to all weapon types instead.\"\n                )\n                item_filter.itemType = WEAPON_TYPES\n            else:\n                item_filter.itemType = []\n                LOGGER.warning(f\"Couldn't match item_type: {slot}. Please edit manually\")\n        else:\n            item_filter.itemType = [item_type]\n\n        # We don't bother importing affixes for mythics\n        if rarity != ItemRarity.Mythic:\n            item_filter.affixPool = [\n                AffixFilterCountModel(\n                    count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes],\n                    minCount=1 if rarity == ItemRarity.Unique else 3,\n                )\n            ]\n            update_mingreateraffixcount(item_filter, config.require_greater_affixes)\n            if inherents:\n                item_filter.inherentPool = [\n                    AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents])\n                ]\n        item_filter.minPower = 100\n        filter_name_template = item_filter.itemType[0].name if item_type else slot.replace(\" \", \"\")\n        filter_name = filter_name_template\n        i = 2\n        while any(filter_name == next(iter(x)) for x in finished_filters):\n            filter_name = f\"{filter_name_template}{i}\"\n            i += 1\n        finished_filters.append({filter_name: item_filter})\n    profile = ProfileModel(name=\"imported profile\", Affixes=sort_profile_filters(finished_filters))\n    if config.import_aspect_upgrades and aspect_upgrade_filters:\n        profile.AspectUpgrades = aspect_upgrade_filters\n\n    file_name = config.custom_file_name or build_default_profile_file_name(\n        source_name=\"d4builds\",\n        class_name=class_name,\n        season_number=season_number,\n        build_header=build_header,\n        variant_name=variant_name,\n    )\n\n    # Optionally embed Paragon data into the profile model before saving\n    if config.export_paragon:\n        steps = extract_d4builds_paragon_steps(driver, class_name=class_name)\n        if steps:\n            profile.Paragon = build_paragon_profile_payload(\n                build_name=build_name, source_url=url, paragon_boards_list=steps\n            )\n        else:\n            LOGGER.warning(\"Paragon export enabled, but no paragon data was found on this D4Builds page.\")\n\n    corrected_file_name = save_as_profile(file_name=file_name, profile=profile, url=url)\n    if config.add_to_profiles:\n        add_to_profiles(corrected_file_name)\n\n    LOGGER.info(\"Finished\")\n\n\ndef _corrections(input_str: str) -> str:\n    input_str = input_str.lower()\n    match input_str:\n        case \"max life\":\n            return \"maximum life\"\n        case \"total armor\":\n            return \"armor\"\n    if \"ranks to\" in input_str or \"ranks of\" in input_str or \"ranks\" in input_str:\n        return input_str.replace(\"ranks to\", \"to\").replace(\"ranks of\", \"to\").replace(\"ranks\", \"to\")\n    return input_str\n\n\ndef _extract_build_metadata(data: lxml.html.HtmlElement) -> tuple[str, str, str, str]:\n    class_name = \"Unknown\"\n    if header_nodes := data.xpath(CLASS_XPATH):\n        class_name = get_class_name(\" \".join(header_nodes[0].text_content().split()))\n    build_header = \"\"\n    if description_nodes := data.xpath(BUILD_DESCRIPTION_XPATH):\n        build_header = \" \".join(description_nodes[0].text_content().split())\n    elif input_nodes := data.xpath(BUILD_HEADER_INPUT_XPATH):\n        build_header = str(input_nodes[0].get(\"value\") or \"\").strip()\n    season_number = _extract_d4builds_season_number(data=data)\n    variant_name = _extract_variant_name(data=data)\n    return class_name, build_header, season_number, variant_name\n\n\ndef _extract_variant_name(data: lxml.html.HtmlElement) -> str:\n    if variant_nodes := data.xpath(VARIANT_INPUT_XPATH):\n        if variant_value := str(variant_nodes[0].get(\"value\") or \"\").strip():\n            return variant_value\n        return \" \".join(variant_nodes[0].text_content().split())\n    return \"\"\n\n\ndef _extract_d4builds_season_number(data: lxml.html.HtmlElement) -> str:\n    if not (season_nodes := data.xpath(SEASON_DROPDOWN_XPATH)):\n        return \"\"\n    season_text = \" \".join(season_nodes[0].text_content().split())\n    if season_match := re.search(r\"\\bSeason\\s+(\\d+)\\b\", season_text, flags=re.IGNORECASE):\n        return season_match.group(1)\n    return \"\"\n\n\ndef _get_item_slots(data: lxml.html.HtmlElement) -> dict[str, tuple[str, ItemRarity] | None]:\n    result = {}\n    if not (paperdoll := data.xpath(PAPERDOLL_XPATH)):\n        LOGGER.error(msg := \"No paperdoll found\")\n        raise D4BuildsException(msg)\n    if not (items := paperdoll[0].xpath(PAPERDOLL_ITEM_XPATH)):\n        LOGGER.error(msg := \"No items found\")\n        raise D4BuildsException(msg)\n    for item in items:\n        if item.xpath(PAPERDOLL_ITEM_SLOT_XPATH):\n            slot = item.xpath(PAPERDOLL_ITEM_SLOT_XPATH)[0].text\n            if slot == \"2H Weapon\":  # This happens when a build has a weapon and no offhand\n                slot = \"Weapon\"\n            unique_name_elem = item.xpath(PAPERDOLL_ITEM_UNIQUE_NAME_XPATH)\n            if unique_name_elem:\n                unique_name = unique_name_elem[0].text\n                rarity = ItemRarity.Mythic if \"mythic\" in str(unique_name_elem[0].attrib) else ItemRarity.Unique\n                result[slot] = (unique_name, rarity)\n            else:\n                result[slot] = None\n    return result\n\n\ndef _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]:\n    result = []\n    if not (paperdoll := data.xpath(PAPERDOLL_XPATH)):\n        # Shouldn't happen, earlier code would have thrown an exception\n        return result\n\n    aspects = paperdoll[0].xpath(PAPERDOLL_LEGENDARY_ASPECT_XPATH)\n    for aspect in aspects:\n        aspect_name = correct_name(aspect.text.lower().replace(\"aspect\", \"\").strip())\n\n        if aspect_name not in Dataloader().aspect_list:\n            LOGGER.warning(\n                f\"Legendary aspect '{aspect_name}' that is not in our aspect data, unable to add to AspectUpgrades.\"\n            )\n        else:\n            result.append(aspect_name)\n\n    return result\n\n\ndef _get_affix_name(stat: lxml.html.HtmlElement) -> str:\n    \"\"\"Bloodied attributes are saved in some special HTML that we need to remove here.\"\"\"\n    for span in stat.xpath(\"./span\"):\n        affix_name = \" \".join(span.text_content().split())\n        if affix_name:\n            return affix_name\n    return \"\"\n\n\nif __name__ == \"__main__\":\n    src.logger.setup()\n    URLS = [\"https://d4builds.gg/builds/whirlwind-barbarian-endgame/?var=4\"]\n\n    from selenium import webdriver\n\n    options = webdriver.ChromeOptions()\n    options.add_argument(\"--headless=new\")\n    options.add_argument(\"log-level=3\")\n    driver = webdriver.Chrome(options=options)\n\n    for X in URLS:\n        config = ImportConfig(\n            url=X,\n            import_aspect_upgrades=True,\n            add_to_profiles=False,\n            import_greater_affixes=True,\n            require_greater_affixes=True,\n            export_paragon=True,\n            custom_file_name=None,\n        )\n        import_d4builds(config, driver)\n"
  },
  {
    "path": "src/gui/importer/diablo_trade.py",
    "content": "import dataclasses\nimport datetime\nimport json\nimport logging\nimport pathlib\nfrom typing import TYPE_CHECKING, Any\nfrom urllib.parse import parse_qs, urlencode, urlparse, urlunparse\n\nimport lxml.html\nfrom pydantic import ValidationError\n\nimport src.logger\nfrom src.config.loader import IniConfigLoader\nfrom src.config.profile_models import AffixFilterCountModel, AffixFilterModel, ItemFilterModel, ProfileModel\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.gui_common import format_number_as_short_string, match_to_enum, retry_importer, save_as_profile\nfrom src.item.data.affix import Affix\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.descr.text import clean_str, closest_match\n\nif TYPE_CHECKING:\n    import seleniumbase\n\nLOGGER = logging.getLogger(__name__)\nLOGGER.propagate = True\nBASE_URL = \"diablo.trade/listings/\"\n\n\n@dataclasses.dataclass\nclass _AnnotatedFilter:\n    filter: ItemFilterModel | None = None\n    max_price: int | None = None\n    min_price: int | None = None\n\n\n@dataclasses.dataclass\nclass _Listing:\n    affixes: list[Affix] | None = None\n    inherents: list[Affix] | None = None\n    item_power: int = 0\n    item_rarity: ItemRarity | None = None\n    item_type: ItemType | None = None\n    price: int = 0\n    raw_data: dict[str, Any] | None = None\n\n\nclass DiabloTradeException(Exception):\n    pass\n\n\n@retry_importer(inject_webdriver=True, uc=True)\ndef import_diablo_trade(url: str, max_listings: int, driver: seleniumbase.Driver = None):\n    url = url.strip().replace(\"\\n\", \"\")\n    if BASE_URL not in url:\n        LOGGER.error(\"Invalid url, please use a diablo.trade filter url\")\n        return\n    LOGGER.info(\"Start fetching listings\")\n    all_listings = []\n    cursor = 1\n    while True:\n        api_url = _construct_api_url(listing_url=url, cursor=cursor)\n        try:\n            driver.default_get(url=api_url)\n            source = lxml.html.fromstring(driver.get_page_source())\n            data = json.loads(source.text_content().strip())\n        except Exception:\n            LOGGER.exception(\"Can't fetch listings, saving current data\")\n            break\n        if not (listings := data[\"data\"]):\n            LOGGER.debug(\"Reached end\")\n            break\n        for listing in listings:\n            if not (item_type := match_to_enum(enum_class=ItemType, target_string=listing[\"itemType\"])):\n                continue\n            if item_type in [None, ItemType.Material]:\n                continue\n            if \"rarity\" not in listing:\n                if \"name\" in listing:\n                    LOGGER.debug(f\"Skipping {listing['name']} because it had no rarity.\")\n                continue\n            item_rarity = match_to_enum(enum_class=ItemRarity, target_string=listing[\"rarity\"])\n            if item_rarity != ItemRarity.Legendary:\n                continue\n            listing_obj = _Listing(\n                affixes=_create_affixes_from_api_dict(listing[\"affixes\"]),\n                inherents=_create_affixes_from_api_dict(listing[\"implicits\"]),\n                item_power=listing[\"itemPower\"],\n                item_rarity=ItemRarity.Legendary,\n                item_type=item_type,\n                price=listing[\"price\"],\n                raw_data=listing,\n            )\n            try:\n                assert listing_obj.item_type is not None\n                assert len(listing[\"affixes\"]) == len(listing_obj.affixes)\n                assert len(listing[\"implicits\"]) == len(listing_obj.inherents)\n            except AssertionError:\n                LOGGER.error(f\"If you see this create a bug ticket! {listing=}\")\n            all_listings.append(listing_obj)\n        LOGGER.info(f\"Fetched {len(all_listings)} listings\")\n        if len(all_listings) >= max_listings:\n            break\n        cursor += 1\n\n    try:\n        profile = ProfileModel(name=\"diablo_trade\", Affixes=_create_filters_from_items(items=all_listings))\n    except Exception as exc:\n        LOGGER.exception(msg := \"Failed to validate profile. Dumping data for debugging.\")\n        with pathlib.Path(\n            IniConfigLoader().user_dir\n            / f\"diablo_trade_dump_{datetime.datetime.now(tz=datetime.UTC).strftime('%Y_%m_%d_%H_%M_%S')}.json\"\n        ).open(\"w\", encoding=\"utf-8\") as f:\n            json.dump(all_listings, f, indent=4, sort_keys=True)\n        raise DiabloTradeException(msg) from exc\n\n    LOGGER.info(f\"Saving profile with {len(profile.Affixes)} filters\")\n    save_as_profile(\n        file_name=f\"diablo_trade_{datetime.datetime.now(tz=datetime.UTC).strftime('%Y_%m_%d_%H_%M_%S')}\",\n        profile=profile,\n        url=url,\n    )\n    LOGGER.info(\"Finished\")\n\n\ndef _construct_api_url(listing_url: str, cursor: int = 1) -> str:\n    parsed_url = urlparse(listing_url)\n    query_dict = parse_qs(parsed_url.query)\n    query_dict[\"cursor\"] = [str(cursor)]\n    new_query_string = urlencode(query_dict, doseq=True)\n    return urlunparse((\n        parsed_url.scheme,\n        parsed_url.netloc,\n        \"api/items/search\",\n        parsed_url.params,\n        new_query_string,\n        parsed_url.fragment,\n    ))\n\n\ndef _create_affixes_from_api_dict(affixes: list[dict[str, Any]]) -> list[Affix]:\n    res = []\n    for affix in affixes:\n        new_affix = Affix(name=closest_match(clean_str(affix[\"name\"]), Dataloader().affix_dict), value=affix[\"value\"])\n        if isinstance(new_affix.value, list):\n            if new_affix.value:\n                new_affix.value = new_affix.value[0]\n            else:\n                new_affix.value = 0\n        res.append(new_affix)\n    if len(affixes) != len(res) or any(x.name is None for x in res):\n        LOGGER.error(f\"If you see this create a bug ticket! {affixes=}\")\n    return res\n\n\ndef _create_filters_from_items(items: list[_Listing]) -> list[dict[str, ItemFilterModel]]:\n    to_check = items.copy()\n    result = []\n    for item in items.copy():\n        if item not in to_check:\n            continue\n        to_check.remove(item)\n        try:\n            annotated_filter = _AnnotatedFilter(\n                max_price=item.price,\n                min_price=item.price,\n                filter=ItemFilterModel(\n                    minPower=item.item_power,\n                    itemType=[item.item_type],\n                    affixPool=[\n                        AffixFilterCountModel(\n                            count=[AffixFilterModel(name=x.name, value=x.value) for x in item.affixes]\n                        )\n                    ],\n                ),\n            )\n        except ValidationError:\n            LOGGER.exception(f\"Failed validation. Skipping {item=}\")\n            continue\n        to_delete = []\n        for to_check_item in [x for x in to_check if x.item_type in annotated_filter.filter.itemType]:\n            annotated_filter_affixes = [(x.name, x.value) for x in annotated_filter.filter.affixPool[0].count]\n            to_check_item_affixes = [(x.name, x.value) for x in to_check_item.affixes]\n            for x in annotated_filter_affixes:\n                if not any(a[0] == x[0] for a in to_check_item_affixes):\n                    break\n            else:\n                to_delete.append(to_check_item)\n                annotated_filter.min_price = min(annotated_filter.min_price, to_check_item.price)\n                annotated_filter.max_price = max(annotated_filter.max_price, to_check_item.price)\n                for x in annotated_filter.filter.affixPool[0].count:\n                    for y in [a for a in to_check_item.affixes if x.name == a.name]:\n                        x.value = min(x.value, y.value)\n        for to_delete_item in to_delete:\n            to_check.remove(to_delete_item)\n        result.append(annotated_filter)\n    converted_result = []\n    for annotated_filter in result:\n        name = (\n            f\"{annotated_filter.filter.itemType[0].value}_{format_number_as_short_string(annotated_filter.min_price)}\"\n        )\n        if annotated_filter.min_price != annotated_filter.max_price:\n            name += f\"_{format_number_as_short_string(annotated_filter.max_price)}\"\n        # 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\n        suffixed_name = name\n        i = 2\n        while any(suffixed_name in x for x in converted_result):\n            suffixed_name = f\"{name}_{i}\"\n            i += 1\n        converted_result.append({suffixed_name: annotated_filter.filter})\n    return sorted(converted_result, key=lambda x: next(iter(x)))\n\n\nif __name__ == \"__main__\":\n    src.logger.setup()\n    URLS = [\"https://diablo.trade/listings/items?exactPrice=true&rarity=legendary&sold=true&sort=newest\"]\n    for x in URLS:\n        import_diablo_trade(url=x, max_listings=400)\n"
  },
  {
    "path": "src/gui/importer/gui_common.py",
    "content": "import datetime\nimport functools\nimport logging\nimport pathlib\nimport re\nimport shutil\nimport time\nfrom typing import TYPE_CHECKING, Literal, TypeVar\n\nimport httpx\nfrom ruamel.yaml import YAML, StringIO\nfrom selenium import webdriver\nfrom selenium.common.exceptions import TimeoutException\nfrom selenium.webdriver.remote.webdriver import WebDriver\nfrom selenium.webdriver.remote.webelement import WebElement\nfrom selenium.webdriver.support.wait import WebDriverWait\nfrom seleniumbase import SB\n\nfrom src import __version__\nfrom src.config.loader import IniConfigLoader\nfrom src.config.profile_models import ItemFilterModel, ProfileModel  # noqa: TC001\nfrom src.config.settings_models import BrowserType\nfrom src.item.data.item_type import ItemType\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\n    from selenium.webdriver.chromium.webdriver import ChromiumDriver\n\nLOGGER = logging.getLogger(__name__)\n\nD = TypeVar(\"D\", bound=WebDriver | WebElement)\nT = TypeVar(\"T\")\nHEADERS = {\n    \"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\"\n}\n\nPLAYER_CLASSES = [\"barbarian\", \"druid\", \"necromancer\", \"rogue\", \"sorcerer\", \"spiritborn\", \"paladin\", \"warlock\"]\nBUILD_SOURCES = [\"d4builds\", \"maxroll\", \"mobalytics\"]\n_SOURCE_TITLE_SUFFIXES = {\"d4builds\": (\"D4Builds\", \"D4 Builds\"), \"maxroll\": (\"Maxroll\",), \"mobalytics\": (\"Mobalytics\",)}\nMAX_POWER = 900\n\n\ndef extract_digits(text: str) -> int:\n    return int(\"\".join([char for char in text if char.isdigit()]))\n\n\ndef fix_weapon_type(input_str: str) -> ItemType | None:\n    input_str = input_str.lower()\n    if \"1h axe\" in input_str:\n        return ItemType.Axe\n    if \"1h mace\" in input_str:\n        return ItemType.Mace\n    if \"1h sword\" in input_str:\n        return ItemType.Sword\n    if \"2h axe\" in input_str:\n        return ItemType.Axe2H\n    if \"2h mace\" in input_str:\n        return ItemType.Mace2H\n    if \"2h scythe\" in input_str:\n        return ItemType.Scythe2H\n    if \"2h sword\" in input_str:\n        return ItemType.Sword2H\n    if \"bow\" in input_str:\n        return ItemType.Bow\n    if \"crossbow\" in input_str:\n        return ItemType.Crossbow2H\n    if \"dagger\" in input_str:\n        return ItemType.Dagger\n    if \"flail\" in input_str:\n        return ItemType.Flail\n    if \"glaive\" in input_str:\n        return ItemType.Glaive\n    if \"polearm\" in input_str:\n        return ItemType.Polearm\n    if \"quarterstaff\" in input_str:\n        return ItemType.Quarterstaff\n    if \"scythe\" in input_str:\n        return ItemType.Scythe\n    if \"staff\" in input_str:\n        return ItemType.Staff\n    if \"wand\" in input_str:\n        return ItemType.Wand\n    return None\n\n\ndef fix_offhand_type(input_str: str, class_str: str) -> ItemType | None:\n    input_str = input_str.lower()\n    class_str = class_str.lower()\n    if \"sorc\" in class_str or \"warlock\" in class_str:\n        return ItemType.Focus\n    if \"druid\" in class_str:\n        return ItemType.OffHandTotem\n    if \"paladin\" in class_str:\n        return ItemType.Shield\n    if \"necro\" in class_str:\n        if \"focus\" in input_str or (\"offhand\" in input_str and \"lucky hit chance\" in input_str):\n            return ItemType.Focus\n        if \"shield\" in input_str:\n            return ItemType.Shield\n    return None\n\n\ndef format_number_as_short_string(n: int) -> str:\n    result = n / 1_000_000\n    return f\"{int(result)}M\" if result.is_integer() else f\"{result:.2f}M\"\n\n\ndef get_class_name(input_str: str) -> str:\n    input_str = input_str.lower()\n    for class_name in PLAYER_CLASSES:\n        if class_name in input_str:\n            return class_name.title()\n\n    LOGGER.error(f\"Couldn't match class name {input_str=}\")\n    return \"Unknown\"\n\n\ndef normalize_profile_file_name(file_name: str) -> str:\n    file_name = file_name.replace(\"'\", \"\")\n    file_name = re.sub(r\"\\W\", \"_\", file_name)\n    return re.sub(r\"_+\", \"_\", file_name).rstrip(\"_\")\n\n\ndef build_default_profile_file_name(\n    source_name: str, class_name: str = \"\", season_number: str = \"\", build_header: str = \"\", variant_name: str = \"\"\n) -> str:\n    normalized_source_name = _normalize_profile_name_part(source_name) or \"imported\"\n    clean_title = _clean_build_header(normalized_source_name, build_header, season_number)\n    normalized_class_name = _normalize_profile_name_part(class_name) or \"unknown\"\n    normalized_variant_name = _normalize_profile_name_part(variant_name)\n    season_match = re.search(r\"\\d+\", str(season_number))\n    normalized_season_name = f\"s{season_match.group(0)}\" if season_match else \"\"\n    file_name_parts = [normalized_source_name, normalized_class_name]\n    if normalized_season_name:\n        file_name_parts.append(normalized_season_name)\n    if clean_title:\n        file_name_parts.append(clean_title)\n    if normalized_variant_name:\n        file_name_parts.append(normalized_variant_name)\n    return normalize_profile_file_name(\"_\".join(file_name_parts))\n\n\ndef _clean_build_header(source_name: str, build_header: str, season_number: str = \"\") -> str:\n    clean_header = _normalize_profile_name_part(build_header)\n    if not clean_header:\n        return \"\"\n\n    source_labels = _SOURCE_TITLE_SUFFIXES.get(source_name, (source_name.title(),))\n    for source_label in source_labels:\n        normalized_source_label = source_label.casefold()\n        for separator in (\" - \", \" | \", \" · \"):\n            suffix = f\"{separator}{normalized_source_label}\"\n            if clean_header.endswith(suffix):\n                clean_header = clean_header.removesuffix(suffix)\n                break\n\n    if re.search(r\"\\d+\", str(season_number)):\n        clean_header = re.sub(r\"^\\s*(?:S\\d+|Season\\s+\\d+)\\b\", \"\", clean_header, count=1, flags=re.IGNORECASE)\n        clean_header = re.sub(r\"\\(\\s*(?:S\\d+|Season\\s+\\d+)\\s*\\)\", \"\", clean_header, flags=re.IGNORECASE)\n        clean_header = re.sub(r\"\\b(?:S\\d+|Season\\s+\\d+)\\b\", \"\", clean_header, flags=re.IGNORECASE)\n    return re.sub(r\"\\s+\", \" \", clean_header).strip(\" -_:\")\n\n\ndef _normalize_profile_name_part(name_part: str) -> str:\n    return re.sub(r\"\\s+\", \" \", str(name_part or \"\").strip()).casefold()\n\n\ndef update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool):\n    if require_gas:\n        num_greater = 0\n        for affix in item_filter.affixPool[0].count:\n            num_greater += 1 if affix.want_greater else 0\n        item_filter.minGreaterAffixCount = num_greater\n    else:\n        item_filter.minGreaterAffixCount = 0\n\n\ndef sort_profile_filters(filters: list[dict[str, ItemFilterModel]]) -> list[dict[str, ItemFilterModel]]:\n    return sorted(filters, key=_profile_filter_sort_key)\n\n\ndef _profile_filter_sort_key(filter_entry: dict[str, ItemFilterModel]) -> str:\n    filter_name, _ = next(iter(filter_entry.items()))\n    return filter_name.casefold()\n\n\ndef get_with_retry(url: str, custom_headers: dict[str, str] | None = None) -> httpx.Response:\n    for _ in range(10):\n        try:\n            r = httpx.get(url, headers=custom_headers if custom_headers is not None else HEADERS)\n        except httpx.RequestError:\n            LOGGER.debug(f\"Request {url} timed out, retrying...\")\n            continue\n        if r.status_code != 200:\n            LOGGER.debug(f\"Request {url} failed with status code {r.status_code}, retrying...\")\n            continue\n        return r\n    LOGGER.error(msg := f\"Failed to get a successful response after 10 attempts: {url=}\")\n    raise ConnectionError(msg)\n\n\ndef handle_popups[D: WebDriver | WebElement, T](\n    driver: ChromiumDriver, method: Callable[[D], Literal[False] | T], timeout: int = 10\n):\n    LOGGER.info(\"Handling cookie / adblock popups\")\n    wait = WebDriverWait(driver, timeout)\n    for _ in range(3):\n        try:\n            elem = wait.until(method)\n        except TimeoutException:\n            break\n        elem.click()\n        time.sleep(1)\n\n\ndef match_to_enum(enum_class, target_string: str, check_keys: bool = False):\n    target_string = target_string.casefold().replace(\" \", \"\").replace(\"-\", \"\")\n    for enum_member in enum_class:\n        if enum_member.value.casefold().replace(\" \", \"\").replace(\"-\", \"\") == target_string:\n            return enum_member\n        if check_keys and enum_member.name.casefold().replace(\" \", \"\").replace(\"-\", \"\") == target_string:\n            return enum_member\n    return None\n\n\ndef retry_importer(func=None, inject_webdriver: bool = False, uc=False):\n    def decorator_retry_importer(wrap_function):\n        @functools.wraps(wrap_function)\n        def wrapper(*args, **kwargs):\n            if inject_webdriver and \"driver\" not in kwargs and not args:\n                kwargs[\"driver\"] = setup_webdriver(uc=uc)\n            for _ in range(2):\n                try:\n                    res = wrap_function(*args, **kwargs)\n                    if inject_webdriver and \"driver\" in kwargs:\n                        kwargs[\"driver\"].quit()\n                except Exception:\n                    LOGGER.exception(\"An error occurred while importing. Retrying...\")\n                else:\n                    return res\n            return None\n\n        return wrapper\n\n    return decorator_retry_importer if func is None else decorator_retry_importer(func)\n\n\ndef save_as_profile(file_name: str, profile: ProfileModel, url: str, exclude=None, backup_file=False) -> str:\n    file_name = normalize_profile_file_name(file_name)\n    save_path = IniConfigLoader().user_dir / f\"profiles/{file_name}.yaml\"\n    save_path.parent.mkdir(parents=True, exist_ok=True)\n\n    if save_path.exists() and backup_file:\n        backup_path = IniConfigLoader().user_dir / f\"profiles/backups/{file_name}_original.yaml\"\n        backup_path.parent.mkdir(parents=True, exist_ok=True)\n        if not backup_path.exists():  # If already backed up don't overwrite\n            shutil.copyfile(save_path, backup_path)\n\n    exclude = exclude or {\"name\", \"Sigils\"}\n    with pathlib.Path(save_path).open(\"w\", encoding=\"utf-8\") as file:\n        file.write(f\"# {url}\\n\")\n        file.write(f\"# {datetime.datetime.now(tz=datetime.UTC).strftime('%Y-%m-%d %H:%M:%S')} (v{__version__})\\n\")\n        file.write(_to_yaml_str(profile, exclude_defaults=not IniConfigLoader().general.full_dump, exclude=exclude))\n    LOGGER.info(f\"Created profile {save_path}\")\n    return file_name\n\n\ndef add_to_profiles(build_name):\n    profiles = IniConfigLoader().general.profiles\n    if build_name in profiles:\n        LOGGER.info(f\"Profile {build_name} was already an active profile.\")\n    else:\n        profiles.append(build_name)\n        IniConfigLoader().save_value(\"general\", \"profiles\", \", \".join(profiles))\n        LOGGER.info(f\"Added {build_name} to active profiles configuration\")\n\n\n# Built in to_yaml_str does not preserve the order of the attributes of the model, which is important for uniques\ndef _to_yaml_str(profile: ProfileModel, exclude_defaults: bool, exclude: set[str]) -> str:\n    str_val = profile.model_dump_json(exclude_defaults=exclude_defaults, exclude=exclude)\n    yaml = YAML()\n    yaml.default_flow_style = None  # Back to original\n    dict_val = yaml.load(str_val)\n    _sort_profile_sections(dict_val)\n    _rm_style_info(dict_val)\n    _use_block_style(dict_val.get(\"AspectUpgrades\"))\n    stream = StringIO()\n    yaml.dump(dict_val, stream)\n    stream.seek(0)\n    return stream.read()\n\n\ndef _sort_profile_sections(d):\n    if isinstance(d, dict) and isinstance(d.get(\"AspectUpgrades\"), list):\n        d[\"AspectUpgrades\"].sort(key=str.casefold)\n\n\ndef _use_block_style(d):\n    if hasattr(d, \"fa\"):\n        d.fa.set_block_style()\n\n\ndef _rm_style_info(d):\n    if isinstance(d, dict):\n        d.fa._flow_style = None\n        for k, v in d.items():\n            _rm_style_info(k)\n            _rm_style_info(v)\n    elif isinstance(d, list):\n        d.fa._flow_style = None\n        for elem in d:\n            _rm_style_info(elem)\n\n\ndef setup_webdriver(uc: bool = False) -> ChromiumDriver:\n    if uc:\n        return SB(uc=uc, headless2=True)\n    match IniConfigLoader().general.browser:\n        case BrowserType.edge:\n            options = webdriver.EdgeOptions()\n            options.add_argument(\"--headless=new\")\n            options.add_argument(\"log-level=3\")\n            driver = webdriver.Edge(options=options)\n        case BrowserType.chrome:\n            options = webdriver.ChromeOptions()\n            options.add_argument(\"--headless=new\")\n            options.add_argument(\"log-level=3\")\n            driver = webdriver.Chrome(options=options)\n        case BrowserType.firefox:\n            options = webdriver.FirefoxOptions()\n            options.add_argument(\"--headless\")\n            options.add_argument(\"log-level=3\")\n            driver = webdriver.Firefox(options=options)\n    return driver  # It must be one of the 3 browsers due to ini validation\n"
  },
  {
    "path": "src/gui/importer/importer_config.py",
    "content": "from dataclasses import dataclass\n\n\n@dataclass\nclass ImportConfig:\n    url: str\n    import_aspect_upgrades: bool\n    add_to_profiles: bool\n    import_greater_affixes: bool\n    require_greater_affixes: bool\n    export_paragon: bool = False\n    custom_file_name: str | None = None\n"
  },
  {
    "path": "src/gui/importer/maxroll.py",
    "content": "import json\nimport logging\nimport re\n\nimport lxml.html\n\nimport src.logger\nfrom src.config.profile_models import (\n    AffixFilterCountModel,\n    AffixFilterModel,\n    AspectUniqueFilterModel,\n    ItemFilterModel,\n    ProfileModel,\n)\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.gui_common import (\n    add_to_profiles,\n    build_default_profile_file_name,\n    fix_offhand_type,\n    fix_weapon_type,\n    get_with_retry,\n    match_to_enum,\n    retry_importer,\n    save_as_profile,\n    sort_profile_filters,\n    update_mingreateraffixcount,\n)\nfrom src.gui.importer.importer_config import ImportConfig\nfrom src.gui.importer.paragon_export import build_paragon_profile_payload, extract_maxroll_paragon_steps\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.descr.text import clean_str, closest_match\nfrom src.scripts import correct_name\n\nLOGGER = logging.getLogger(__name__)\nLOGGER.propagate = True\nBUILD_GUIDE_BASE_URL = \"https://maxroll.gg/d4/build-guides/\"\nPLANNER_API_BASE_URL = \"https://planners.maxroll.gg/profiles/d4/\"\nPLANNER_API_DATA_URL = \"https://assets-ng.maxroll.gg/d4-tools/game/data.min.json?376b600d\"\nPLANNER_BASE_URL = \"https://maxroll.gg/d4/planner/\"\nSCRIPT_XPATH = \"//div[@id='root']/script\"\nBUILD_SCRIPT_PREFIX = \"window.__remixContext = \"\nPLANNER_API_REGEX = re.compile(r'(https://maxroll\\.gg/d4/planner/[^\"|\\\\]*)')\nSKILL_RANK_BONUS_FORMULAS = {\"GearAffix_SkillRankBonus\", \"GearAffix_SkillRankBonus_1to2\"}\nSKILL_RANK_AFFIX_KEY_REGEX = re.compile(r\"(?:_Category_|_Special_)(?P<label>[A-Za-z0-9]+)\")\nSKILL_RANK_DESC_LABEL_REGEX = re.compile(r\"\\{c_important\\}([^{}]+)\\{/c\\}\\s+Skills\")\n\n\nclass MaxrollException(Exception):\n    pass\n\n\n@retry_importer\ndef import_maxroll(config: ImportConfig):\n    url = config.url.strip().replace(\"\\n\", \"\")\n    if PLANNER_BASE_URL not in url and BUILD_GUIDE_BASE_URL not in url:\n        LOGGER.error(\"Invalid url, please use a maxroll build guide or maxroll planner url\")\n        return\n    LOGGER.info(f\"Loading {url}\")\n    if BUILD_GUIDE_BASE_URL in url:\n        api_url, build_id, build_id_is_visible_position = _extract_planner_url_and_id_from_guide(url)\n    else:\n        api_url, build_id, build_id_is_visible_position = _extract_planner_url_and_id_from_planner(url)\n    try:\n        r = get_with_retry(url=api_url)\n    except ConnectionError:\n        LOGGER.error(\"Couldn't get planner\")\n        return\n    all_data = r.json()\n    guide_season = all_data.get(\"season\", \"\")\n    build_data = json.loads(all_data[\"data\"])\n    if build_id_is_visible_position:\n        build_id = _resolve_visible_profile_index(build_data[\"profiles\"], build_id)\n    items = build_data[\"items\"]\n    try:\n        mapping_data = get_with_retry(url=PLANNER_API_DATA_URL).json()\n    except ConnectionError:\n        LOGGER.error(\"Couldn't get planner data\")\n        return\n    # The attribute descriptions are not always consistent with the casing for the key so we fix that here\n    mapping_data[\"attributeDescriptions\"] = {k.lower(): v for k, v in mapping_data[\"attributeDescriptions\"].items()}\n    active_profile = build_data[\"profiles\"][build_id]\n    build_header = all_data[\"name\"] or all_data[\"class\"]\n    variant_name = active_profile[\"name\"] or \"\"\n    build_name = build_header\n    if not build_name:\n        build_name = all_data[\"class\"]\n    if variant_name:\n        build_name += f\"_{variant_name}\"\n    finished_filters = []\n    aspect_upgrade_filters = []\n    for item_id in active_profile[\"items\"].values():\n        resolved_item = items[str(item_id)]\n        resolved_item_id = resolved_item[\"id\"]\n        rarity = _find_item_rarity(resolved_item_id, mapping_data)\n\n        item_filter = ItemFilterModel()\n        if (\n            item_type := _find_item_type(\n                mapping_data=mapping_data[\"items\"], value=resolved_item[\"id\"], class_name=all_data[\"class\"]\n            )\n        ) is None:\n            LOGGER.warning(\n                f\"Couldn't find item type for {resolved_item['id']} from mapping data provided by Maxroll. Skipping item.\"\n            )\n            continue\n\n        if item_type in [ItemType.HoradricSeal, ItemType.Charm]:\n            LOGGER.warning(\n                f\"Seals and Charms are not currently supported, skipping {resolved_item.get('name', '(could not determine item name)')}.\"\n            )\n            continue\n\n        item_filter.itemType = [item_type]\n\n        # Legendary aspect upgrade handling\n        if rarity == ItemRarity.Legendary and config.import_aspect_upgrades:\n            legendary_aspect = _find_legendary_aspect(\n                mapping_data, resolved_item.get(\"legendaryPower\", resolved_item.get(\"aspects\", {}))\n            )\n            if legendary_aspect:\n                if legendary_aspect not in Dataloader().aspect_list:\n                    LOGGER.warning(\n                        f\"Found legendary aspect '{legendary_aspect}' that is not in our aspect data, unable to add \"\n                        f\"to AspectUpgrades. Please report a bug.\"\n                    )\n                else:\n                    aspect_upgrade_filters.append(legendary_aspect)\n\n        # Unique aspect, if the item is a unique\n        if rarity in [ItemRarity.Unique, ItemRarity.Mythic]:\n            unique_name = mapping_data[\"items\"][resolved_item_id][\"name\"]\n            try:\n                unique_name = _unique_name_special_handling(unique_name)\n                item_filter.uniqueAspect = AspectUniqueFilterModel(name=unique_name)\n            except Exception:\n                LOGGER.exception(f\"Unexpected error adding unique aspect for {unique_name}, please report a bug.\")\n\n        # Standard item handling. For mythics we don't import affixes\n        if rarity != ItemRarity.Mythic:\n            item_filter.affixPool = [\n                AffixFilterCountModel(\n                    count=[\n                        AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater)\n                        for x in _find_item_affixes(\n                            mapping_data=mapping_data,\n                            item_affixes=resolved_item[\"explicits\"],\n                            item_type=item_type,\n                            import_greater_affixes=config.import_greater_affixes,\n                        )\n                    ],\n                    minCount=1 if rarity == ItemRarity.Unique else 3,\n                )\n            ]\n            update_mingreateraffixcount(item_filter, config.require_greater_affixes)\n\n        item_filter.minPower = 100\n        filter_name = item_filter.itemType[0].name\n        i = 2\n        while any(filter_name == next(iter(x)) for x in finished_filters):\n            filter_name = f\"{item_filter.itemType[0].name}{i}\"\n            i += 1\n\n        finished_filters.append({filter_name: item_filter})\n    profile = ProfileModel(name=\"imported profile\", Affixes=sort_profile_filters(finished_filters))\n    if config.import_aspect_upgrades and aspect_upgrade_filters:\n        profile.AspectUpgrades = aspect_upgrade_filters\n\n    file_name = config.custom_file_name\n    if not file_name:\n        file_name = build_default_profile_file_name(\n            source_name=\"maxroll\",\n            class_name=all_data[\"class\"],\n            season_number=guide_season,\n            build_header=build_header,\n            variant_name=variant_name,\n        )\n\n    # Optionally embed Paragon data into the profile model before saving\n    if config.export_paragon:\n        steps = extract_maxroll_paragon_steps(active_profile)\n        if steps:\n            profile.Paragon = build_paragon_profile_payload(\n                build_name=build_name, source_url=url, paragon_boards_list=steps\n            )\n        else:\n            LOGGER.warning(\"Paragon export enabled, but no paragon steps were found in this Maxroll profile.\")\n\n    corrected_file_name = save_as_profile(file_name=file_name, profile=profile, url=url)\n\n    if config.add_to_profiles:\n        add_to_profiles(corrected_file_name)\n\n    LOGGER.info(\"Finished\")\n\n\ndef _attribute_description_corrections(input_str: str) -> str:\n    match input_str:\n        case \"On_Hit_Vulnerable_Proc_Chance\":\n            return \"On_Hit_Vulnerable_Proc\".lower()\n        case \"Movement_Bonus_On_Elite_Kill\":\n            return \"Movement_Speed_Bonus_On_Elite_Kill\".lower()\n    return input_str.lower()\n\n\ndef _find_item_rarity(resolved_item_id, mapping_data) -> ItemRarity:\n    # magic/rare = 0, legendary = 1, unique = 2, mythic = 4\n    if resolved_item_id in mapping_data[\"items\"]:\n        rarity_id = mapping_data[\"items\"][resolved_item_id][\"magicType\"]\n        if rarity_id == 1:\n            return ItemRarity.Legendary\n        if rarity_id == 2:\n            return ItemRarity.Unique\n        if rarity_id == 4:\n            return ItemRarity.Mythic\n\n    return ItemRarity.Common\n\n\ndef _find_item_affixes(\n    mapping_data: dict, item_affixes: dict, item_type: ItemType, import_greater_affixes=False\n) -> list[Affix]:\n    res = []\n    for affix_id in item_affixes:\n        for affix_key, affix in mapping_data[\"affixes\"].items():\n            if affix[\"id\"] != affix_id[\"nid\"]:\n                continue\n            if affix[\"magicType\"] in [2, 4]:\n                break\n            attr_desc = _attr_desc_special_handling(affix[\"id\"])\n            if not attr_desc:\n                if \"formula\" in affix[\"attributes\"][0] and affix[\"attributes\"][0][\"formula\"] in [\n                    \"GearAffix_Resource_Per_Second\",\n                    \"GearAffix_DamageType\",\n                    \"GearAffix_DamageType_Greater\",\n                    \"GearAffix_Resource_On_Kill\",\n                    \"GearAffix_Resource_On_Kill_Warlock\",\n                ]:\n                    if affix[\"attributes\"][0][\"formula\"] in [\"GearAffix_DamageType\", \"GearAffix_DamageType_Greater\"]:\n                        attr_desc = (\n                            mapping_data[\"uiStrings\"][\"damageType\"][str(affix[\"attributes\"][0][\"param\"])]\n                            + \" Damage Multiplier\"\n                        )\n                    elif affix[\"attributes\"][0][\"formula\"] in [\"GearAffix_Resource_Per_Second\"]:\n                        param = str(affix[\"attributes\"][0][\"param\"])\n                        attr_desc = mapping_data[\"uiStrings\"][\"resourceType\"][param] + \" Regeneration\"\n                    elif affix[\"attributes\"][0][\"formula\"] in [\n                        \"GearAffix_Resource_On_Kill\",\n                        \"GearAffix_Resource_On_Kill_Warlock\",\n                    ]:\n                        attr_desc = (\n                            mapping_data[\"uiStrings\"][\"resourceType\"][str(affix[\"attributes\"][0][\"param\"])] + \" On Kill\"\n                        )\n                elif \"param\" not in affix[\"attributes\"][0]:\n                    attr_id = affix[\"attributes\"][0][\"id\"]\n                    attr_obj = mapping_data[\"attributes\"][str(attr_id)]\n                    attr_desc = mapping_data[\"attributeDescriptions\"].get(\n                        _attribute_description_corrections(attr_obj[\"name\"])\n                    )\n                    if not attr_desc:\n                        LOGGER.warning(\n                            f\"Unable to map {attr_obj['name']} from MaxRoll data to an affix, skipping affix and please report a bug.\"\n                        )\n                        continue\n                else:  # must be + to talent or skill\n                    attr_param = affix[\"attributes\"][0][\"param\"]\n                    for skill_data in mapping_data[\"skills\"].values():\n                        if skill_data[\"id\"] == attr_param:\n                            attr_desc = f\"to {skill_data['name']}\"\n                            break\n                    else:\n                        attr_desc = _find_skill_rank_affix_description(\n                            mapping_data=mapping_data, affix_key=affix_key, attribute=affix[\"attributes\"][0]\n                        )\n            clean_desc = re.sub(r\"\\[.*?\\]|[^a-zA-Z ]\", \"\", attr_desc)\n            clean_desc = clean_desc.replace(\"SecondSeconds\", \"seconds\")\n            if not clean_desc:\n                LOGGER.warning(\n                    f\"We were unable to map an attribute on item type {item_type.value} to an affix. Please report a bug and include a link to the build, we are skipping that affix.\"\n                )\n                continue\n\n            affix_obj = Affix(name=closest_match(clean_str(clean_desc), Dataloader().affix_dict))\n            if import_greater_affixes and affix_id.get(\"greater\", False):\n                affix_obj.type = AffixType.greater\n            if affix_obj.name is not None:\n                res.append(affix_obj)\n            elif \"formula\" in affix[\"attributes\"][0] and affix[\"attributes\"][0][\"formula\"] in [\n                \"InherentAffixAnyResist_Ring\"\n            ]:\n                LOGGER.info(\"Skipping InherentAffixAnyResist_Ring\")\n            else:\n                LOGGER.error(f\"Couldn't match {affix_id=}\")\n            break\n    return res\n\n\ndef _find_skill_rank_affix_description(mapping_data: dict, affix_key: str, attribute: dict) -> str:\n    if attribute.get(\"formula\") not in SKILL_RANK_BONUS_FORMULAS:\n        return \"\"\n\n    if (label := _find_skill_rank_label_from_descriptions(mapping_data, attribute.get(\"param\"))) or (\n        label := _find_skill_rank_label_from_affix_key(affix_key)\n    ):\n        return f\"to {label} skills\"\n    return \"\"\n\n\ndef _find_skill_rank_label_from_descriptions(mapping_data: dict, param: int | None) -> str:\n    if param is None:\n        return \"\"\n\n    for affix in mapping_data[\"affixes\"].values():\n        if not any(\n            attr.get(\"formula\") in SKILL_RANK_BONUS_FORMULAS and attr.get(\"param\") == param\n            for attr in affix.get(\"attributes\", [])\n        ):\n            continue\n        if match := SKILL_RANK_DESC_LABEL_REGEX.search(affix.get(\"desc\", \"\")):\n            return match.group(1)\n    return \"\"\n\n\ndef _find_skill_rank_label_from_affix_key(affix_key: str) -> str:\n    if \"SkillRankBonus_AllSkills\" in affix_key:\n        return \"all\"\n    if match := SKILL_RANK_AFFIX_KEY_REGEX.search(affix_key):\n        label = match.group(\"label\")\n        label = re.sub(r\"(?<=[a-z])(?=[A-Z])\", \" \", label)\n        label = re.sub(r\"(?<=[A-Z])(?=[A-Z][a-z])\", \" \", label)\n        return \" \".join(label.split())\n    return \"\"\n\n\ndef _find_legendary_aspect(mapping_data: dict, legendary_aspect: dict) -> str | None:\n    if not legendary_aspect:\n        return None\n\n    if isinstance(legendary_aspect, list):\n        legendary_aspect = legendary_aspect[0]\n\n    for affix in mapping_data[\"affixes\"].values():\n        if affix[\"id\"] != legendary_aspect[\"nid\"]:\n            continue\n\n        if \"prefix\" in affix:\n            return correct_name(affix[\"prefix\"])\n        if \"suffix\" in affix:\n            return correct_name(affix[\"suffix\"])\n        return None\n\n    return None\n\n\ndef _attr_desc_special_handling(affix_id: str) -> str:\n    match affix_id:\n        case 1014505 | 2051010:\n            return \"evade grants movement speed for second\"\n        case 2568489:\n            return \"hunger increased reputation from kill streaks\"\n        case 2568491:\n            return \"hunger increased experience from kill streaks\"\n        case 2057810:\n            return \"damage reduction from bleeding enemies\"\n        case 2067844:\n            return \"maximum poison resistance\"\n        case 2037914:\n            return \"subterfuge cooldown reduction\"\n        case 2123788:\n            return \"chance for core skills to hit twice\"\n        case 2119054:\n            return \"chance for basic skills to deal double damage\"\n        case 2119058:\n            return \"basic lucky hit chance\"\n        case 2052125:\n            return \"non-physical damage\"\n        case _:\n            return \"\"\n\n\ndef _unique_name_special_handling(unique_name: str) -> str:\n    match unique_name:\n        case \"[PH] Season 7 Necro Pants\":\n            return \"kessimes_legacy\"\n        case \"[PH] Season 7 Barb Chest\":\n            return \"mantle_of_mountains_fury\"\n        case _:\n            return unique_name.replace(\"\\xa0\", \" \")\n\n\ndef _find_item_type(mapping_data: dict, value: str, class_name: str = \"\") -> ItemType | None:\n    for d_key, d_value in mapping_data.items():\n        if d_key == value:\n            item_type_str = d_value[\"type\"]\n            normalized_item_type_str = _normalize_item_type_str_for_import_helpers(item_type_str)\n            if (item_type := fix_weapon_type(input_str=normalized_item_type_str)) is not None:\n                return item_type\n            if (\n                any(substring in normalized_item_type_str for substring in [\"focus\", \"off hand\", \"shield\", \"totem\"])\n            ) and (item_type := fix_offhand_type(input_str=normalized_item_type_str, class_str=class_name)) is not None:\n                return item_type\n            if (res := match_to_enum(enum_class=ItemType, target_string=item_type_str, check_keys=True)) is None:\n                LOGGER.error(\"Couldn't match item type to enum\")\n                return None\n            return res\n    return None\n\n\ndef _normalize_item_type_str_for_import_helpers(item_type_str: str) -> str:\n    normalized_item_type = re.sub(r\"(?<=[a-z])(?=[A-Z])\", \" \", item_type_str)\n    normalized_item_type = re.sub(r\"(?<=[A-Za-z])(?=[12]H\\b)\", \" \", normalized_item_type)\n    normalized_item_type = normalized_item_type.replace(\"-\", \" \").lower()\n    normalized_item_type = \" \".join(normalized_item_type.split())\n    return re.sub(r\"\\b([a-z]+)\\s+(1h|2h)\\b\", r\"\\2 \\1\", normalized_item_type)\n\n\ndef _extract_planner_url_and_id_from_planner(url: str) -> tuple[str, int, bool]:\n    planner_suffix = url.split(PLANNER_BASE_URL)\n    if len(planner_suffix) != 2:\n        LOGGER.error(msg := \"Invalid planner url\")\n        raise MaxrollException(msg)\n    if \"#\" in planner_suffix[1]:\n        planner_id, data_id = planner_suffix[1].split(\"#\")\n        data_id = int(data_id) - 1\n        build_id_is_visible_position = True\n    else:\n        planner_id = planner_suffix[1]\n\n        try:\n            r = get_with_retry(url=PLANNER_API_BASE_URL + planner_id)\n        except ConnectionError as exc:\n            LOGGER.exception(msg := \"Couldn't get planner\")\n            raise MaxrollException(msg) from exc\n        data_id = json.loads(r.json()[\"data\"])[\"activeProfile\"]\n        build_id_is_visible_position = False\n    return PLANNER_API_BASE_URL + planner_id, data_id, build_id_is_visible_position\n\n\ndef _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int, bool]:\n    \"\"\"Resolve a build guide to the underlying planner API url and profile selection.\"\"\"\n    try:\n        r = get_with_retry(url=url)\n    except ConnectionError as exc:\n        LOGGER.exception(msg := \"Couldn't get build guide\")\n        raise MaxrollException(msg) from exc\n    data = lxml.html.fromstring(r.text)\n    # As of season 13, the link to the planner is stuck in a script so we get it from there\n    script_elements = data.xpath(SCRIPT_XPATH)\n    for script_element in script_elements:\n        if script_element.text and script_element.text.strip().startswith(BUILD_SCRIPT_PREFIX):\n            planner_link = PLANNER_API_REGEX.search(script_element.text).group()\n            if planner_link:\n                api_url, build_id, build_id_is_visible_position = _extract_planner_url_and_id_from_planner(planner_link)\n                return api_url, build_id, build_id_is_visible_position\n\n    msg = \"Couldn't resolve a planner profile from this Maxroll build guide. Use the planner link directly and please report a bug.\"\n    LOGGER.error(msg)\n    raise MaxrollException(msg)\n\n\ndef _resolve_visible_profile_index(profiles: list[dict], visible_profile_index: int) -> int:\n    visible_index = 0\n    for profile_index, profile in enumerate(profiles):\n        if profile.get(\"hidden\"):\n            continue\n        if visible_index == visible_profile_index:\n            return profile_index\n        visible_index += 1\n    return visible_profile_index\n\n\ndef _extract_guide_profile_id(embed: lxml.html.HtmlElement) -> int | None:\n    if data_id := embed.get(\"data-d4-id\"):\n        return int(data_id.split(\",\")[0]) - 1\n    if data_ids := embed.get(\"data-d4-data\"):\n        guide_profile_ids = [int(value) for value in data_ids.split(\",\") if value]\n        if (active_tab_index := _extract_active_guide_embed_tab_index(embed)) is not None and active_tab_index < len(\n            guide_profile_ids\n        ):\n            return guide_profile_ids[active_tab_index] - 1\n        return guide_profile_ids[0] - 1\n    return None\n\n\ndef _extract_active_guide_embed_tab_index(embed: lxml.html.HtmlElement) -> int | None:\n    for index, tab in enumerate(embed.xpath(\".//*[contains(@class, 'd4t-tabs')]/li\")):\n        if \"d4t-active\" in (tab.get(\"class\") or \"\"):\n            return index\n    return None\n\n\nif __name__ == \"__main__\":\n    src.logger.setup()\n    URLS = [\"https://maxroll.gg/d4/planner/n51lwl0u#1\"]\n    for X in URLS:\n        config = ImportConfig(\n            url=X,\n            import_aspect_upgrades=True,\n            add_to_profiles=False,\n            import_greater_affixes=True,\n            require_greater_affixes=True,\n            export_paragon=True,\n            custom_file_name=None,\n        )\n        import_maxroll(config)\n"
  },
  {
    "path": "src/gui/importer/mobalytics.py",
    "content": "import json\nimport logging\nimport re\nfrom urllib.parse import unquote\n\nimport jsonpath\nimport lxml.html\n\nimport src.logger\nfrom src.config.profile_models import (\n    AffixFilterCountModel,\n    AffixFilterModel,\n    AspectUniqueFilterModel,\n    ItemFilterModel,\n    ProfileModel,\n)\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.gui_common import (\n    add_to_profiles,\n    build_default_profile_file_name,\n    fix_offhand_type,\n    fix_weapon_type,\n    get_with_retry,\n    match_to_enum,\n    retry_importer,\n    save_as_profile,\n    sort_profile_filters,\n    update_mingreateraffixcount,\n)\nfrom src.gui.importer.importer_config import ImportConfig\nfrom src.gui.importer.paragon_export import build_paragon_profile_payload, extract_mobalytics_paragon_steps\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.item_type import WEAPON_TYPES, ItemType\nfrom src.item.descr.text import clean_str, closest_match\nfrom src.scripts import correct_name\n\nLOGGER = logging.getLogger(__name__)\nLOGGER.propagate = True\nBUILD_GUIDE_BASE_URL = \"https://mobalytics.gg/diablo-4/\"\nPROFILE_GUIDE_BASE_URL = f\"{BUILD_GUIDE_BASE_URL}profile\"\nSCRIPT_XPATH = \"//script\"\nBUILD_SCRIPT_PREFIX = \"window.__PRELOADED_STATE__=\"\n\n\nclass MobalyticsException(Exception):\n    pass\n\n\n@retry_importer\ndef import_mobalytics(config: ImportConfig):\n    url = config.url.strip().replace(\"\\n\", \"\")\n    if BUILD_GUIDE_BASE_URL not in url:\n        LOGGER.error(\"Invalid url, please use a mobalytics build guide\")\n        return\n    if PROFILE_GUIDE_BASE_URL in url:\n        LOGGER.error(\"Builds from user profiles are not supported at this time.\")\n        return\n    url = _fix_input_url(url=url)\n    LOGGER.info(f\"Loading {url}\")\n    try:\n        r = get_with_retry(url=url, custom_headers={})\n    except ConnectionError as exc:\n        LOGGER.exception(msg := \"Couldn't get build\")\n        raise MobalyticsException(msg) from exc\n    variant_id = url.split(\",\")[1].split(\"#\")[0] if \"activeVariantId\" in url else None\n    raw_html_data = lxml.html.fromstring(r.text)\n    # The build is shoved in a massive JSON in one of the script tags. We find that json now.\n    scripts_elem = raw_html_data.xpath(SCRIPT_XPATH)\n    full_script_data_json = None\n    for script in scripts_elem:\n        if script.text and script.text.strip().startswith(BUILD_SCRIPT_PREFIX):\n            full_script_data_json = json.loads(script.text.strip().replace(BUILD_SCRIPT_PREFIX, \"\")[:-1])\n            break\n\n    if not full_script_data_json:\n        LOGGER.error(\n            msg\n            := \"No script containing build data was found. This means Mobalytics has changed how they present data, please submit a bung.\"\n        )\n        raise MobalyticsException(msg)\n\n    # Get the JSON block that contains the build and its variants\n    build_data = dict(jsonpath.findall(\"$..userGeneratedDocumentBySlug.data.data\", full_script_data_json)[0])\n    season_number = _extract_mobalytics_season_number(full_script_data_json)\n    build_header = build_data[\"name\"]\n    if not build_header:\n        LOGGER.error(msg := \"No build name found\")\n        raise MobalyticsException(msg)\n    class_name = jsonpath.findall(\n        \"$..userGeneratedDocumentBySlug.data.tags.data[?@.groupSlug=='class'].name\", full_script_data_json\n    )[0].lower()\n    if not class_name:\n        LOGGER.error(msg := \"No class name found\")\n        raise MobalyticsException(msg)\n    if variant_id:\n        items = jsonpath.findall(f\"$..buildVariants.values[?@.id=='{variant_id}'].genericBuilder.slots\", build_data)[0]\n    else:\n        items = jsonpath.findall(\"$..buildVariants.values[0].genericBuilder.slots\", build_data)[0]\n        variant_id = jsonpath.findall(\"$..buildVariants.values[0].id\", build_data)[0]\n\n    paragon_data = jsonpath.findall(f\"$..buildVariants.values[?@.id=='{variant_id}'].paragon\", build_data)[0]\n\n    variant_name = jsonpath.findall(f\"$..childrenVariants[?@.id=='{variant_id}'].title\", full_script_data_json)\n    variant_name = variant_name[0] if variant_name else \"\"\n    build_name = f\"{build_header} {variant_name}\".strip() if variant_name else build_header\n\n    if not items:\n        LOGGER.error(msg := \"No items found\")\n        raise MobalyticsException(msg)\n    finished_filters = []\n    aspect_upgrade_filters = []\n    for item in items:\n        item_filter = ItemFilterModel()\n        entity_type = jsonpath.findall(\".gameEntity.type\", item)[0]\n        mythic_result = jsonpath.findall(\".gameEntity.entity.mythic\", item)\n        is_mythic = mythic_result[0] if mythic_result else False\n        if entity_type not in [\"aspects\", \"uniqueItems\"]:\n            continue\n        if not (item_name := str(jsonpath.findall(\".gameEntity.entity.title\", item)[0])):\n            LOGGER.error(msg := \"No item name found\")\n            raise MobalyticsException(msg)\n        if not (slot_type := str(jsonpath.findall(\".gameSlotSlug\", item)[0])):\n            LOGGER.error(msg := \"No slot type found\")\n            raise MobalyticsException(msg)\n\n        raw_affixes = jsonpath.findall(\".gameEntity.modifiers.gearStats[*]\", item)\n        raw_inherents = jsonpath.findall(\".gameEntity.modifiers.implicitStats[*]\", item)\n        raw_affixes = [x for x in raw_affixes if x is not None]\n        raw_inherents = [x for x in raw_inherents if x is not None]\n\n        is_unique = entity_type == \"uniqueItems\"\n        if is_unique:\n            try:\n                item_filter.uniqueAspect = AspectUniqueFilterModel(name=item_name)\n            except Exception:\n                LOGGER.exception(f\"Unexpected error adding unique aspect for {item_name}, please report a bug.\")\n\n        legendary_aspect = _get_legendary_aspect(item_name)\n        if legendary_aspect:\n            aspect_upgrade_filters.append(legendary_aspect)\n\n        if not raw_affixes and not raw_inherents:\n            LOGGER.warning(f\"Skipping {slot_type} because it had no stats provided.\")\n            continue\n\n        item_type = None\n        # Item type is hidden in the inherents. If it's in there, then we assume there are no further inherents\n        is_weapon = \"weapon\" in slot_type\n        for inherent in raw_inherents:\n            potential_item_type = \" \".join(inherent[\"id\"].split(\"-\")[:2]).lower()\n            if is_weapon and (x := fix_weapon_type(input_str=potential_item_type)) is not None:\n                item_type = x\n                break\n            if (\n                \"offhand\" in slot_type\n                and (x := fix_offhand_type(input_str=inherent[\"id\"].replace(\"-\", \" \"), class_str=class_name))\n                is not None\n            ):\n                item_type = x\n                break\n        if item_type:\n            raw_inherents.clear()\n\n        # Druid and sorc have a default offhand item type that we may have missed if there were no inherents\n        if not item_type and \"offhand\" in slot_type:\n            item_type = fix_offhand_type(\"\", class_name)\n\n        item_type = (\n            match_to_enum(enum_class=ItemType, target_string=re.sub(r\"\\d+\", \"\", slot_type))\n            if item_type is None\n            else item_type\n        )\n        if item_type is None:\n            if is_weapon:\n                LOGGER.warning(\n                    f\"Couldn't find an item_type for weapon slot {slot_type}, defaulting to all weapon types instead.\"\n                )\n                item_filter.itemType = WEAPON_TYPES\n            else:\n                item_filter.itemType = []\n                LOGGER.warning(f\"Couldn't match item_type: {slot_type}. Please edit manually\")\n        else:\n            item_filter.itemType = [item_type]\n\n        affixes = _convert_raw_to_affixes(raw_affixes, config.import_greater_affixes)\n        inherents = _convert_raw_to_affixes(raw_inherents)\n\n        if not is_mythic:\n            item_filter.affixPool = [\n                AffixFilterCountModel(\n                    count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes],\n                    minCount=1 if is_unique else 3,\n                )\n            ]\n            update_mingreateraffixcount(item_filter, config.require_greater_affixes)\n        item_filter.minPower = 100\n        if inherents and not is_mythic:\n            item_filter.inherentPool = [AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents])]\n        filter_name_template = item_filter.itemType[0].name if item_type else slot_type.replace(\" \", \"\")\n        filter_name = filter_name_template\n        i = 2\n        while any(filter_name == next(iter(x)) for x in finished_filters):\n            filter_name = f\"{filter_name_template}{i}\"\n            i += 1\n        finished_filters.append({filter_name: item_filter})\n    profile = ProfileModel(name=\"imported profile\", Affixes=sort_profile_filters(finished_filters))\n    if config.import_aspect_upgrades and aspect_upgrade_filters:\n        profile.AspectUpgrades = aspect_upgrade_filters\n\n    file_name = config.custom_file_name or build_default_profile_file_name(\n        source_name=\"mobalytics\",\n        class_name=class_name,\n        season_number=season_number,\n        build_header=build_header,\n        variant_name=variant_name,\n    )\n    # Optionally embed Paragon data into the profile model before saving\n    if config.export_paragon:\n        steps = extract_mobalytics_paragon_steps(paragon_data if isinstance(paragon_data, dict) else {})\n        if steps:\n            profile.Paragon = build_paragon_profile_payload(\n                build_name=build_name, source_url=url, paragon_boards_list=steps\n            )\n        else:\n            LOGGER.warning(\"Paragon export enabled, but no paragon data was found for this Mobalytics variant.\")\n\n    corrected_file_name = save_as_profile(file_name=file_name, profile=profile, url=url)\n\n    if config.add_to_profiles:\n        add_to_profiles(corrected_file_name)\n\n    LOGGER.info(\"Finished\")\n\n\ndef _corrections(input_str: str) -> str:\n    match input_str.lower():\n        case \"max life\":\n            return \"maximum life\"\n    return input_str\n\n\ndef _fix_input_url(url: str) -> str:\n    return unquote(url)\n\n\ndef _extract_mobalytics_season_number(full_script_data_json: dict) -> str:\n    tag_names = jsonpath.findall(\"$..userGeneratedDocumentBySlug.data.tags.data[*].name\", full_script_data_json)\n    for tag_name in tag_names:\n        if season_match := re.search(r\"\\bSeason\\s+(\\d+)\\b\", str(tag_name), flags=re.IGNORECASE):\n            season_number = season_match.group(1)\n            break\n    else:\n        season_number = \"\"\n    return season_number\n\n\ndef _get_legendary_aspect(name: str) -> str:\n    if \"aspect\" in name.lower():\n        aspect_name = correct_name(name.lower().replace(\"aspect\", \"\").strip())\n\n        if aspect_name not in Dataloader().aspect_list:\n            LOGGER.warning(\n                f\"Legendary aspect '{aspect_name}' that is not in our aspect data, unable to add to AspectUpgrades.\"\n            )\n        else:\n            return aspect_name\n    return \"\"\n\n\ndef _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) -> list[Affix]:\n    result = []\n    for stat in raw_stats:\n        if stat:\n            affix_obj = Affix(\n                name=closest_match(clean_str(_corrections(input_str=stat[\"id\"])), Dataloader().affix_dict)\n            )\n            if affix_obj.name is None:\n                LOGGER.error(f\"Couldn't match {stat=}\")\n                continue\n            if import_greater_affixes and stat.get(\"isGreater\", False):\n                affix_obj.type = AffixType.greater\n            result.append(affix_obj)\n    return result\n\n\nif __name__ == \"__main__\":\n    src.logger.setup()\n    URLS = [\n        # # No frills and no uniques\n        # \"https://mobalytics.gg/diablo-4/builds/barbarian-whirlwind-leveling-barb\",\n        # # Is a variant of the one above\n        # \"https://mobalytics.gg/diablo-4/builds/barbarian-whirlwind-leveling-barb?ws-ngf5-1=activeVariantId%2C7a9c6d51-18e9-4090-a804-7b73ff00879d\",\n        # # This one has no variants at all, just to make sure that works too\n        # \"https://mobalytics.gg/diablo-4/profile/screamheart/builds/15x-thrash-out-of-date\",\n        # # This one has an item type for the weapon\n        # \"https://mobalytics.gg/diablo-4/builds/druid-zaior-pulverize-druid\",\n        # # This has a necro offhand\n        # \"https://mobalytics.gg/diablo-4/builds/necromancer-kripp-golem-summoner\",\n        # # This has two rogue offhand weapons\n        # \"https://mobalytics.gg/diablo-4/builds/rogue-efficientrogue-dance-of-knives?ws-ngf5-1=activeVariantId%2Ca2977139-f3e2-4b13-aa64-82ba69972528\",\n        # Season 13 testing\n        \"https://mobalytics.gg/diablo-4/builds/warlock-profane-sentinel-endgame\"\n    ]\n    for X in URLS:\n        config = ImportConfig(\n            url=X,\n            import_aspect_upgrades=True,\n            add_to_profiles=False,\n            import_greater_affixes=True,\n            require_greater_affixes=True,\n            export_paragon=True,\n            custom_file_name=None,\n        )\n        import_mobalytics(config)\n"
  },
  {
    "path": "src/gui/importer/paragon_export.py",
    "content": "from __future__ import annotations\n\nimport datetime\nimport logging\nimport re\nimport time\nfrom typing import TYPE_CHECKING, Any\n\nfrom src import __version__\nfrom src.gui.importer.gui_common import PLAYER_CLASSES\n\ntry:\n    from selenium.webdriver.common.by import By\n    from selenium.webdriver.support.ui import WebDriverWait\nexcept ImportError:  # pragma: no cover\n    By = None  # type: ignore[assignment]\n    WebDriverWait = None  # type: ignore[assignment]\n\nif TYPE_CHECKING:\n    from selenium.webdriver.remote.webdriver import WebDriver\n    from selenium.webdriver.support.ui import WebDriverWait as SeleniumWebDriverWait\n\n\n#\n# =============================================================================\n# SHARED SLUG HELPERS\n# =============================================================================\n\n\ndef _class_slug_from_name(class_name: str) -> str:\n    \"\"\"Normalize a build class label into the shared export slug format.\"\"\"\n    class_name = (class_name or \"\").strip().lower()\n    if not class_name or class_name == \"unknown\":\n        return \"\"\n    # Normalize planner-provided labels so all exporters use the same class prefix.\n    return re.sub(r\"[^a-z0-9\\-]\", \"\", re.sub(r\"[\\s_]+\", \"-\", class_name))\n\n\ndef _prefix_with_class_slug(slug: str, class_slug: str) -> str:\n    \"\"\"Prefix a slug with its class name once, matching the other exporters.\"\"\"\n    if not slug:\n        return slug\n    if not class_slug:\n        return slug\n    if slug.startswith(class_slug + \"-\"):\n        return slug\n    return f\"{class_slug}-{slug}\"\n\n\nLOGGER = logging.getLogger(__name__)\n\nGRID = 21\nNODES_LEN = GRID * GRID\n\n\n#\n# =============================================================================\n# MAXROLL NAME MAPS\n# =============================================================================\n# Maxroll ID -> human friendly names (ported from Diablo4Companion data files).\n# Used to export Paragon JSON with readable identifiers similar to Mobalytics.\n\n_MAXROLL_BOARD_ID_TO_NAME = {\n    \"Paragon_Barb_00\": \"Start\",\n    \"Paragon_Barb_01\": \"Hemorrhage\",\n    \"Paragon_Barb_02\": \"Blood Rage\",\n    \"Paragon_Barb_03\": \"Carnage\",\n    \"Paragon_Barb_04\": \"Decimator\",\n    \"Paragon_Barb_05\": \"Bone Breaker\",\n    \"Paragon_Barb_06\": \"Flawless Technique\",\n    \"Paragon_Barb_07\": \"Warbringer\",\n    \"Paragon_Barb_08\": \"Weapons Master\",\n    \"Paragon_Barb_10\": \"Force of Nature\",\n    \"Paragon_Druid_00\": \"Start\",\n    \"Paragon_Druid_01\": \"Thunderstruck\",\n    \"Paragon_Druid_02\": \"Earthen Devastation\",\n    \"Paragon_Druid_03\": \"Survival Instincts\",\n    \"Paragon_Druid_04\": \"Lust for Carnage\",\n    \"Paragon_Druid_05\": \"Heightened Malice\",\n    \"Paragon_Druid_06\": \"Inner Beast\",\n    \"Paragon_Druid_07\": \"Constricting Tendrils\",\n    \"Paragon_Druid_08\": \"Ancestral Guidance\",\n    \"Paragon_Druid_10\": \"Untamed\",\n    \"Paragon_Necro_00\": \"Start\",\n    \"Paragon_Necro_01\": \"Cult Leader\",\n    \"Paragon_Necro_02\": \"Hulking Monstrosity\",\n    \"Paragon_Necro_03\": \"Flesh-eater\",\n    \"Paragon_Necro_04\": \"Scent of Death\",\n    \"Paragon_Necro_05\": \"Bone Graft\",\n    \"Paragon_Necro_06\": \"Blood Begets Blood\",\n    \"Paragon_Necro_07\": \"Bloodbath\",\n    \"Paragon_Necro_08\": \"Wither\",\n    \"Paragon_Necro_10\": \"Frailty\",\n    \"Paragon_Paladin_00\": \"Start\",\n    \"Paragon_Paladin_01\": \"Castle\",\n    \"Paragon_Paladin_02\": \"Shield Bearer\",\n    \"Paragon_Paladin_03\": \"Fervent\",\n    \"Paragon_Paladin_04\": \"Preacher\",\n    \"Paragon_Paladin_05\": \"Divinity\",\n    \"Paragon_Paladin_06\": \"Relentless\",\n    \"Paragon_Paladin_07\": \"Sentencing\",\n    \"Paragon_Paladin_08\": \"Endure\",\n    \"Paragon_Paladin_09\": \"Beacon\",\n    \"Paragon_Rogue_00\": \"Start\",\n    \"Paragon_Rogue_01\": \"Eldritch Bounty\",\n    \"Paragon_Rogue_02\": \"Tricks of the Trade\",\n    \"Paragon_Rogue_03\": \"Cheap Shot\",\n    \"Paragon_Rogue_04\": \"Deadly Ambush\",\n    \"Paragon_Rogue_05\": \"Leyrana's Instinct\",\n    \"Paragon_Rogue_06\": \"No Witnesses\",\n    \"Paragon_Rogue_07\": \"Exploit Weakness\",\n    \"Paragon_Rogue_08\": \"Cunning Stratagem\",\n    \"Paragon_Rogue_10\": \"Danse Macabre\",\n    \"Paragon_Sorc_00\": \"Start\",\n    \"Paragon_Sorc_01\": \"Searing Heat\",\n    \"Paragon_Sorc_02\": \"Frigid Fate\",\n    \"Paragon_Sorc_03\": \"Static Surge\",\n    \"Paragon_Sorc_04\": \"Elemental Summoner\",\n    \"Paragon_Sorc_05\": \"Burning Instinct\",\n    \"Paragon_Sorc_06\": \"Icefall\",\n    \"Paragon_Sorc_07\": \"Ceaseless Conduit\",\n    \"Paragon_Sorc_08\": \"Enchantment Master\",\n    \"Paragon_Sorc_10\": \"Fundamental Release\",\n    \"Paragon_Spirit_0\": \"Start\",\n    \"Paragon_Spirit_01\": \"In-Fighter\",\n    \"Paragon_Spirit_02\": \"Spiney Skin\",\n    \"Paragon_Spirit_03\": \"Viscous Shield\",\n    \"Paragon_Spirit_04\": \"Bitter Medicine\",\n    \"Paragon_Spirit_05\": \"Revealing\",\n    \"Paragon_Spirit_06\": \"Drive\",\n    \"Paragon_Spirit_07\": \"Convergence\",\n    \"Paragon_Spirit_08\": \"Sapping\",\n}\n\n_MAXROLL_GLYPH_ID_TO_NAME = {\n    \"Rare_001_Intelligence_Main\": \"Enchanter\",\n    \"Rare_002_Intelligence_Main\": \"Unleash\",\n    \"Rare_003_Intelligence_Main\": \"Elementalist\",\n    \"Rare_004_Intelligence_Main\": \"Adept\",\n    \"Rare_005_Intelligence_Main\": \"Conjurer\",\n    \"Rare_006_Intelligence_Main\": \"Charged\",\n    \"Rare_007_Willpower_Side\": \"Torch\",\n    \"Rare_008_Willpower_Side\": \"Pyromaniac\",\n    \"Rare_009_Willpower_Side\": \"Cryopathy\",\n    \"Rare_010_Dexterity_Main\": \"Tactician\",\n    \"Rare_011_Intelligence_Side\": \"Guzzler\",\n    \"Rare_011_Willpower_Side\": \"Imbiber\",\n    \"Rare_012_Intelligence_Side\": \"Protector\",\n    \"Rare_012_Willpower_Side\": \"Reinforced\",\n    \"Rare_013_Dexterity_Side\": \"Poise\",\n    \"Rare_014_Dexterity_Side\": \"Territorial\",\n    \"Rare_014_Strength_Main\": \"Turf\",\n    \"Rare_014_Strength_Side\": \"Turf\",\n    \"Rare_015_Dexterity_Side\": \"Flamefeeder\",\n    \"Rare_016_Dexterity_Side\": \"Exploit\",\n    \"Rare_016_Intelligence_Side\": \"Exploit\",\n    \"Rare_016_Strength_Side\": \"Exploit\",\n    \"Rare_017_Dexterity_Side\": \"Winter\",\n    \"Rare_018_Dexterity_Side\": \"Electrocute\",\n    \"Rare_019_Dexterity_Side\": \"Destruction\",\n    \"Rare_020_Dexterity_Side\": \"Control\",\n    \"Rare_020_Intelligence_Main\": \"Control\",\n    \"Rare_020_Intelligence_Side\": \"Control\",\n    \"Rare_021_Strength_Main\": \"Ambidextrous\",\n    \"Rare_022_Strength_Main\": \"Might\",\n    \"Rare_023_Strength_Main\": \"Cleaver\",\n    \"Rare_024_Strength_Main\": \"Seething\",\n    \"Rare_025_Strength_Main\": \"Crusher\",\n    \"Rare_026_Strength_Main\": \"Executioner\",\n    \"Rare_027_Strength_Main\": \"Ire\",\n    \"Rare_028_Strength_Main\": \"Marshal\",\n    \"Rare_029_Dexterity_Side\": \"Bloodfeeder\",\n    \"Rare_030_Dexterity_Side\": \"Wrath\",\n    \"Rare_031_Dexterity_Side\": \"Weapon Master\",\n    \"Rare_032_Dexterity_Side\": \"Mortal Draw\",\n    \"Rare_033_Intelligence_Side\": \"Revenge\",\n    \"Rare_033_Willpower_Side\": \"Revenge\",\n    \"Rare_033_Willpower_Side_Necro\": \"Revenge\",\n    \"Rare_034_Intelligence_Side\": \"Undaunted\",\n    \"Rare_034_Willpower_Side\": \"Undaunted\",\n    \"Rare_035_Intelligence_Side\": \"Dominate\",\n    \"Rare_035_Willpower_Side\": \"Dominate\",\n    \"Rare_035_Willpower_Side_Necro\": \"Dominate\",\n    \"Rare_036_Willpower_Side\": \"Disembowel\",\n    \"Rare_037_Willpower_Side\": \"Brawl\",\n    \"Rare_038_Intelligence_Main\": \"Corporeal\",\n    \"Rare_039_Willpower_Main\": \"Fang and Claw\",\n    \"Rare_040_Willpower_Main\": \"Earth and Sky\",\n    \"Rare_041_Intelligence_Side\": \"Wilds\",\n    \"Rare_042_Willpower_Main\": \"Werebear\",\n    \"Rare_043_Willpower_Main\": \"Werewolf\",\n    \"Rare_044_Willpower_Main\": \"Human\",\n    \"Rare_045_Intelligence_Side\": \"Bane\",\n    \"Rare_045_Strength_Side\": \"Bane\",\n    \"Rare_046_Dexterity_Side\": \"Abyssal\",\n    \"Rare_046_Intelligence_Side\": \"Keeper\",\n    \"Rare_047_Dexterity_Side\": \"Fulminate\",\n    \"Rare_047_Intelligence_Side\": \"Fulminate\",\n    \"Rare_048_Dexterity_Side\": \"Tracker\",\n    \"Rare_048_Intelligence_Side\": \"Tracker\",\n    \"Rare_049_Dexterity_Side\": \"Outmatch\",\n    \"Rare_049_Strength_Main\": \"Outmatch\",\n    \"Rare_049_Strength_Side\": \"Outmatch\",\n    \"Rare_050_Dexterity_Main\": \"Spirit\",\n    \"Rare_050_Dexterity_Side\": \"Spirit\",\n    \"Rare_050_Willpower_Side\": \"Spirit\",\n    \"Rare_051_Dexterity_Side\": \"Shapeshifter\",\n    \"Rare_052_Dexterity_Main\": \"Versatility\",\n    \"Rare_053_Dexterity_Main\": \"Closer\",\n    \"Rare_054_Dexterity_Main\": \"Ranger\",\n    \"Rare_055_Dexterity_Main\": \"Chip\",\n    \"Rare_055_Dexterity_Side\": \"Chip\",\n    \"Rare_055_Willpower_Side\": \"Chip\",\n    \"Rare_056_Dexterity_Main\": \"Frostfeeder\",\n    \"Rare_057_Dexterity_Main\": \"Fluidity\",\n    \"Rare_058_Intelligence_Side\": \"Infusion\",\n    \"Rare_059_Dexterity_Main\": \"Devious\",\n    \"Rare_060_Dexterity_Side\": \"Warrior\",\n    \"Rare_061_Intelligence_Side\": \"Combat\",\n    \"Rare_062_Dexterity_Side\": \"Gravekeeper\",\n    \"Rare_063_Intelligence_Side\": \"Canny\",\n    \"Rare_064_Intelligence_Side\": \"Efficacy\",\n    \"Rare_065_Intelligence_Side\": \"Snare\",\n    \"Rare_066_Dexterity_Side\": \"Essence\",\n    \"Rare_067_Strength_Side\": \"Pride\",\n    \"Rare_068_Strength_Side\": \"Ambush\",\n    \"Rare_069_Intelligence_Main\": \"Sacrificial\",\n    \"Rare_070_Intelligence_Main\": \"Blood-drinker\",\n    \"Rare_071_Intelligence_Main\": \"Deadraiser\",\n    \"Rare_072_Intelligence_Main\": \"Mage\",\n    \"Rare_073_Intelligence_Main\": \"Amplify\",\n    \"Rare_074_Willpower_Side\": \"Golem\",\n    \"Rare_075_Willpower_Side\": \"Scourge\",\n    \"Rare_076_Strength_Main\": \"Diminish\",\n    \"Rare_076_Strength_Side\": \"Diminish\",\n    \"Rare_077_Willpower_Side\": \"Warding\",\n    \"Rare_078_Willpower_Side\": \"Darkness\",\n    \"Rare_079_Dexterity_Side\": \"Exploit\",\n    \"Rare_080_Strength_Main\": \"Twister\",\n    \"Rare_081_Strength_Main\": \"Rumble\",\n    \"Rare_082_Dexterity_Main\": \"Explosive\",\n    \"Rare_083_Intelligence_Side\": \"Nightstalker\",\n    \"Rare_084_Intelligence_Main\": \"Stalagmite\",\n    \"Rare_085_Dexterity_Side\": \"Invocation\",\n    \"Rare_086_Dexterity_Side\": \"Tectonic\",\n    \"Rare_087_Willpower_Main\": \"Electrocution\",\n    \"Rare_088_Intelligence_Main\": \"Exhumation\",\n    \"Rare_089_Willpower_Side\": \"Desecration\",\n    \"Rare_090_Dexterity_Main\": \"Menagerist\",\n    \"Rare_091_Strength_Side\": \"Hone\",\n    \"Rare_092_Intelligence_Side\": \"Consumption\",\n    \"Rare_093_Dexterity_Main\": \"Fitness\",\n    \"Rare_094_Intelligence_Side\": \"Ritual\",\n    \"Rare_095_Dexterity_Main\": \"Jagged Plume\",\n    \"Rare_096_Strength_Side\": \"Innate\",\n    \"Rare_097_Dexterity_Main\": \"Wildfire\",\n    \"Rare_098_Strength_Side\": \"Colossal\",\n    \"Rare_100_Dexterity_Main\": \"Talon\",\n    \"Rare_101_Strength_Side\": \"Hubris\",\n    \"Rare_102_Dexterity_Main\": \"Fester\",\n    \"Rare_103_Strength_Main\": \"Sentinel\",\n    \"Rare_104_Dexterity_Side\": \"Honed\",\n    \"Rare_105_Strength_Main\": \"Law\",\n    \"Rare_106_Willpower_Side\": \"Arbiter \",\n    \"Rare_107_Strength_Main\": \"Resplendence\",\n    \"Rare_108_Intelligence_Side\": \"Judicator\",\n    \"Rare_109_Dexterity_Side\": \"Feverous\",\n    \"Rare_110_Strength_Main\": \"Apostle\",\n    \"Rare_Dex_Generic\": \"Headhunter\",\n    \"Rare_Int_Generic\": \"Eliminator\",\n    \"Rare_Str_Generic\": \"Challenger\",\n    \"Rare_Will_Generic\": \"Headhunter\",\n}\n\n\n#\n# =============================================================================\n# GENERAL EXPORT HELPERS\n# =============================================================================\n\n\ndef _slugify(s: str) -> str:\n    \"\"\"Collapse planner labels into stable lowercase slug tokens.\"\"\"\n    s = (s or \"\").strip().lower()\n    s = re.sub(r\"[^a-z0-9]+\", \"-\", s)\n    return s.strip(\"-\")\n\n\ndef _maxroll_class_slug(board_id: str) -> str:\n    # Example: \"Paragon_Paladin_02\" -> \"paladin\"\n    m = re.match(r\"^Paragon_([A-Za-z]+)_\\d+$\", board_id or \"\")\n    return _slugify(m.group(1)) if m else \"\"\n\n\ndef _maxroll_board_slug(board_id: str) -> str:\n    cls = _maxroll_class_slug(board_id)\n    name = _MAXROLL_BOARD_ID_TO_NAME.get(board_id, board_id)\n    name_slug = _slugify(name)\n    return f\"{cls}-{name_slug}\" if cls and name_slug else _slugify(board_id)\n\n\ndef _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str:\n    # We prefix with class for consistency with Mobalytics output.\n    cls = _maxroll_class_slug(board_id)\n    name = _MAXROLL_GLYPH_ID_TO_NAME.get(glyph_id, glyph_id)\n    name_slug = _slugify(name)\n    return f\"{cls}-{name_slug}\" if cls and name_slug else _slugify(glyph_id)\n\n\n#\n# =============================================================================\n# PAYLOAD BUILDER\n# =============================================================================\n\n\ndef build_paragon_profile_payload(\n    build_name: str, source_url: str, paragon_boards_list: list[list[dict[str, Any]]]\n) -> dict[str, Any]:\n    \"\"\"Build the Paragon payload intended to be embedded into a profile YAML.\n\n    The structure matches the existing JSON export payload (without the outer list wrapper).\n    \"\"\"\n    return {\n        \"Name\": build_name,\n        \"Source\": source_url,\n        \"GeneratedAt\": datetime.datetime.now(tz=datetime.UTC).strftime(\"%Y-%m-%d %H:%M:%S UTC\"),\n        \"Generator\": f\"d4lf v{__version__}\",\n        \"ParagonBoardsList\": paragon_boards_list,\n    }\n\n\n#\n# =============================================================================\n# MAXROLL EXPORT\n# =============================================================================\n\n\ndef extract_maxroll_paragon_steps(active_profile: dict[str, Any]) -> list[list[dict[str, Any]]]:\n    \"\"\"Extract paragon steps from Maxroll planner data.\n\n    Matches the rotation + node-index transformation used in Diablo4Companion.\n    \"\"\"\n    steps_out: list[list[dict[str, Any]]] = []\n    paragon = (active_profile or {}).get(\"paragon\") or {}\n    steps = paragon.get(\"steps\") or []\n\n    for step in steps:\n        boards_out: list[dict[str, Any]] = []\n        for bd in (step or {}).get(\"data\") or []:\n            board_id = (bd or {}).get(\"id\", \"\")\n            glyph_id = (bd or {}).get(\"glyph\", \"\")\n            rotation = int((bd or {}).get(\"rotation\", 0))\n            nodes_bool = [False] * NODES_LEN\n\n            # Maxroll stores active nodes as a dict keyed by flat node indices.\n            nodes_dict = (bd or {}).get(\"nodes\") or {}\n            for loc_key in nodes_dict:\n                try:\n                    loc = int(loc_key)\n                except TypeError, ValueError:\n                    loc = None\n                if loc is None:\n                    continue\n                idx = _transform_maxroll_location(loc=loc, rotation=rotation)\n                if 0 <= idx < NODES_LEN:\n                    nodes_bool[idx] = True\n\n            boards_out.append({\n                \"Name\": _maxroll_board_slug(board_id),\n                \"Glyph\": _maxroll_glyph_slug(glyph_id, board_id) if glyph_id else \"\",\n                \"Rotation\": _rotation_info_maxroll(rotation),\n                \"Nodes\": nodes_bool,\n                \"BoardId\": board_id,\n                \"GlyphId\": glyph_id,\n            })\n\n        if boards_out:\n            steps_out.append(boards_out)\n\n    return steps_out\n\n\n#\n# =============================================================================\n# MOBALYTICS EXPORT\n# =============================================================================\n\n\ndef _fix_mobalytics_starting_board_slug(board_slug: str) -> str:\n    \"\"\"Normalize Mobalytics' starter-board naming to the shared starting-board form.\"\"\"\n    for player_class in PLAYER_CLASSES:\n        board_slug = board_slug.replace(f\"{player_class}-starter-board\", f\"{player_class}-starting-board\")\n    return board_slug\n\n\ndef extract_mobalytics_paragon_steps(paragon_data: dict[str, Any]) -> list[list[dict[str, Any]]]:\n    \"\"\"Extract paragon boards from Mobalytics preloaded-state build variant.\n\n    Matches the rotation + node-index transformation used in Diablo4Companion.\n    \"\"\"\n    paragon = paragon_data or {}\n    boards_data = paragon.get(\"boards\") or []\n    nodes_data = paragon.get(\"nodes\") or []\n\n    boards_out: list[dict[str, Any]] = []\n\n    for board in boards_data:\n        board_slug = ((board or {}).get(\"board\") or {}).get(\"slug\", \"\")\n        board_slug = _fix_mobalytics_starting_board_slug(board_slug)\n\n        glyph_slug = ((board or {}).get(\"glyph\") or {}).get(\"slug\", \"\")\n        rotation = int((board or {}).get(\"rotation\", 0))\n\n        nodes_bool = [False] * NODES_LEN\n        # Mobalytics exposes nodes as one flat list, so filter it back down to the current board first.\n        board_nodes = [\n            n\n            for n in nodes_data\n            if isinstance(n, dict) and isinstance(n.get(\"slug\"), str) and n[\"slug\"].startswith(board_slug)\n        ]\n\n        for n in board_nodes:\n            slug = n.get(\"slug\", \"\")\n            node_position = slug.replace(board_slug + \"-\", \"\")\n            try:\n                x_part, y_part = node_position.split(\"-\", 1)\n                x = int(x_part.lstrip(\"x\"))\n                y = int(y_part.lstrip(\"y\"))\n            except ValueError, IndexError:\n                x = None\n                y = None\n            if x is None or y is None:\n                continue\n\n            idx = _transform_xy_common(x=x, y=y, rotation_deg=rotation, base=\"mobalytics\")\n            if 0 <= idx < NODES_LEN:\n                nodes_bool[idx] = True\n\n        boards_out.append({\n            \"Name\": board_slug,\n            \"Glyph\": glyph_slug,\n            \"Rotation\": _rotation_info_degrees(rotation),\n            \"Nodes\": nodes_bool,\n        })\n\n    return [boards_out] if boards_out else []\n\n\n#\n# =============================================================================\n# D4BUILDS EXPORT\n# =============================================================================\n\n\ndef _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[list[dict[str, Any]]]:\n    \"\"\"Parse D4Builds paragon boards from the currently loaded page.\n\n    D4Builds does not expose the board export as a ready-made JSON payload, so this\n    parser reconstructs one from DOM text, element attributes, and active tile classes.\n    \"\"\"\n    boards_out: list[dict[str, Any]] = []\n    try:\n        board_elements = driver.find_elements(By.CLASS_NAME, \"paragon__board\")\n    except Exception:\n        board_elements = []\n\n    for board_elem in board_elements:\n        name_raw = \"\"\n        lines = []\n        name_display = \"\"\n        try:\n            name_raw = board_elem.find_element(By.CLASS_NAME, \"paragon__board__name\").get_attribute(\"innerText\") or \"\"\n            lines = [ln.strip() for ln in (name_raw or \"\").splitlines() if ln.strip()]\n            # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1)\n            name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else \"\"))\n        except Exception:\n            name_display = \"\"\n\n        # Try to detect a stable board id/slug from element attributes (best effort)\n        board_id = \"\"\n        try:\n            attrs = driver.execute_script(\n                \"var a=arguments[0].attributes; var o={}; for (var i=0;i<a.length;i++){o[a[i].name]=a[i].value}; return o;\",\n                board_elem,\n            )\n            if isinstance(attrs, dict):\n                for key in (\"data-board\", \"data-board-id\", \"data-id\", \"data-name\", \"data-board-name\", \"data-boardname\"):\n                    v = attrs.get(key)\n                    if isinstance(v, str) and v.strip():\n                        board_id = v.strip()\n                        break\n                if not board_id:\n                    for v in attrs.values():\n                        if isinstance(v, str):\n                            vv = v.strip()\n                            if vv and \"-\" in vv and re.fullmatch(r\"[A-Za-z0-9_-]{3,64}\", vv):\n                                board_id = vv\n                                break\n        except Exception:\n            LOGGER.debug(\"Failed to infer board id (continuing).\", exc_info=True)\n\n        name_slug = _slugify(board_id or name_display)\n        name_slug = _prefix_with_class_slug(name_slug, class_slug)\n        if not name_slug and lines and str(lines[0]).isdigit():\n            name_slug = f\"board-{lines[0]}\"\n\n        glyph_raw = \"\"\n        try:\n            glyph_elems = board_elem.find_elements(By.CLASS_NAME, \"paragon__board__name__glyph\")\n            if glyph_elems:\n                glyph_raw = (glyph_elems[0].get_attribute(\"innerText\") or \"\").strip()\n        except Exception:\n            LOGGER.debug(\"Failed to read glyph name (continuing).\", exc_info=True)\n\n        glyph_display = (glyph_raw or \"\").replace(\"(\", \"\").replace(\")\", \"\").strip()\n        glyph_slug = _slugify(glyph_display)\n        glyph_slug = _prefix_with_class_slug(glyph_slug, class_slug)\n\n        style_str = board_elem.get_attribute(\"style\") or \"\"\n        rotate_int = 0\n        if \"rotate(\" in style_str:\n            mm = re.search(r\"rotate\\(([-\\d]+)deg\\)\", style_str)\n            if mm:\n                try:\n                    rotate_int = int(mm.group(1)) % 360\n                except Exception:\n                    rotate_int = 0\n\n        nodes = [False] * (21 * 21)\n\n        try:\n            tile_elems = board_elem.find_elements(By.CLASS_NAME, \"paragon__board__tile\")\n        except Exception:\n            tile_elems = []\n\n        # D4Builds encodes the active grid coordinates in CSS class tokens like \"r2 c10\".\n        for tile in tile_elems:\n            cls = tile.get_attribute(\"class\") or \"\"\n            if \"active\" not in cls:\n                continue\n            parts = [pp for pp in cls.split() if pp]\n            # Example: \"paragon__board__tile r2 c10 active enabled\"\n            r_part = next((x for x in parts if x.startswith(\"r\")), \"r0\")\n            c_part = next((x for x in parts if x.startswith(\"c\")), \"c0\")\n            r = int(\"\".join(ch for ch in r_part if ch.isdigit()) or \"0\")\n            c = int(\"\".join(ch for ch in c_part if ch.isdigit()) or \"0\")\n\n            # Transform coordinates based on rotation (matching Diablo4Companion)\n            x = c\n            y = r\n            if rotate_int == 0:\n                x = x - 1\n                y = y - 1\n            elif rotate_int == 90:\n                x = 21 - r\n                y = c - 1\n            elif rotate_int == 180:\n                x = 21 - c\n                y = 21 - r\n            elif rotate_int == 270:\n                x = r - 1\n                y = 21 - c\n\n            if 0 <= x < 21 and 0 <= y < 21:\n                nodes[y * 21 + x] = True\n\n        boards_out.append({\n            \"Name\": name_slug or \"paragon-board\",\n            \"Glyph\": glyph_slug,\n            \"Rotation\": f\"{rotate_int}°\" if rotate_int in (0, 90, 180, 270) else \"0°\",\n            \"Nodes\": nodes,\n        })\n\n    return [boards_out]\n\n\ndef extract_d4builds_paragon_steps(\n    driver: WebDriver, class_name: str = \"\", *, wait: SeleniumWebDriverWait | None = None\n) -> list[list[dict[str, Any]]]:\n    \"\"\"Extract paragon boards from D4Builds using Selenium.\n\n    This reuses the existing Selenium session/page state created by the importer. We only\n    click/wait for the Paragon tab if boards are not already present in the DOM.\n    \"\"\"\n    class_slug = _class_slug_from_name(class_name)\n\n    if By is None or WebDriverWait is None:  # pragma: no cover\n        msg = \"Selenium not available, cannot export D4Builds paragon\"\n        raise RuntimeError(msg)\n\n    if wait is None:\n        wait = WebDriverWait(driver, 10)\n\n    # Fast path: if boards are already present, don't click/wait again.\n    try:\n        if driver.find_elements(By.CLASS_NAME, \"paragon__board\"):\n            return _parse_d4builds_paragon_boards(driver, class_slug)\n    except Exception:\n        LOGGER.debug(\"Could not query for existing D4Builds paragon boards (continuing).\", exc_info=True)\n\n    # Best effort: ensure the navigation is present before attempting to click Paragon.\n    try:\n        wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, \"builder__navigation__link\")) > 0)\n    except Exception:\n        LOGGER.debug(\"Timed out waiting for D4Builds navigation links (continuing).\", exc_info=True)\n\n    # Switch to Paragon tab (D4Builds uses left navigation links)\n    try:\n        nav_links = driver.find_elements(By.CLASS_NAME, \"builder__navigation__link\")\n        if len(nav_links) >= 3:\n            driver.execute_script(\"arguments[0].click();\", nav_links[2])\n        else:\n            # Fallback: click any element containing 'Paragon'\n            el = driver.find_element(By.XPATH, \"//*[contains(normalize-space(.), 'Paragon')]\")\n            driver.execute_script(\"arguments[0].click();\", el)\n        time.sleep(0.25)\n    except Exception:\n        # Not fatal: sometimes paragon is already visible or site changed\n        LOGGER.debug(\"Could not click Paragon tab (continuing).\", exc_info=True)\n\n    # Wait for paragon boards to appear (best effort)\n    try:\n        wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, \"paragon__board\")) > 0)\n    except Exception:\n        LOGGER.debug(\"Timed out waiting for D4Builds paragon boards (continuing).\", exc_info=True)\n\n    return _parse_d4builds_paragon_boards(driver, class_slug)\n\n\n#\n# =============================================================================\n# SHARED COORDINATE TRANSFORMS\n# =============================================================================\n\n\ndef _rotation_info_maxroll(rot: int) -> str:\n    return {0: \"0°\", 1: \"90°\", 2: \"180°\", 3: \"270°\"}.get(rot, \"?°\")\n\n\ndef _rotation_info_degrees(rot: int) -> str:\n    rot = rot % 360\n    return {0: \"0°\", 90: \"90°\", 180: \"180°\", 270: \"270°\"}.get(rot, \"?°\")\n\n\ndef _transform_maxroll_location(loc: int, rotation: int) -> int:\n    \"\"\"Transform a 0-based location index from Maxroll into the Nodes[] index.\n\n    This follows the exact switch used in Diablo4Companion BuildsManagerMaxroll.\n    \"\"\"\n    x = loc % GRID\n    y = loc // GRID\n    xt = x\n    yt = y\n\n    match rotation:\n        case 0:\n            return loc\n        case 1:\n            xt = GRID - y\n            yt = x\n            xt -= 1\n            return yt * GRID + xt\n        case 2:\n            xt = GRID - x\n            yt = GRID - y\n            xt -= 1\n            yt -= 1\n            return yt * GRID + xt\n        case 3:\n            xt = y\n            yt = GRID - x\n            yt -= 1\n            return yt * GRID + xt\n        case _:\n            return loc\n\n\ndef _transform_xy_common(x: int, y: int, rotation_deg: int, base: str) -> int:\n    \"\"\"Shared x/y to Nodes[] transform.\n\n    base:\n      - 'd4builds' uses 1-based r/c coordinates.\n      - 'mobalytics' uses 1-based x/y coordinates.\n\n    The formulas mirror Diablo4Companion's implementations for each source.\n    \"\"\"\n    rotation_deg = rotation_deg % 360\n\n    xt = x\n    yt = y\n\n    if base in {\"d4builds\", \"mobalytics\"}:\n        # both sources provide 1-based coords in the '0°' case and need (x-1, y-1)\n        if rotation_deg in {0, 360}:\n            xt -= 1\n            yt -= 1\n        elif rotation_deg == 90:\n            xt = GRID - y\n            yt = x\n            yt -= 1\n        elif rotation_deg == 180:\n            xt = GRID - x\n            yt = GRID - y\n        elif rotation_deg == 270:\n            xt = y\n            yt = GRID - x\n            xt -= 1\n\n    return yt * GRID + xt\n"
  },
  {
    "path": "src/gui/importer_window.py",
    "content": "import logging\nimport sys\nimport threading\nfrom pathlib import Path\n\nfrom PyQt6.QtCore import QObject, QPoint, QRunnable, QSettings, QSize, Qt, QThreadPool, pyqtSignal, pyqtSlot\nfrom PyQt6.QtGui import QIcon\nfrom PyQt6.QtWidgets import (\n    QCheckBox,\n    QHBoxLayout,\n    QLabel,\n    QLineEdit,\n    QMainWindow,\n    QPushButton,\n    QTextEdit,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.loader import IniConfigLoader\nfrom src.gui.importer.d4builds import import_d4builds\nfrom src.gui.importer.importer_config import ImportConfig\nfrom src.gui.importer.maxroll import import_maxroll\nfrom src.gui.importer.mobalytics import import_mobalytics\nfrom src.gui.open_user_config_button import OpenUserConfigButton\n\nBASE_DIR = Path(sys.executable).parent if getattr(sys, \"frozen\", False) else Path(__file__).resolve().parent.parent\n\nICON_PATH = BASE_DIR / \"assets\" / \"logo.png\"\n\nLOGGER = logging.getLogger(__name__)\nTHREADPOOL = QThreadPool()\n\n\nclass ImporterWindow(QMainWindow):\n    \"\"\"Standalone window for Maxroll/D4Builds/Mobalytics importer.\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n\n        if ICON_PATH.exists():\n            self.setWindowIcon(QIcon(str(ICON_PATH)))\n\n        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)\n\n        # Settings for persistent window geometry\n        self.settings = QSettings(\"d4lf\", \"ImporterWindow\")\n\n        self.setWindowTitle(\"Profile Importer - Maxroll / D4Builds / Mobalytics\")\n        self.setMinimumSize(700, 600)\n\n        # Restore window geometry\n        self.resize(self.settings.value(\"size\", QSize(700, 600)))\n        self.move(self.settings.value(\"pos\", QPoint(100, 100)))\n\n        if self.settings.value(\"maximized\", \"false\") == \"true\":\n            self.showMaximized()\n\n        # Create main widget\n        main_widget = QWidget()\n        self.setCentralWidget(main_widget)\n        layout = QVBoxLayout(main_widget)\n\n        # URL input\n        url_hbox = QHBoxLayout()\n        url_label = QLabel(\"URL:\")\n        url_hbox.addWidget(url_label)\n        self.input_box = QLineEdit()\n        self.input_box.textChanged.connect(self._handle_text_changed)\n        url_hbox.addWidget(self.input_box)\n        layout.addLayout(url_hbox)\n\n        # Filename input\n        filename_hbox = QHBoxLayout()\n        filename_label = QLabel(\"Custom file name:\")\n        filename_hbox.addWidget(filename_label)\n        self.filename_input_box = QLineEdit()\n        self.filename_input_box.setPlaceholderText(\"Leave blank for default filename\")\n        filename_hbox.addWidget(self.filename_input_box)\n        layout.addLayout(filename_hbox)\n\n        # Checkboxes\n        self.import_aspect_upgrades_checkbox = self._generate_checkbox(\n            \"Import Aspect Upgrades\",\n            \"import_aspect_upgrades\",\n            \"If legendary aspects are in the build, do you want an aspect upgrades section generated for them?\",\n        )\n        self.add_to_profiles_checkbox = self._generate_checkbox(\n            \"Auto-add To Profiles\",\n            \"import_add_to_profiles\",\n            \"After import, should the imported file be automatically added to your active profiles?\",\n        )\n        self.import_gas_checkbox = self._generate_checkbox(\n            \"Import GAs\",\n            \"import_gas\",\n            \"If a build has greater affixes, should they be included in the imported profile?\",\n        )\n        self.require_all_gas_checkbox = self._generate_checkbox(\n            \"Require all GAs\",\n            \"require_all_gas\",\n            \"If a build has greater affixes, should an item have all of them to be kept?\",\n            \"false\",\n        )\n\n        self.export_paragon_checkbox = self._generate_checkbox(\n            \"Import Paragon\",\n            \"export_paragon\",\n            \"Import Paragon boards into your profile for the integrated Paragon overlay.\",\n            \"false\",\n        )\n\n        # GA dependency logic\n        def disable_require_if_import_disabled():\n            if not self.import_gas_checkbox.isChecked():\n                self.require_all_gas_checkbox.setChecked(False)\n                self.require_all_gas_checkbox.setEnabled(False)\n            else:\n                self.require_all_gas_checkbox.setEnabled(True)\n\n        # Apply initial enabled/disabled state\n        self.require_all_gas_checkbox.setEnabled(self.import_gas_checkbox.isChecked())\n\n        # Apply initial gray-out if Import GAs starts off\n        if not self.import_gas_checkbox.isChecked():\n            self.require_all_gas_checkbox.setChecked(False)\n            self.require_all_gas_checkbox.setEnabled(False)\n\n        # Connect toggle logic\n        self.import_gas_checkbox.stateChanged.connect(lambda: disable_require_if_import_disabled())\n\n        checkbox_hbox = QHBoxLayout()\n        checkbox_hbox.addWidget(self.import_aspect_upgrades_checkbox)\n        checkbox_hbox.addWidget(self.import_gas_checkbox)\n        checkbox_hbox.addWidget(self.require_all_gas_checkbox)\n        layout.addLayout(checkbox_hbox)\n        # Second row of checkboxes, probably need a better solution for this one day\n        checkbox_hbox = QHBoxLayout()\n        checkbox_hbox.addWidget(self.export_paragon_checkbox)\n        checkbox_hbox.addWidget(self.add_to_profiles_checkbox)\n        layout.addLayout(checkbox_hbox)\n\n        # Generate button\n        button_hbox = QHBoxLayout()\n        self.generate_button = QPushButton(\"Generate\")\n        self.generate_button.setEnabled(False)\n        self.generate_button.clicked.connect(self._generate_button_click)\n        button_hbox.addWidget(self.generate_button)\n\n        profiles_button = OpenUserConfigButton()\n        button_hbox.addWidget(profiles_button)\n        layout.addLayout(button_hbox)\n\n        # Log output\n        log_label = QLabel(\"Log:\")\n        layout.addWidget(log_label)\n\n        self.log_output = QTextEdit()\n        self.log_output.setReadOnly(True)\n        layout.addWidget(self.log_output)\n\n        # Setup logging\n        self.log_handler = _GuiLogHandler(self.log_output)\n\n        # Attach directly to each importer logger AND gui_common.py\n        for name in (\n            \"src.gui.importer.mobalytics\",\n            \"src.gui.importer.maxroll\",\n            \"src.gui.importer.d4builds\",\n            \"src.gui.importer.gui_common\",\n        ):\n            logger = logging.getLogger(name)\n            logger.setLevel(logging.DEBUG)\n            logger.addHandler(self.log_handler)\n\n        # Instructions\n        instructions_label = QLabel(\"Instructions:\")\n        layout.addWidget(instructions_label)\n\n        instructions_text = QTextEdit()\n        instructions_text.setText(\n            \"You can link either the build guide or a direct link to the specific planner.\\n\\n\"\n            \"https://maxroll.gg/d4/build-guides/tornado-druid-guide\\n\"\n            \"or\\n\"\n            \"https://maxroll.gg/d4/planner/cm6pf0xa#5\\n\"\n            \"or\\n\"\n            \"https://d4builds.gg/builds/ef414fbd-81cd-49d1-9c8d-4938b278e2ee\\n\"\n            \"or\\n\"\n            \"https://mobalytics.gg/diablo-4/builds/barbarian/bash\\n\\n\"\n            f\"It will create a file based on the label of the build in the planner in: {IniConfigLoader().user_dir / 'profiles'}\\n\\n\"\n            \"For d4builds you need to specify your browser in the Settings window\"\n        )\n        instructions_text.setReadOnly(True)\n        font_metrics = instructions_text.fontMetrics()\n        text_height = font_metrics.height() * (instructions_text.document().lineCount() + 2)\n        instructions_text.setFixedHeight(text_height)\n        layout.addWidget(instructions_text)\n\n    def _generate_checkbox(self, name, settings_value, desc, default_value=\"true\") -> QCheckBox:\n        def save_setting_change(settings_value, value):\n            self.settings.setValue(settings_value, value)\n\n        checkbox = QCheckBox(name)\n        checkbox.setChecked(self.settings.value(settings_value, default_value) == \"true\")\n        checkbox.setToolTip(desc)\n        checkbox.stateChanged.connect(lambda: save_setting_change(settings_value, checkbox.isChecked()))\n        return checkbox\n\n    def _handle_text_changed(self, text):\n        \"\"\"Enable/disable generate button based on input.\"\"\"\n        self.generate_button.setEnabled(bool(text.strip()))\n\n    def _generate_button_click(self):\n        self.log_output.clear()\n        \"\"\"Handle generate button click\"\"\"\n        url = self.input_box.text().strip()\n        custom_filename = self.filename_input_box.text()\n        if custom_filename:\n            custom_filename = custom_filename.split(\".\")[0]\n            custom_filename = custom_filename.strip()\n\n        importer_config = ImportConfig(\n            url,\n            self.import_aspect_upgrades_checkbox.isChecked(),\n            self.add_to_profiles_checkbox.isChecked(),\n            self.import_gas_checkbox.isChecked(),\n            self.require_all_gas_checkbox.isChecked(),\n            self.export_paragon_checkbox.isChecked(),\n            custom_filename,\n        )\n\n        if \"maxroll\" in url:\n            worker = _Worker(name=\"maxroll\", fn=import_maxroll, config=importer_config)\n        elif \"d4builds\" in url:\n            worker = _Worker(name=\"d4builds\", fn=import_d4builds, config=importer_config)\n        else:\n            worker = _Worker(name=\"mobalytics\", fn=import_mobalytics, config=importer_config)\n\n        worker.signals.finished.connect(self._on_worker_finished)\n        self.generate_button.setEnabled(False)\n        self.generate_button.setText(\"Generating...\")\n        THREADPOOL.start(worker)\n\n    def _on_worker_finished(self):\n        \"\"\"Handle worker completion.\"\"\"\n        self.generate_button.setEnabled(True)\n        self.generate_button.setText(\"Generate\")\n        self.filename_input_box.clear()\n\n    def closeEvent(self, event):\n        \"\"\"Cleanup when window closes and save geometry.\"\"\"\n        # Save window geometry\n        if not self.isMaximized():\n            self.settings.setValue(\"size\", self.size())\n            self.settings.setValue(\"pos\", self.pos())\n        self.settings.setValue(\"maximized\", \"true\" if self.isMaximized() else \"false\")\n\n        # Cleanup log handler\n        logging.getLogger(\"src.gui.importer.mobalytics\").removeHandler(self.log_handler)\n        logging.getLogger(\"src.gui.importer.maxroll\").removeHandler(self.log_handler)\n        logging.getLogger(\"src.gui.importer.d4builds\").removeHandler(self.log_handler)\n        logging.getLogger(\"src.gui.importer.common\").removeHandler(self.log_handler)\n        event.accept()\n\n\nclass _GuiLogHandler(logging.Handler):\n    \"\"\"Thread-safe log handler that emits signals for GUI updates.\"\"\"\n\n    def __init__(self, text_widget: QTextEdit):\n        super().__init__()\n        self.text_widget = text_widget\n        self.signals = _LogSignals()\n        # Connect signal to slot in main thread\n        self.signals.log_message.connect(self._append_log)\n        # Set log level to DEBUG to capture everything\n        self.setLevel(logging.DEBUG)\n\n    def emit(self, record):\n        \"\"\"Called from any thread - emit signal instead of direct GUI update.\"\"\"\n        try:\n            log_entry = self.format(record)\n            self.signals.log_message.emit(log_entry)\n        except Exception:\n            self.handleError(record)\n\n    def _append_log(self, message):\n        \"\"\"Slot that runs in main thread - safe to update GUI.\"\"\"\n        self.text_widget.append(message)\n        self.text_widget.ensureCursorVisible()\n\n\nclass _LogSignals(QObject):\n    \"\"\"Signals for thread-safe logging.\"\"\"\n\n    log_message = pyqtSignal(str)\n\n\nclass _Worker(QRunnable):\n    def __init__(self, name, fn, *args, **kwargs):\n        super().__init__()\n        self.name = name\n        self.fn = fn\n        self.args = args\n        self.kwargs = kwargs\n        self.signals = _WorkerSignals()\n\n    @pyqtSlot()\n    def run(self):\n        threading.current_thread().name = self.name\n        self.fn(*self.args, **self.kwargs)\n        self.signals.finished.emit()\n\n\nclass _WorkerSignals(QObject):\n    finished = pyqtSignal()\n"
  },
  {
    "path": "src/gui/open_user_config_button.py",
    "content": "import os\n\nfrom PyQt6.QtWidgets import QPushButton\n\nfrom src.config.loader import IniConfigLoader\n\n\nclass OpenUserConfigButton(QPushButton):\n    def __init__(self):\n        super().__init__(\"Open Userconfig Directory\")\n        self.clicked.connect(self._open_userconfig_directory)\n\n    @staticmethod\n    def _open_userconfig_directory():\n        os.startfile(IniConfigLoader().user_dir)\n"
  },
  {
    "path": "src/gui/profile_editor/__init__.py",
    "content": ""
  },
  {
    "path": "src/gui/profile_editor/affixes_tab.py",
    "content": "import logging\n\nfrom PyQt6.QtCore import QSettings, Qt, QTimer\nfrom PyQt6.QtGui import QDoubleValidator, QIntValidator\nfrom PyQt6.QtWidgets import (\n    QCheckBox,\n    QComboBox,\n    QCompleter,\n    QDialog,\n    QDialogButtonBox,\n    QFormLayout,\n    QFrame,\n    QGroupBox,\n    QHBoxLayout,\n    QLabel,\n    QLineEdit,\n    QListWidget,\n    QListWidgetItem,\n    QMessageBox,\n    QPushButton,\n    QScrollArea,\n    QSizePolicy,\n    QSpinBox,\n    QTabWidget,\n    QToolBar,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.profile_models import (\n    AffixFilterCountModel,\n    AffixFilterModel,\n    AspectUniqueFilterModel,\n    DynamicItemFilterModel,\n)\nfrom src.dataloader import Dataloader\nfrom src.gui.collapsible_widget import Container\nfrom src.gui.dialog import (\n    CreateItem,\n    DeleteAffixPool,\n    DeleteItem,\n    IgnoreScrollWheelComboBox,\n    IgnoreScrollWheelSpinBox,\n    MinGreaterDialog,\n    MinPercentDialog,\n    MinPowerDialog,\n)\nfrom src.gui.importer.gui_common import MAX_POWER\nfrom src.item.data.item_type import ItemType, is_armor, is_jewelry, is_weapon\n\nLOGGER = logging.getLogger(__name__)\n\nAFFIXES_TABNAME = \"Affixes\"\nAFFIX_VALUE_MODE = \"Value\"\nAFFIX_PERCENT_MODE = \"Min %\"\n\n\ndef _item_type_summary(item_types: list[ItemType]) -> str:\n    if not item_types:\n        return \"All item types\"\n    return \", \".join(item_type.value for item_type in item_types)\n\n\nclass ItemTypePicker(QDialog):\n    def __init__(self, parent: QWidget, item_types: list[ItemType], selected_item_types: list[ItemType]):\n        super().__init__(parent)\n        self.setWindowTitle(\"Select Item Types\")\n        self.resize(650, 500)\n        self.checkboxes: dict[ItemType, QCheckBox] = {}\n\n        selected_item_type_set = set(selected_item_types)\n        weapon_item_types = [\n            item_type for item_type in item_types if is_weapon(item_type) or item_type == ItemType.Shield\n        ]\n        weapon_item_type_set = set(weapon_item_types)\n        non_weapon_item_types = [item_type for item_type in item_types if item_type not in weapon_item_type_set]\n\n        layout = QVBoxLayout(self)\n        picker_layout = QHBoxLayout()\n        picker_layout.addWidget(self._create_item_type_group(\"Weapons\", weapon_item_types, selected_item_type_set))\n        picker_layout.addWidget(\n            self._create_item_type_group(\"Non-weapons\", non_weapon_item_types, selected_item_type_set)\n        )\n        layout.addLayout(picker_layout)\n\n        note_label = QLabel(\"If no item types are selected, all item types will be evaluated for this filter.\")\n        note_label.setWordWrap(True)\n        layout.addWidget(note_label)\n\n        button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)\n        clear_button = button_box.addButton(\"Clear\", QDialogButtonBox.ButtonRole.ResetRole)\n        clear_button.clicked.connect(self.clear_selection)\n        button_box.accepted.connect(self.accept)\n        button_box.rejected.connect(self.reject)\n        layout.addWidget(button_box)\n\n    def _create_item_type_group(\n        self, title: str, item_types: list[ItemType], selected_item_types: set[ItemType]\n    ) -> QGroupBox:\n        group_box = QGroupBox(title)\n        group_layout = QVBoxLayout(group_box)\n\n        scroll_area = QScrollArea()\n        scroll_area.setWidgetResizable(True)\n        content_widget = QWidget()\n        content_layout = QVBoxLayout(content_widget)\n        content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        for item_type in item_types:\n            checkbox = QCheckBox(item_type.value)\n            checkbox.setChecked(item_type in selected_item_types)\n            self.checkboxes[item_type] = checkbox\n            content_layout.addWidget(checkbox)\n\n        scroll_area.setWidget(content_widget)\n        group_layout.addWidget(scroll_area)\n        return group_box\n\n    def clear_selection(self):\n        for checkbox in self.checkboxes.values():\n            checkbox.setChecked(False)\n\n    def get_selected_item_types(self) -> list[ItemType]:\n        return [item_type for item_type, checkbox in self.checkboxes.items() if checkbox.isChecked()]\n\n\nclass AffixGroupEditor(QWidget):\n    def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None):\n        super().__init__(parent)\n        self.settings = QSettings(\"d4lf\", \"profile_editor\")\n        for item_name, config in dynamic_filter.root.items():\n            self.item_name = item_name\n            self.config = config\n\n        self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)\n        self.setup_ui()\n\n    def setup_ui(self):\n        scroll_area = QScrollArea()\n        scroll_area.setWidgetResizable(True)\n        scroll_area.setFrameShape(QFrame.Shape.NoFrame)\n\n        content_widget = QWidget()\n        self.content_layout = QVBoxLayout(content_widget)\n        self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        general_form = QFormLayout()\n\n        self.item_types = [\n            item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item)\n        ]\n        self.item_type_line_edit = QLineEdit()\n        self.item_type_line_edit.setReadOnly(True)\n        self.item_type_line_edit.setMinimumWidth(360)\n        self.item_type_line_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)\n        self.refresh_item_type_summary()\n\n        item_type_layout = QHBoxLayout()\n        item_type_layout.addWidget(self.item_type_line_edit)\n        edit_item_types_btn = QPushButton(\"...\")\n        edit_item_types_btn.setMaximumWidth(40)\n        edit_item_types_btn.clicked.connect(self.edit_item_types)\n        item_type_layout.addWidget(edit_item_types_btn)\n        item_type_layout.addStretch()\n        general_form.addRow(\"Item Types:\", item_type_layout)\n\n        self.min_power = IgnoreScrollWheelSpinBox()\n        self.min_power.setMaximum(MAX_POWER)\n        self.min_power.setValue(self.config.minPower)\n        self.min_power.setMaximumWidth(150)\n        self.min_power.valueChanged.connect(self.update_min_power)\n        general_form.addRow(\"Minimum Power:\", self.min_power)\n\n        min_greater_layout = QHBoxLayout()\n\n        self.min_greater = QSpinBox()\n        self.min_greater.setValue(self.config.minGreaterAffixCount)\n        self.min_greater.setMaximum(4)\n        self.min_greater.setMinimum(0)\n        self.min_greater.setMaximumWidth(80)\n        self.min_greater.setToolTip(\n            \"Minimum number of checked affixes that must be Greater Affixes.\\n\"\n            \"0 = Accept items even without GAs (for leveling)\\n\"\n            \"1-4 = At least this many checked affixes must be GA\"\n        )\n        self.min_greater.valueChanged.connect(self.update_min_greater_affix)\n\n        self.auto_sync_checkbox = QCheckBox(\"Auto Sync\")\n        self.auto_sync_checkbox.setToolTip(\n            \"When checked: Min Greater Affixes automatically matches the number of affixes marked as 'want greater'\\n\"\n            \"When unchecked: You can manually set Min Greater Affixes to any value\"\n        )\n        self.auto_sync_checkbox.setChecked(self.settings.value(f\"auto_sync_ga_{self.item_name}\", False, type=bool))\n        self.auto_sync_checkbox.stateChanged.connect(self.toggle_auto_sync)\n\n        self.greater_count_label = QLabel()\n        self.greater_count_label.setProperty(\"greaterCountLabel\", True)\n        self._refresh_widget_style(self.greater_count_label)\n        self.update_greater_count_label()\n\n        min_greater_layout.addWidget(self.min_greater)\n        min_greater_layout.addWidget(self.auto_sync_checkbox)\n        min_greater_layout.addWidget(self.greater_count_label)\n        min_greater_layout.addStretch()\n\n        self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked())\n\n        if self.auto_sync_checkbox.isChecked():\n            self.min_greater.setProperty(\"autoSyncSpin\", True)\n            self._refresh_widget_style(self.min_greater)\n\n        general_form.addRow(\"Min Greater Affixes:\", min_greater_layout)\n\n        self.content_layout.addLayout(general_form)\n        self.create_unique_aspect_groupbox()\n\n        pool_btn_layout = QHBoxLayout()\n        add_affix_pool_btn = QPushButton(\"Add Affix Pool\")\n        add_affix_pool_btn.clicked.connect(self.add_affix_pool)\n        add_inherent_pool_btn = QPushButton(\"Add Inherent Pool\")\n        add_inherent_pool_btn.clicked.connect(self.add_inherent_pool)\n        remove_affix_pool_btn = QPushButton(\"Remove Affix Pool\")\n        remove_affix_pool_btn.clicked.connect(lambda: self.remove_selected(self.affix_pool_layout))\n        remove_inherent_pool_btn = QPushButton(\"Remove Inherent Pool\")\n        remove_inherent_pool_btn.clicked.connect(lambda: self.remove_selected(self.inherent_pool_layout, inherent=True))\n\n        pool_btn_layout.addWidget(add_affix_pool_btn)\n        pool_btn_layout.addWidget(add_inherent_pool_btn)\n        pool_btn_layout.addWidget(remove_affix_pool_btn)\n        pool_btn_layout.addWidget(remove_inherent_pool_btn)\n\n        self.affix_pool_container = Container(\"Affix Pool\")\n        self.affix_pool_layout = QVBoxLayout(self.affix_pool_container.contentWidget)\n        self.affix_pool_container.firstExpansion.connect(self.init_affix_pool)\n\n        self.inherent_pool_container = Container(\"Inherent Pool\")\n        self.inherent_pool_layout = QVBoxLayout(self.inherent_pool_container.contentWidget)\n        self.inherent_pool_container.firstExpansion.connect(self.init_inherent_pool)\n\n        self.content_layout.addWidget(self.affix_pool_container)\n        self.content_layout.addWidget(self.inherent_pool_container)\n        self.content_layout.addLayout(pool_btn_layout)\n\n        scroll_area.setWidget(content_widget)\n\n        main_layout = QVBoxLayout(self)\n        main_layout.addWidget(scroll_area)\n        self.setLayout(main_layout)\n\n        QTimer.singleShot(100, self.affix_pool_container.expand)\n        QTimer.singleShot(100, self.inherent_pool_container.expand)\n\n    def create_unique_aspect_groupbox(self):\n        self.unique_aspect_groupbox = QGroupBox(\"Unique Aspect\")\n        self.unique_aspect_form = QFormLayout()\n\n        self.unique_aspect_name_combo = IgnoreScrollWheelComboBox()\n        self.unique_aspect_name_combo.setEditable(True)\n        self.unique_aspect_name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.unique_aspect_name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.unique_aspect_name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)\n        self.unique_aspect_name_combo.addItems(sorted(Dataloader().aspect_unique_dict.keys()))\n        if self.config.uniqueAspect is not None:\n            self.unique_aspect_name_combo.setCurrentText(self.config.uniqueAspect.name)\n        else:\n            self.unique_aspect_name_combo.setCurrentText(\"\")\n        self.unique_aspect_name_combo.setSizeAdjustPolicy(\n            QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon\n        )\n        self.unique_aspect_name_combo.setMinimumContentsLength(24)\n        self.unique_aspect_name_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)\n        self.unique_aspect_name_combo.setMaximumWidth(600)\n        self.unique_aspect_name_combo.currentTextChanged.connect(self.update_unique_aspect_name)\n        self.unique_aspect_form.addRow(\"Name:\", self.unique_aspect_name_combo)\n\n        self.unique_aspect_mode_combo = IgnoreScrollWheelComboBox()\n        self.unique_aspect_mode_combo.setFixedSize(100, self.unique_aspect_mode_combo.sizeHint().height())\n        self.unique_aspect_mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE])\n        if self.config.uniqueAspect is not None and self.config.uniqueAspect.minPercentOfAspect:\n            self.unique_aspect_mode_combo.setCurrentText(AFFIX_PERCENT_MODE)\n        else:\n            self.unique_aspect_mode_combo.setCurrentText(AFFIX_VALUE_MODE)\n        self.unique_aspect_mode_combo.currentTextChanged.connect(self.update_unique_aspect_mode)\n        self.unique_aspect_form.addRow(\"Mode:\", self.unique_aspect_mode_combo)\n\n        self.unique_aspect_value_edit = QLineEdit()\n        self.unique_aspect_value_edit.setFixedSize(100, self.unique_aspect_value_edit.sizeHint().height())\n        self.unique_aspect_value_edit.textChanged.connect(self.update_unique_aspect_value)\n        self.unique_aspect_form.addRow(\"Threshold:\", self.unique_aspect_value_edit)\n\n        self.unique_aspect_groupbox.setLayout(self.unique_aspect_form)\n        self.content_layout.addWidget(self.unique_aspect_groupbox)\n        self.refresh_unique_aspect_value_input()\n        self.set_unique_aspect_controls_enabled()\n\n    def _refresh_widget_style(self, widget):\n        widget.style().unpolish(widget)\n        widget.style().polish(widget)\n\n    def set_unique_aspect_controls_enabled(self):\n        enabled = self.config.uniqueAspect is not None\n        self.unique_aspect_name_combo.setEnabled(True)\n        self.unique_aspect_mode_combo.setEnabled(enabled)\n        self.unique_aspect_value_edit.setEnabled(enabled)\n\n    def update_unique_aspect_name(self, current_text=None):\n        aspect_name = self.unique_aspect_name_combo.currentText() if current_text is None else current_text\n        aspect_name = aspect_name.strip()\n        if not aspect_name:\n            self.config.uniqueAspect = None\n            self.refresh_unique_aspect_value_input()\n            self.set_unique_aspect_controls_enabled()\n            return\n        if aspect_name not in Dataloader().aspect_unique_dict:\n            return\n        if self.config.uniqueAspect is None:\n            self.config.uniqueAspect = AspectUniqueFilterModel(name=aspect_name)\n        else:\n            self.config.uniqueAspect.name = aspect_name\n        self.refresh_unique_aspect_value_input()\n        self.set_unique_aspect_controls_enabled()\n\n    def refresh_unique_aspect_value_input(self):\n        self.unique_aspect_value_edit.blockSignals(True)\n        if self.config.uniqueAspect is None:\n            self.unique_aspect_value_edit.setText(\"\")\n            self.unique_aspect_value_edit.setPlaceholderText(\"Value (optional)\")\n            self.unique_aspect_value_edit.setValidator(QDoubleValidator(self.unique_aspect_value_edit))\n        elif self.unique_aspect_mode_combo.currentText() == AFFIX_PERCENT_MODE:\n            self.unique_aspect_value_edit.setPlaceholderText(\"Percent (0-100)\")\n            self.unique_aspect_value_edit.setValidator(QIntValidator(0, 100, self.unique_aspect_value_edit))\n            display_value = (\n                \"\"\n                if self.config.uniqueAspect.minPercentOfAspect == 0\n                else str(self.config.uniqueAspect.minPercentOfAspect)\n            )\n            self.unique_aspect_value_edit.setText(display_value)\n        else:\n            self.unique_aspect_value_edit.setPlaceholderText(\"Value (optional)\")\n            self.unique_aspect_value_edit.setValidator(QDoubleValidator(self.unique_aspect_value_edit))\n            display_value = \"\" if self.config.uniqueAspect.value is None else str(self.config.uniqueAspect.value)\n            self.unique_aspect_value_edit.setText(display_value)\n        self.unique_aspect_value_edit.blockSignals(False)\n\n    def update_unique_aspect_mode(self, current_text=None):\n        if self.config.uniqueAspect is None:\n            self.set_unique_aspect_controls_enabled()\n            return\n        mode = current_text or self.unique_aspect_mode_combo.currentText()\n        if mode == AFFIX_PERCENT_MODE:\n            self.config.uniqueAspect.value = None\n        else:\n            self.config.uniqueAspect.minPercentOfAspect = 0\n        self.refresh_unique_aspect_value_input()\n        self.set_unique_aspect_controls_enabled()\n\n    def update_unique_aspect_value(self, value):\n        if self.config.uniqueAspect is None:\n            return\n        if self.unique_aspect_mode_combo.currentText() == AFFIX_PERCENT_MODE:\n            try:\n                percent = int(value) if value else 0\n            except ValueError:\n                return\n            if not 0 <= percent <= 100:\n                QMessageBox.warning(self, \"Warning\", \"Min % must be between 0 and 100.\")\n                self.refresh_unique_aspect_value_input()\n                return\n            self.config.uniqueAspect.minPercentOfAspect = percent\n            self.config.uniqueAspect.value = None\n            return\n\n        try:\n            self.config.uniqueAspect.value = float(value) if value else None\n        except ValueError:\n            return\n        self.config.uniqueAspect.minPercentOfAspect = 0\n\n    def init_affix_pool(self):\n        \"\"\"Initialize affix pool content on first expansion.\"\"\"\n        for pool in self.config.affixPool:\n            self.add_affix_pool_item(pool)\n        QTimer.singleShot(50, self.update_greater_count_label)\n\n    def init_inherent_pool(self):\n        \"\"\"Initialize inherent pool content on first expansion.\"\"\"\n        for pool in self.config.inherentPool:\n            self.add_affix_pool_item(pool, True)\n        QTimer.singleShot(50, self.update_greater_count_label)\n\n    def add_affix_pool_item(self, pool: AffixFilterCountModel, inherent: bool = False):\n        if inherent:\n            nb_count = self.inherent_pool_layout.count()\n            container = Container(f\"Count {nb_count}\", True)\n            container_layout = QVBoxLayout(container.contentWidget)\n            widget = AffixPoolWidget(pool)\n            container_layout.addWidget(widget)\n            self.inherent_pool_layout.addWidget(container)\n            QTimer.singleShot(50, container.expand)\n        else:\n            nb_count = self.affix_pool_layout.count()\n            container = Container(f\"Count {nb_count}\", True)\n            container_layout = QVBoxLayout(container.contentWidget)\n            widget = AffixPoolWidget(pool)\n            container_layout.addWidget(widget)\n            self.affix_pool_layout.addWidget(container)\n            QTimer.singleShot(50, container.expand)\n\n    def add_affix_pool(self):\n        default_affix = AffixFilterModel(\n            name=next(iter(Dataloader().affix_dict.keys())),  # First valid affix name\n            value=None,\n        )\n\n        new_pool = AffixFilterCountModel(count=[default_affix], minCount=1, maxCount=3)\n        self.config.affixPool.append(new_pool)\n        self.add_affix_pool_item(new_pool)\n\n    def add_inherent_pool(self):\n        default_affix = AffixFilterModel(\n            name=next(iter(Dataloader().affix_dict.keys())),  # First valid affix name\n            value=None,\n        )\n\n        new_pool = AffixFilterCountModel(count=[default_affix], minCount=1, maxCount=3)\n        self.config.inherentPool.append(new_pool)\n        self.add_affix_pool_item(new_pool, True)\n\n    def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False):\n        nb_pool = layout_widget.count()\n        dialog = DeleteAffixPool(nb_pool, inherent)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            to_delete = dialog.get_value()\n            to_delete_list = []\n            for i in range(layout_widget.count()):\n                item = layout_widget.itemAt(i)\n                if item and item.widget() is not None and item.widget().header.name in to_delete:\n                    to_delete_list.append((item.widget(), i))\n            to_delete_list.reverse()\n            for widget, index in to_delete_list:\n                widget.setParent(None)\n                if inherent:\n                    self.config.inherentPool.pop(index)\n                else:\n                    self.config.affixPool.pop(index)\n            self.reorganize_pool(layout_widget)\n\n    def reorganize_pool(self, layout_widget: QVBoxLayout):\n        for i in range(layout_widget.count()):\n            item = layout_widget.itemAt(i)\n            if item and item.widget() is not None:\n                item.widget().header.set_name(f\"Count {i}\")\n\n    def refresh_item_type_summary(self):\n        self.item_type_line_edit.setText(_item_type_summary(self.config.itemType))\n\n    def edit_item_types(self):\n        item_type_picker = ItemTypePicker(self, self.item_types, self.config.itemType)\n        if item_type_picker.exec() == QDialog.DialogCode.Accepted:\n            self.config.itemType = item_type_picker.get_selected_item_types()\n            self.refresh_item_type_summary()\n\n    def update_min_power(self):\n        self.config.minPower = self.min_power.value()\n\n    def update_min_greater_affix(self):\n        self.config.minGreaterAffixCount = self.min_greater.value()\n\n    def toggle_auto_sync(self):\n        is_auto_sync = self.auto_sync_checkbox.isChecked()\n\n        # Save UI-only state (replaces writing to config)\n        self.settings.setValue(f\"auto_sync_ga_{self.item_name}\", is_auto_sync)\n\n        # Keep your existing behavior\n        self.min_greater.setEnabled(not is_auto_sync)\n\n        if is_auto_sync:\n            self.min_greater.setProperty(\"autoSyncSpin\", True)\n            self._refresh_widget_style(self.min_greater)\n\n            self.affix_pool_container.expand()\n            self.inherent_pool_container.expand()\n\n            count = self.count_want_greater_affixes()\n            self.min_greater.setValue(count)\n            self.update_greater_count_label()\n        else:\n            self.min_greater.setProperty(\"autoSyncSpin\", False)\n            self._refresh_widget_style(self.min_greater)\n\n    def _update_auto_sync_count(self):\n        count = self.count_want_greater_affixes()\n        self.min_greater.setValue(count)\n        self.update_greater_count_label()\n\n    def sync_min_greater_from_checkboxes(self):\n        if self.auto_sync_checkbox.isChecked():\n            count = self.count_want_greater_affixes()\n            self.min_greater.setValue(count)\n\n    def _ensure_pool_widgets_initialized(self):\n        for container in (self.affix_pool_container, self.inherent_pool_container):\n            was_visible = container.contentWidget.isVisible()\n            if container.header.first_expansion:\n                container.expand()\n                if not was_visible:\n                    container.collapse()\n\n    def iter_affix_widgets(self):\n        self._ensure_pool_widgets_initialized()\n\n        # Inherents do not participate in Greater Affix auto-sync or bulk Min % updates.\n        for i in range(self.affix_pool_layout.count()):\n            container = self.affix_pool_layout.itemAt(i).widget()\n            if container is None or not hasattr(container, \"contentWidget\"):\n                continue\n            pool_item = container.contentWidget.layout().itemAt(0)\n            if pool_item is None:\n                continue\n            pool_widget = pool_item.widget()\n            if not isinstance(pool_widget, AffixPoolWidget):\n                continue\n            for j in range(pool_widget.affix_list.count()):\n                list_item = pool_widget.affix_list.item(j)\n                affix_widget = pool_widget.affix_list.itemWidget(list_item)\n                if isinstance(affix_widget, AffixWidget):\n                    yield affix_widget\n\n    def count_want_greater_affixes(self):\n        want_greater_count = 0\n\n        if not hasattr(self, \"affix_pool_layout\") or not hasattr(self, \"inherent_pool_layout\"):\n            return 0\n\n        for affix_widget in self.iter_affix_widgets():\n            if affix_widget.greater_checkbox.isChecked():\n                want_greater_count += 1\n\n        return want_greater_count\n\n    def update_greater_count_label(self):\n        count = self.count_want_greater_affixes()\n        if count == 0:\n            self.greater_count_label.setText(\"(no greater affixes marked)\")\n        elif count == 1:\n            self.greater_count_label.setText(\"(1 greater affix marked)\")\n        else:\n            self.greater_count_label.setText(f\"({count} greater affixes marked)\")\n\n    def convert_all_to_min_percent_of_affix(self, percent: int):\n        for affix_widget in self.iter_affix_widgets():\n            affix_widget.set_min_percent(percent, convert_mode=True)\n\n\nclass AffixPoolWidget(QWidget):\n    def __init__(self, pool: AffixFilterCountModel, parent=None):\n        super().__init__(parent)\n        self.pool = pool\n        self.setup_ui()\n\n    def setup_ui(self):\n        layout = QVBoxLayout()\n        layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        config_layout = QHBoxLayout()\n        config_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)\n\n        min_count_label = QLabel(\"Min Count:\")\n        min_count_label.setMaximumWidth(100)\n        min_count_label.setProperty(\"affixHeaderLabel\", True)\n        self._refresh_widget_style(min_count_label)\n        config_layout.addWidget(min_count_label)\n\n        self.min_count = IgnoreScrollWheelSpinBox()\n        self.min_count.setValue(self.pool.minCount)\n        self.min_count.setMaximumWidth(100)\n        self.min_count.valueChanged.connect(self.update_min_count)\n        config_layout.addWidget(self.min_count)\n        config_layout.addSpacing(150)\n\n        max_count_label = QLabel(\"Max Count:\")\n        max_count_label.setMaximumWidth(100)\n        max_count_label.setProperty(\"affixHeaderLabel\", True)\n        self._refresh_widget_style(max_count_label)\n        config_layout.addWidget(max_count_label)\n\n        self.max_count = IgnoreScrollWheelSpinBox()\n        self.max_count.setValue(min(self.pool.maxCount, 2147483647))\n        self.max_count.setMaximumWidth(100)\n        self.max_count.valueChanged.connect(self.update_max_count)\n        config_layout.addWidget(self.max_count)\n\n        layout.addLayout(config_layout)\n\n        title_layout = QHBoxLayout()\n        title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)\n\n        affix_label = QLabel(\"Affixes\")\n        affix_label.setProperty(\"affixHeaderLabel\", True)\n        self._refresh_widget_style(affix_label)\n\n        greater_label = QLabel(\"Greater\")\n        greater_label.setProperty(\"affixHeaderLabel\", True)\n        self._refresh_widget_style(greater_label)\n\n        mode_label = QLabel(\"Mode\")\n        mode_label.setProperty(\"affixHeaderLabel\", True)\n        self._refresh_widget_style(mode_label)\n\n        value_label = QLabel(\"Threshold\")\n        value_label.setProperty(\"affixHeaderLabel\", True)\n        self._refresh_widget_style(value_label)\n\n        title_layout.addSpacing(250)\n        title_layout.addWidget(affix_label)\n        title_layout.addSpacing(400)\n        title_layout.addWidget(greater_label)\n        title_layout.addSpacing(70)\n        title_layout.addWidget(mode_label)\n        title_layout.addSpacing(85)\n        title_layout.addWidget(value_label)\n\n        self.affix_list = QListWidget()\n        self.affix_list.setMinimumHeight(200)\n        self.affix_list.setAlternatingRowColors(True)\n        for affix in self.pool.count:\n            self.add_affix_item(affix)\n\n        affix_btn_layout = QHBoxLayout()\n        add_affix_btn = QPushButton(\"Add Affix\")\n        add_affix_btn.clicked.connect(self.add_affix)\n        affix_btn_layout.addWidget(add_affix_btn)\n\n        remove_affix_btn = QPushButton(\"Remove Affix\")\n        remove_affix_btn.clicked.connect(lambda: self.remove_selected(self.affix_list))\n        affix_btn_layout.addWidget(remove_affix_btn)\n\n        layout.addLayout(affix_btn_layout)\n        layout.addLayout(title_layout)\n        layout.addWidget(self.affix_list)\n\n        self.setLayout(layout)\n\n    def _refresh_widget_style(self, widget):\n        widget.style().unpolish(widget)\n        widget.style().polish(widget)\n\n    def add_affix_item(self, affix: AffixFilterModel):\n        item = QListWidgetItem()\n        widget = AffixWidget(affix)\n        item.setSizeHint(widget.sizeHint())\n        self.affix_list.addItem(item)\n        self.affix_list.setItemWidget(item, widget)\n\n    def add_affix(self):\n        new_affix = AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())), value=None)\n        self.pool.count.append(new_affix)\n        self.add_affix_item(new_affix)\n\n    def remove_selected(self, list_widget: QListWidget):\n        for item in list_widget.selectedItems():\n            row = list_widget.row(item)\n            list_widget.takeItem(row)\n            del self.pool.count[row]\n\n    def update_min_count(self):\n        self.pool.minCount = self.min_count.value()\n\n    def update_max_count(self):\n        self.pool.maxCount = self.max_count.value()\n\n\nclass AffixWidget(QWidget):\n    def __init__(self, affix: AffixFilterModel, parent=None):\n        super().__init__(parent)\n        self.affix = affix\n        self.setup_ui()\n\n    def setup_ui(self):\n        layout = QHBoxLayout()\n        layout.setAlignment(Qt.AlignmentFlag.AlignLeft)\n        layout.setSpacing(50)\n\n        self.create_affix_name_combobox()\n        self.create_greater_checkbox()\n        self.create_mode_combobox()\n        self.create_value_input()\n        self.mode_combo.currentTextChanged.connect(self.update_mode)\n        self.update_mode(self.mode_combo.currentText())\n\n        layout.addWidget(self.name_combo)\n        layout.addWidget(self.greater_checkbox)\n        layout.addWidget(self.mode_combo)\n        layout.addWidget(self.value_edit)\n\n        self.setLayout(layout)\n\n    def create_affix_name_combobox(self):\n        self.name_combo = IgnoreScrollWheelComboBox()\n        self.name_combo.setEditable(True)\n        self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)\n        self.name_combo.addItems(sorted(Dataloader().affix_dict.values()))\n        self.name_combo.setMaximumWidth(600)\n        if self.affix.name in Dataloader().affix_dict:\n            self.name_combo.setCurrentText(Dataloader().affix_dict[self.affix.name])\n        # currentIndexChanged misses some editable-combobox keyboard flows.\n        self.name_combo.currentTextChanged.connect(self.update_name)\n\n    def create_greater_checkbox(self):\n        self.greater_checkbox = QCheckBox(\"Greater\")\n        self.greater_checkbox.setChecked(getattr(self.affix, \"want_greater\", False))\n        self.greater_checkbox.setFixedWidth(80)\n        self.greater_checkbox.setProperty(\"greaterCheckbox\", True)\n        self._refresh_widget_style(self.greater_checkbox)\n        self.greater_checkbox.stateChanged.connect(self.update_greater)\n        self.greater_checkbox.stateChanged.connect(self.update_parent_count_label)\n\n    def _refresh_widget_style(self, widget):\n        widget.style().unpolish(widget)\n        widget.style().polish(widget)\n\n    def update_parent_count_label(self):\n        parent = self.parent()\n        while parent:\n            if isinstance(parent, AffixGroupEditor):\n                parent.update_greater_count_label()\n                parent.sync_min_greater_from_checkboxes()\n                break\n            parent = parent.parent()\n\n    def create_mode_combobox(self):\n        self.mode_combo = IgnoreScrollWheelComboBox()\n        self.mode_combo.setFixedSize(100, self.mode_combo.sizeHint().height())\n        self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE])\n        if self.affix.minPercentOfAffix:\n            self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE)\n        else:\n            self.mode_combo.setCurrentText(AFFIX_VALUE_MODE)\n\n    def create_value_input(self):\n        self.value_edit = QLineEdit()\n        self.value_edit.setFixedSize(100, self.value_edit.sizeHint().height())\n        self.value_edit.textChanged.connect(self.update_value)\n\n    def update_name(self, current_text=None):\n        \"\"\"Update the model only when the editable combobox contains a valid affix.\"\"\"\n        reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()}\n        affix_name = reverse_dict.get(current_text or self.name_combo.currentText())\n        if affix_name is None:\n            return\n        self.affix.name = affix_name\n\n    def refresh_value_input(self):\n        if self.mode_combo.currentText() == AFFIX_PERCENT_MODE:\n            self.value_edit.setPlaceholderText(\"Percent (0-100)\")\n            self.value_edit.setValidator(QIntValidator(0, 100, self.value_edit))\n            display_value = \"\" if self.affix.minPercentOfAffix == 0 else str(self.affix.minPercentOfAffix)\n        else:\n            self.value_edit.setPlaceholderText(\"Value (optional)\")\n            self.value_edit.setValidator(QDoubleValidator(self.value_edit))\n            display_value = \"\" if self.affix.value is None else str(self.affix.value)\n\n        self.value_edit.blockSignals(True)\n        self.value_edit.setText(display_value)\n        self.value_edit.blockSignals(False)\n\n    def update_mode(self, current_text=None):\n        mode = current_text or self.mode_combo.currentText()\n        if mode == AFFIX_PERCENT_MODE:\n            self.affix.value = None\n        else:\n            self.affix.minPercentOfAffix = 0\n        self.refresh_value_input()\n\n    def update_value(self, value):\n        if self.mode_combo.currentText() == AFFIX_PERCENT_MODE:\n            try:\n                percent = int(value) if value else 0\n            except ValueError:\n                return\n            if not 0 <= percent <= 100:\n                QMessageBox.warning(self, \"Warning\", \"Min % must be between 0 and 100.\")\n                self.refresh_value_input()\n                return\n            self.affix.minPercentOfAffix = percent\n            self.affix.value = None\n            return\n\n        try:\n            self.affix.value = float(value) if value else None\n        except ValueError:\n            return\n        self.affix.minPercentOfAffix = 0\n\n    def update_greater(self):\n        self.affix.want_greater = self.greater_checkbox.isChecked()\n\n    def set_min_percent(self, percent: int, convert_mode: bool = False):\n        if convert_mode and self.mode_combo.currentText() != AFFIX_PERCENT_MODE:\n            self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE)\n        if self.mode_combo.currentText() != AFFIX_PERCENT_MODE:\n            return\n        self.value_edit.setText(str(percent))\n\n\nclass AffixesTab(QWidget):\n    def __init__(self, affixes_model: list[DynamicItemFilterModel], parent=None):\n        super().__init__(parent)\n        self.affixes_model = affixes_model\n        self.loaded = False\n\n    def load(self):\n        if not self.loaded:\n            self.setup_ui()\n            self.loaded = True\n\n    def setup_ui(self):\n        \"\"\"Populate the grid layout with existing groups.\"\"\"\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setContentsMargins(0, 20, 0, 20)\n\n        self.tab_widget = QTabWidget(self)\n        self.tab_widget.setTabsClosable(True)\n        self.tab_widget.tabCloseRequested.connect(self.close_tab)\n\n        self.toolbar = QToolBar(\"MyToolBar\", self)\n        self.toolbar.setMinimumHeight(50)\n        self.toolbar.setContentsMargins(10, 10, 10, 10)\n        self.toolbar.setMovable(False)\n\n        self.item_names = []\n        for affix_group in self.affixes_model:\n            for item_name in affix_group.root:\n                if item_name in self.item_names:\n                    QMessageBox.warning(\n                        self, \"Warning\", f\"Item name already exist please rename {item_name} in the profile file.\"\n                    )\n                    continue\n                group = AffixGroupEditor(affix_group)\n                self.item_names.append(item_name)\n                self.tab_widget.addTab(group, item_name)\n\n        add_item_button = QPushButton()\n        add_item_button.setText(\"Create Item\")\n        add_item_button.clicked.connect(self.add_item_type)\n\n        remove_item_button = QPushButton()\n        remove_item_button.setText(\"Remove Item\")\n        remove_item_button.clicked.connect(self.remove_item_type)\n\n        set_all_minGreaterAffix_button = QPushButton(\"Set All Min GAs (Excludes Auto Synced Items)\")\n        convert_all_to_min_percent_button = QPushButton(\"Convert All To Min %\")\n        set_all_minPower_button = QPushButton(\"Set all minPower\")\n        set_all_minGreaterAffix_button.clicked.connect(self.set_all_minGreaterAffix)\n        convert_all_to_min_percent_button.clicked.connect(self.convert_all_to_min_percent_of_affix)\n        set_all_minPower_button.clicked.connect(self.set_all_minPower)\n\n        self.toolbar.addWidget(add_item_button)\n        self.toolbar.addWidget(remove_item_button)\n        self.toolbar.addWidget(set_all_minGreaterAffix_button)\n        self.toolbar.addWidget(convert_all_to_min_percent_button)\n        self.toolbar.addWidget(set_all_minPower_button)\n\n        self.main_layout.addWidget(self.toolbar)\n        self.main_layout.addWidget(self.tab_widget)\n\n    def show_message(self, text):\n        QMessageBox.information(self, \"Info\", text)\n\n    def add_item_type(self):\n        dialog = CreateItem(self.item_names, self)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            item = dialog.get_value()\n            for item_name in item.root:\n                group = AffixGroupEditor(item)\n                self.item_names.append(item_name)\n                self.tab_widget.addTab(group, item_name)\n                self.affixes_model.append(item)\n            return\n\n    def close_tab(self, index):\n        self.item_names.pop(index)\n        self.tab_widget.removeTab(index)\n        self.affixes_model.pop(index)\n\n    def remove_item_type(self):\n        dialog = DeleteItem(self.item_names, self)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            item_names_to_delete = dialog.get_value()\n            for item_name in item_names_to_delete:\n                index = self.item_names.index(item_name)\n                self.item_names.remove(item_name)\n                self.tab_widget.removeTab(index)\n                self.affixes_model.pop(index)\n            return\n\n    def set_all_minGreaterAffix(self):\n        dialog = MinGreaterDialog(self)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            minGreaterAffix = dialog.get_value()\n            for i in range(self.tab_widget.count()):\n                tab: AffixGroupEditor = self.tab_widget.widget(i)\n                if tab.auto_sync_checkbox.isChecked():\n                    continue\n                tab.min_greater.setValue(minGreaterAffix)\n                tab.update_min_greater_affix()\n\n    def convert_all_to_min_percent_of_affix(self):\n        current_tab = self.tab_widget.currentWidget()\n        if isinstance(current_tab, AffixGroupEditor):\n            dialog = MinPercentDialog(self)\n            if dialog.exec() == QDialog.DialogCode.Accepted:\n                current_tab.convert_all_to_min_percent_of_affix(dialog.get_value())\n\n    def set_all_minPower(self):\n        dialog = MinPowerDialog(self)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            minPower = dialog.get_value()\n            for i in range(self.tab_widget.count()):\n                tab: AffixGroupEditor = self.tab_widget.widget(i)\n                tab.min_power.setValue(minPower)\n                tab.update_min_power()\n"
  },
  {
    "path": "src/gui/profile_editor/aspect_upgrades_tab.py",
    "content": "from PyQt6.QtCore import Qt\nfrom PyQt6.QtWidgets import QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QVBoxLayout, QWidget\n\nfrom src.gui.dialog import AddAspectUpgrade\n\nASPECT_UPGRADES_TABNAME = \"Aspect Upgrades\"\n\n\nclass AspectUpgradesTab(QWidget):\n    def __init__(self, aspect_upgrades: list[str], parent=None):\n        super().__init__(parent)\n        self.aspect_upgrades = aspect_upgrades\n        self.upgrade_list_widget = QListWidget()\n        self.loaded = False\n\n    def load(self):\n        if not self.loaded:\n            self.setup_ui()\n            self.loaded = True\n\n    def setup_ui(self):\n        main_layout = QVBoxLayout(self)\n        main_layout.setContentsMargins(0, 20, 0, 20)\n        main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n        label = QLabel(\n            \"Add any legendary aspects you'd like to have favorited if an upgrade is found. See the readme on AspectUpgrades for more information.\"\n        )\n        main_layout.addWidget(label)\n        button_layout = self.create_button_layout()\n        main_layout.addLayout(button_layout)\n\n        self.upgrade_list_widget.insertItems(0, self.aspect_upgrades)\n        main_layout.addWidget(self.upgrade_list_widget)\n        self.setLayout(main_layout)\n\n    def create_button_layout(self) -> QHBoxLayout:\n        btn_layout = QHBoxLayout()\n\n        add_tribute_btn = QPushButton(\"Add Aspect\")\n        add_tribute_btn.clicked.connect(self.add_aspect)\n\n        remove_tribute_btn = QPushButton(\"Remove Aspect\")\n        remove_tribute_btn.clicked.connect(self.remove_aspect)\n\n        btn_layout.addWidget(add_tribute_btn)\n        btn_layout.addWidget(remove_tribute_btn)\n        return btn_layout\n\n    def add_aspect(self):\n        dialog = AddAspectUpgrade(self.aspect_upgrades)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            aspect_upgrade = dialog.get_value()\n            self.aspect_upgrades.append(aspect_upgrade)\n            self.upgrade_list_widget.addItem(aspect_upgrade)\n\n    def remove_aspect(self):\n        current_aspect = self.upgrade_list_widget.currentItem().text()\n        self.aspect_upgrades.remove(current_aspect)\n        self.upgrade_list_widget.takeItem(self.upgrade_list_widget.currentRow())\n"
  },
  {
    "path": "src/gui/profile_editor/global_uniques_tab.py",
    "content": "from PyQt6.QtCore import Qt\nfrom PyQt6.QtWidgets import (\n    QDialog,\n    QFormLayout,\n    QFrame,\n    QGroupBox,\n    QLineEdit,\n    QPushButton,\n    QScrollArea,\n    QTabWidget,\n    QToolBar,\n    QToolButton,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.profile_models import GlobalUniqueModel\nfrom src.gui.dialog import DeleteItem, IgnoreScrollWheelSpinBox\nfrom src.gui.importer.gui_common import MAX_POWER\n\nUNIQUES_TABNAME = \"GlobalUniques\"\n\n\nclass UniqueWidget(QWidget):\n    def __init__(self, unique_model: GlobalUniqueModel, parent=None):\n        super().__init__(parent)\n        self.unique_model = unique_model\n\n        self.setup_ui()\n\n    def setup_ui(self):\n        scroll_area = QScrollArea(self)\n        scroll_area.setWidgetResizable(True)\n        scroll_area.setFrameShape(QFrame.Shape.NoFrame)\n\n        content_widget = QWidget()\n        self.content_layout = QVBoxLayout(content_widget)\n        self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n\n        self.create_general_groupbox()\n\n        scroll_area.setWidget(content_widget)\n        self.main_layout = QVBoxLayout()\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n        self.main_layout.addWidget(scroll_area)\n        self.setLayout(self.main_layout)\n\n    def create_general_groupbox(self):\n        self.general_groupbox = QGroupBox()\n        self.general_groupbox.setTitle(\"Global Unique Rule\")\n        self.general_form = QFormLayout()\n\n        self.profile_alias = QLineEdit()\n        self.profile_alias.setMaximumWidth(300)\n        self.profile_alias.setText(self.unique_model.profileAlias)\n        self.profile_alias.textChanged.connect(self.update_profile_alias)\n        self.general_form.addRow(\"Profile Alias:\", self.profile_alias)\n\n        self.min_power = IgnoreScrollWheelSpinBox()\n        self.min_power.setRange(0, MAX_POWER)\n        self.min_power.setValue(self.unique_model.minPower)\n        self.min_power.setMaximumWidth(150)\n        self.min_power.valueChanged.connect(self.update_min_power)\n        self.general_form.addRow(\"Minimum Power:\", self.min_power)\n\n        self.min_greater = IgnoreScrollWheelSpinBox()\n        self.min_greater.setRange(0, 4)\n        self.min_greater.setValue(self.unique_model.minGreaterAffixCount)\n        self.min_greater.setMaximumWidth(150)\n        self.min_greater.valueChanged.connect(self.update_min_greater_affix)\n        self.general_form.addRow(\"Min Greater Affixes:\", self.min_greater)\n\n        self.min_percent = IgnoreScrollWheelSpinBox()\n        self.min_percent.setRange(0, 100)\n        self.min_percent.setValue(self.unique_model.minPercentOfAspect)\n        self.min_percent.setMaximumWidth(150)\n        self.min_percent.valueChanged.connect(self.update_min_percent)\n        self.general_form.addRow(\"Min Percent of Aspect:\", self.min_percent)\n\n        self.general_groupbox.setLayout(self.general_form)\n        self.content_layout.addWidget(self.general_groupbox)\n\n    def update_profile_alias(self, value: str):\n        self.unique_model.profileAlias = value.strip()\n\n    def update_min_power(self):\n        self.unique_model.minPower = self.min_power.value()\n\n    def update_min_greater_affix(self):\n        self.unique_model.minGreaterAffixCount = self.min_greater.value()\n\n    def update_min_percent(self):\n        self.unique_model.minPercentOfAspect = self.min_percent.value()\n\n\nclass UniquesTab(QWidget):\n    def __init__(self, unique_model_list: list[GlobalUniqueModel], parent=None):\n        super().__init__(parent)\n        self.unique_model_list = unique_model_list\n        self.loaded = False\n\n    def load(self):\n        if not self.loaded:\n            self.setup_ui()\n            self.loaded = True\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setContentsMargins(0, 20, 0, 20)\n        self.tab_widget = QTabWidget(self)\n        self.tab_widget.setTabsClosable(True)\n        self.tab_widget.tabCloseRequested.connect(self.close_tab)\n\n        self.add_button = QToolButton()\n        self.add_button.setText(\"+\")\n        self.add_button.clicked.connect(self.add_item_type)\n\n        self.tab_widget.setCornerWidget(self.add_button)\n        self.toolbar = QToolBar(\"MyToolBar\", self)\n        self.toolbar.setMinimumHeight(50)\n        self.toolbar.setContentsMargins(10, 10, 10, 10)\n        self.toolbar.setMovable(False)\n        for i, unique_model in enumerate(self.unique_model_list):\n            group = UniqueWidget(unique_model)\n            self.tab_widget.addTab(group, f\"Unique Rule {i}\")\n\n        add_item_button = QPushButton(\"Create Rule\")\n        remove_item_button = QPushButton(\"Remove Rule\")\n        add_item_button.clicked.connect(self.add_item_type)\n        remove_item_button.clicked.connect(self.remove_item_type)\n        self.toolbar.addWidget(add_item_button)\n        self.toolbar.addWidget(remove_item_button)\n        self.main_layout.addWidget(self.toolbar)\n        self.main_layout.addWidget(self.tab_widget)\n\n    def close_tab(self, index):\n        self.tab_widget.removeTab(index)\n        self.unique_model_list.pop(index)\n        self.rename_tabs()\n\n    def remove_item_type(self):\n        dialog = DeleteItem([self.tab_widget.tabText(i) for i in range(self.tab_widget.count())], self)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            item_names_to_delete = dialog.get_value()\n            to_delete_index = [\n                i for i in range(self.tab_widget.count()) if self.tab_widget.tabText(i) in item_names_to_delete\n            ]\n            to_delete_index.reverse()\n            for index in to_delete_index:\n                self.tab_widget.removeTab(index)\n                self.unique_model_list.pop(index)\n            self.rename_tabs()\n            return\n\n    def rename_tabs(self):\n        for i in range(self.tab_widget.count()):\n            self.tab_widget.setTabText(i, f\"Unique Rule {i}\")\n\n    def add_item_type(self):\n        unique_model = GlobalUniqueModel()\n        group = UniqueWidget(unique_model)\n        self.tab_widget.addTab(group, f\"Unique Rule {self.tab_widget.count()}\")\n        self.unique_model_list.append(unique_model)\n"
  },
  {
    "path": "src/gui/profile_editor/profile_editor.py",
    "content": "import logging\n\nfrom PyQt6.QtCore import Qt, pyqtSignal\nfrom PyQt6.QtWidgets import QMessageBox, QTabWidget\n\nfrom src.config.profile_models import ProfileModel\nfrom src.gui.importer.gui_common import save_as_profile\nfrom src.gui.profile_editor.affixes_tab import AFFIXES_TABNAME, AffixesTab\nfrom src.gui.profile_editor.aspect_upgrades_tab import ASPECT_UPGRADES_TABNAME, AspectUpgradesTab\nfrom src.gui.profile_editor.global_uniques_tab import UNIQUES_TABNAME, UniquesTab\nfrom src.gui.profile_editor.sigils_tab import SIGILS_TABNAME, SigilsTab\nfrom src.gui.profile_editor.tributes_tab import TRIBUTES_TABNAME, TributesTab\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass ProfileEditor(QTabWidget):\n    # Signal emitted when profile is saved (passes profile name)\n    profile_saved = pyqtSignal(str)\n\n    def __init__(self, profile_model: ProfileModel, parent=None):\n        super().__init__(parent)\n\n        self.profile_model = profile_model\n        # Create main tabs\n        self.affixes_tab = AffixesTab(self.profile_model.Affixes)\n        self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.AspectUpgrades)\n        self.sigils_tab = SigilsTab(self.profile_model.Sigils)\n        self.tributes_tab = TributesTab(self.profile_model.Tributes)\n        self.uniques_tab = UniquesTab(self.profile_model.GlobalUniques)\n\n        self.currentChanged.connect(self.tab_changed)\n        # Add tabs with icons\n        self.addTab(self.affixes_tab, AFFIXES_TABNAME)\n        self.addTab(self.aspect_upgrades_tab, ASPECT_UPGRADES_TABNAME)\n        self.addTab(self.sigils_tab, SIGILS_TABNAME)\n        self.addTab(self.tributes_tab, TRIBUTES_TABNAME)\n        self.addTab(self.uniques_tab, UNIQUES_TABNAME)\n\n        # Configure tab widget properties\n        self.setDocumentMode(True)\n        self.setMovable(False)\n        self.setTabPosition(QTabWidget.TabPosition.North)\n        self.setElideMode(Qt.TextElideMode.ElideRight)\n\n    def tab_changed(self, index):\n        if self.tabText(index) == AFFIXES_TABNAME:\n            self.affixes_tab.load()\n        elif self.tabText(index) == ASPECT_UPGRADES_TABNAME:\n            self.aspect_upgrades_tab.load()\n        elif self.tabText(index) == SIGILS_TABNAME:\n            self.sigils_tab.load()\n        elif self.tabText(index) == TRIBUTES_TABNAME:\n            self.tributes_tab.load()\n        elif self.tabText(index) == UNIQUES_TABNAME:\n            self.uniques_tab.load()\n\n    @staticmethod\n    def show_warning():\n        msg = QMessageBox()\n        msg.setIcon(QMessageBox.Icon.Warning)\n        msg.setWindowTitle(\"Warning\")\n\n        # Newline in message text\n        msg.setText(\"The profile model might not be valid. Do you still want to save your changes ?\")\n\n        msg.setStandardButtons(QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard)\n\n        response = msg.exec()\n        return response == QMessageBox.StandardButton.Save\n\n    def save_all(self):\n        \"\"\"Save all tabs' configurations.\"\"\"\n        try:\n            # Validate\n            model = ProfileModel.model_validate(self.profile_model)\n            if model != self.profile_model:\n                if self.show_warning():\n                    save_as_profile(\n                        self.profile_model.name, self.profile_model, \"custom\", exclude={\"name\"}, backup_file=True\n                    )\n                    # Emit signal for hot reload\n                    self.profile_saved.emit(self.profile_model.name)\n                    QMessageBox.information(\n                        self, \"Info\", f\"Profile saved successfully to {self.profile_model.name + '.yaml'}\"\n                    )\n                else:\n                    QMessageBox.information(self, \"Info\", \"Profile not saved.\")\n            else:\n                save_as_profile(\n                    self.profile_model.name, self.profile_model, \"custom\", exclude={\"name\"}, backup_file=True\n                )\n                # Emit signal for hot reload\n                self.profile_saved.emit(self.profile_model.name)\n                QMessageBox.information(\n                    self, \"Info\", f\"Profile saved successfully to {self.profile_model.name + '.yaml'}\"\n                )\n        except Exception as e:\n            LOGGER.error(f\"Validation error: {e}\")\n            QMessageBox.critical(self, \"Error\", f\"Failed to save profile: {e}\")\n"
  },
  {
    "path": "src/gui/profile_editor/sigils_tab.py",
    "content": "from PyQt6.QtCore import Qt, pyqtSignal\nfrom PyQt6.QtWidgets import (\n    QComboBox,\n    QCompleter,\n    QDialog,\n    QFormLayout,\n    QHBoxLayout,\n    QLabel,\n    QListWidget,\n    QListWidgetItem,\n    QMessageBox,\n    QPushButton,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.profile_models import SigilConditionModel, SigilFilterModel, SigilPriority\nfrom src.dataloader import Dataloader\nfrom src.gui.collapsible_widget import Container\nfrom src.gui.dialog import CreateSigil, IgnoreScrollWheelComboBox, RemoveSigil\n\nSIGILS_TABNAME = \"Sigils\"\n\n\nclass ConditionWidget(QWidget):\n    condition_changed = pyqtSignal(str, str)\n\n    def __init__(self, condition: str, parent=None):\n        super().__init__(parent)\n        self.condition = condition\n        widget_layout = QHBoxLayout()\n        widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)\n        self.name_combo = IgnoreScrollWheelComboBox()\n        self.name_combo.setEditable(True)\n        self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        affix_sigil_dict = {\n            **Dataloader().affix_sigil_dict_all[\"minor\"],\n            **Dataloader().affix_sigil_dict_all[\"major\"],\n            **Dataloader().affix_sigil_dict_all[\"positive\"],\n        }\n        self.name_combo.addItems(sorted(affix_sigil_dict.values()))\n        self.name_combo.setMaximumWidth(600)\n        self.name_combo.setCurrentText(condition)\n        self.name_combo.currentIndexChanged.connect(self.update_condition)\n        widget_layout.addWidget(self.name_combo)\n        self.setLayout(widget_layout)\n\n    def update_condition(self):\n        old_condition = self.condition\n        self.condition = self.name_combo.currentText()\n        self.condition_changed.emit(old_condition, self.condition)\n\n\nclass SigilWidget(Container):\n    dungeon_changed = pyqtSignal()\n\n    def __init__(self, sigil_name: str, sigil: SigilConditionModel, whitelist: bool):\n        super().__init__(sigil_name, True)\n        self.sigil = sigil\n        self.sigil_name = sigil_name\n        self.whitelist = whitelist\n        self.setup_ui()\n\n    def setup_ui(self):\n        container_layout = QVBoxLayout(self.contentWidget)\n        widget = QWidget()\n        layout = QVBoxLayout()\n        layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n        title_layout = QHBoxLayout()\n        title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)\n\n        form_layout = QFormLayout()\n        self.sigil_name_combo = IgnoreScrollWheelComboBox()\n        self.sigil_name_combo.setEditable(True)\n        self.sigil_name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.sigil_name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.sigil_name_combo.addItems(sorted(Dataloader().affix_sigil_dict_all[\"dungeons\"].values()))\n        self.sigil_name_combo.setCurrentText(self.sigil_name)\n        self.sigil_name_combo.setMaximumWidth(150)\n        self.sigil_name_combo.currentIndexChanged.connect(self.update_sigil_dungeon)\n        form_layout.addRow(\"Dungeon:\", self.sigil_name_combo)\n\n        comparison_label = QLabel(\"Condition\")\n        title_layout.addSpacing(100)\n        title_layout.addWidget(comparison_label)\n        self.condition_list = QListWidget()\n        self.condition_list.setMinimumHeight(50)\n        self.condition_list.setAlternatingRowColors(True)\n        for condition in self.sigil.condition:\n            if not condition:\n                continue\n            self.add_condition_to_list(Dataloader().affix_sigil_dict[condition])\n\n        condition_btn_layout = QHBoxLayout()\n        add_condition_btn = QPushButton(\"Add Condition\")\n        add_condition_btn.clicked.connect(self.add_condition)\n        condition_btn_layout.addWidget(add_condition_btn)\n        remove_condition_btn = QPushButton(\"Remove Condition\")\n        remove_condition_btn.clicked.connect(self.remove_selected)\n        condition_btn_layout.addWidget(remove_condition_btn)\n        layout.addLayout(form_layout)\n        layout.addLayout(condition_btn_layout)\n        layout.addLayout(title_layout)\n        layout.addWidget(self.condition_list)\n        widget.setLayout(layout)\n        container_layout.addWidget(widget)\n\n    def add_condition_to_list(self, condition):\n        widget_item = QListWidgetItem()\n        widget = ConditionWidget(condition)\n        widget.condition_changed.connect(self.on_condition_update)\n        widget_item.setSizeHint(widget.sizeHint())\n        self.condition_list.addItem(widget_item)\n        self.condition_list.setItemWidget(widget_item, widget)\n\n    def add_condition(self):\n        self.add_condition_to_list(next(iter(Dataloader().affix_sigil_dict_all[\"minor\"].values())))\n        self.sigil.condition.append(next(iter(Dataloader().affix_sigil_dict_all[\"minor\"].keys())))\n\n    def remove_selected(self):\n        for item in self.condition_list.selectedItems():\n            row = self.condition_list.row(item)\n            self.condition_list.takeItem(row)\n            self.sigil.condition.pop(row)\n\n    def revert_sigil_dungeon(self):\n        self.sigil_name_combo.currentIndexChanged.disconnect()\n        self.sigil_name_combo.currentTextChanged.connect(lambda: self.update_sigil_dungeon(False))\n        self.sigil_name_combo.setCurrentText(self.old_name)\n        self.sigil_name_combo.currentTextChanged.disconnect()\n        self.sigil_name_combo.currentIndexChanged.connect(self.update_sigil_dungeon)\n\n    def update_sigil_dungeon(self, classic=True):\n        new_name = self.sigil_name_combo.currentText()\n        self.old_name = self.sigil_name\n        self.sigil_name = new_name\n        self.header.set_name(new_name)\n        reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict_all[\"dungeons\"].items()}\n        self.sigil.name = reverse_dict.get(new_name)\n        if classic:\n            self.dungeon_changed.emit()\n\n    def on_condition_update(self, old_condition, condition: str):\n        reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict.items()}\n        index = self.sigil.condition.index(reverse_dict.get(old_condition, \"\"))\n        self.sigil.condition.pop(index)\n        self.sigil.condition.insert(index, reverse_dict.get(condition))\n\n\nclass SigilsTab(QWidget):\n    def __init__(self, sigil_model: SigilFilterModel, parent=None):\n        super().__init__(parent)\n        self.sigil_model = sigil_model\n        self.loaded = False\n\n    def load(self):\n        if not self.loaded:\n            self.setup_ui()\n            self.loaded = True\n\n    def setup_ui(self):\n        \"\"\"Populate the grid layout with existing groups.\"\"\"\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setContentsMargins(0, 20, 0, 20)\n        self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n        self.create_button_layout()\n        self.create_form()\n        self.create_containers()\n\n    def create_button_layout(self):\n        btn_layout = QHBoxLayout()\n\n        add_sigil_btn = QPushButton(\"Add Sigil\")\n        add_sigil_btn.clicked.connect(self.create_sigil)\n\n        remove_whitelist_sigil_btn = QPushButton(\"Remove Whitelist Sigil\")\n        remove_whitelist_sigil_btn.clicked.connect(lambda: self.remove_sigil())\n\n        remove_blacklist_sigil_btn = QPushButton(\"Remove Blacklist Sigil\")\n        remove_blacklist_sigil_btn.clicked.connect(lambda: self.remove_sigil(blacklist=True))\n\n        btn_layout.addWidget(add_sigil_btn)\n        btn_layout.addWidget(remove_whitelist_sigil_btn)\n        btn_layout.addWidget(remove_blacklist_sigil_btn)\n        self.main_layout.addLayout(btn_layout)\n\n    def create_form(self):\n        self.general_form = QFormLayout()\n        self.priority_combobox = IgnoreScrollWheelComboBox()\n        self.priority_combobox.setEditable(True)\n        self.priority_combobox.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)\n        self.priority_combobox.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self.priority_combobox.addItems(SigilPriority._member_names_)\n        self.priority_combobox.setCurrentText(self.sigil_model.priority)\n        self.priority_combobox.setMaximumWidth(150)\n        self.priority_combobox.currentIndexChanged.connect(self.update_priority)\n        self.general_form.addRow(\"Priority:\", self.priority_combobox)\n        self.main_layout.addLayout(self.general_form)\n\n    def create_containers(self):\n        # Blacklist\n        self.blacklist_container = Container(\"Blacklist\")\n        self.blacklist_layout = QVBoxLayout(self.blacklist_container.contentWidget)\n        self.blacklist_sigils = []\n\n        for sigil_condition in self.sigil_model.blacklist:\n            self.add_sigil(sigil_condition)\n            self.blacklist_sigils.append(Dataloader().affix_sigil_dict[sigil_condition.name])\n\n        # Whitelist\n        self.whitelist_container = Container(\"Whitelist\")\n        self.whitelist_layout = QVBoxLayout(self.whitelist_container.contentWidget)\n        self.whitelist_sigils = []\n\n        for sigil_condition in self.sigil_model.whitelist:\n            self.add_sigil(sigil_condition, True)\n            self.whitelist_sigils.append(Dataloader().affix_sigil_dict[sigil_condition.name])\n\n        self.main_layout.addWidget(self.whitelist_container)\n        self.main_layout.addWidget(self.blacklist_container)\n\n    def add_sigil(self, sigil_condition: SigilConditionModel, whitelist: bool = False):\n        name = Dataloader().affix_sigil_dict_all[\"dungeons\"][sigil_condition.name]\n        if whitelist:\n            widget = SigilWidget(name, sigil_condition, True)\n            widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))\n            self.whitelist_layout.addWidget(widget)\n        else:\n            widget = SigilWidget(name, sigil_condition, False)\n            widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))\n            self.blacklist_layout.addWidget(widget)\n\n    def create_sigil(self):\n        dialog = CreateSigil(self.whitelist_sigils, self.blacklist_sigils)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            sigil_name, type_name = dialog.get_value()\n            reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict_all[\"dungeons\"].items()}\n            sigil_condition = SigilConditionModel(name=reverse_dict.get(sigil_name), condition=[])\n            if type_name == \"whitelist\":\n                widget = SigilWidget(sigil_name, sigil_condition, True)\n                widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))\n                self.whitelist_layout.addWidget(widget)\n                self.whitelist_sigils.append(sigil_name)\n                self.sigil_model.whitelist.append(sigil_condition)\n            elif type_name == \"blacklist\":\n                widget = SigilWidget(sigil_name, sigil_condition, False)\n                widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget))\n                self.blacklist_layout.addWidget(widget)\n                self.blacklist_sigils.append(sigil_name)\n                self.sigil_model.blacklist.append(sigil_condition)\n\n    def remove_sigil(self, blacklist: bool = False):\n        dialog = RemoveSigil(self.blacklist_sigils, blacklist=True) if blacklist else RemoveSigil(self.whitelist_sigils)\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            to_delete = dialog.get_value()\n            if blacklist:\n                for sigil in to_delete:\n                    self.blacklist_sigils.remove(sigil)\n                to_delete_list = []\n                for i in range(self.blacklist_layout.count()):\n                    sigil_widget: SigilWidget = self.blacklist_layout.itemAt(i).widget()\n                    if sigil_widget.sigil_name in to_delete:\n                        to_delete_list.append(sigil_widget)\n                for sig_widget in to_delete_list:\n                    sig_widget.setParent(None)\n                    self.sigil_model.blacklist.remove(sig_widget.sigil)\n            else:\n                for sigil in to_delete:\n                    self.whitelist_sigils.remove(sigil)\n                to_delete_list = []\n                for i in range(self.whitelist_layout.count()):\n                    sigil_widget: SigilWidget = self.whitelist_layout.itemAt(i).widget()\n                    if sigil_widget.sigil_name in to_delete:\n                        to_delete_list.append(sigil_widget)\n                for sig_widget in to_delete_list:\n                    sig_widget.setParent(None)\n                    self.sigil_model.whitelist.remove(sig_widget.sigil)\n\n    def update_priority(self):\n        self.sigil_model.priority = SigilPriority(self.priority_combobox.currentText())\n\n    def on_dungeon_changed(self, sigil_widget: SigilWidget):\n        whitelist = sigil_widget.whitelist\n        new_name = sigil_widget.sigil_name\n        old_name = sigil_widget.old_name\n        if whitelist and new_name in self.whitelist_sigils:\n            QMessageBox.warning(self, \"Warning\", \"Sigil already exist in whitelist. You can modify the existing one.\")\n            sigil_widget.revert_sigil_dungeon()\n            return\n        if not whitelist and new_name in self.blacklist_sigils:\n            QMessageBox.warning(self, \"Warning\", \"Sigil already exist in blacklist. You can modify the existing one.\")\n            sigil_widget.revert_sigil_dungeon()\n            return\n        if whitelist and old_name in self.whitelist_sigils:\n            index = self.whitelist_sigils.index(old_name)\n            self.whitelist_sigils.pop(index)\n            self.whitelist_sigils.insert(index, new_name)\n        if not whitelist and old_name in self.blacklist_sigils:\n            index = self.blacklist_sigils.index(old_name)\n            self.blacklist_sigils.pop(index)\n            self.blacklist_sigils.insert(index, new_name)\n"
  },
  {
    "path": "src/gui/profile_editor/tributes_tab.py",
    "content": "from PyQt6.QtCore import Qt\nfrom PyQt6.QtWidgets import (\n    QAbstractItemView,\n    QDialog,\n    QHBoxLayout,\n    QLabel,\n    QListWidget,\n    QMessageBox,\n    QPushButton,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.profile_models import ItemRarity, TributeFilterModel\nfrom src.dataloader import Dataloader\nfrom src.gui.dialog import AddTributeRarity, CreateTribute\n\nTRIBUTES_TABNAME = \"Tributes\"\n\n\nclass TributesTab(QWidget):\n    def __init__(self, tributes: list[TributeFilterModel] | None, parent=None):\n        super().__init__(parent)\n        self.tributes = tributes if tributes is not None else []\n        self.tribute_list_widget = QListWidget()\n        self.loaded = False\n\n    def load(self):\n        if not self.loaded:\n            self.setup_ui()\n            self.loaded = True\n\n    def setup_ui(self):\n        main_layout = QVBoxLayout(self)\n        main_layout.setContentsMargins(0, 20, 0, 20)\n        main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)\n        label = QLabel(\n            \"Add tribute names and tribute rarities you want to keep. These rules are evaluated independently.\"\n        )\n        label.setWordWrap(True)\n        main_layout.addWidget(label)\n        button_layout = self.create_button_layout()\n        main_layout.addLayout(button_layout)\n\n        self.tribute_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)\n        self._reload_tribute_list_widget()\n        main_layout.addWidget(self.tribute_list_widget)\n        self.setLayout(main_layout)\n\n    def create_button_layout(self) -> QHBoxLayout:\n        btn_layout = QHBoxLayout()\n\n        add_tribute_btn = QPushButton(\"Add Tribute\")\n        add_tribute_btn.clicked.connect(self.add_tribute)\n\n        add_rarity_btn = QPushButton(\"Add Rarity\")\n        add_rarity_btn.clicked.connect(self.add_rarity)\n\n        remove_rule_btn = QPushButton(\"Remove Selected\")\n        remove_rule_btn.clicked.connect(self.remove_selected)\n\n        btn_layout.addWidget(add_tribute_btn)\n        btn_layout.addWidget(add_rarity_btn)\n        btn_layout.addWidget(remove_rule_btn)\n        return btn_layout\n\n    def _reload_tribute_list_widget(self):\n        self.tribute_list_widget.clear()\n        for tribute in self.tributes:\n            self.tribute_list_widget.addItem(self._display_text(tribute))\n\n    @staticmethod\n    def _display_text(tribute: TributeFilterModel) -> str:\n        if not tribute.name and not tribute.rarities:\n            return \"Empty tribute rule\"\n\n        parts = []\n        if tribute.name:\n            tribute_name = Dataloader().tribute_dict.get(tribute.name, tribute.name)\n            parts.append(f\"Tribute: {tribute_name}\")\n\n        if tribute.rarities:\n            rarity_names = \", \".join(ItemRarity(rarity).name for rarity in tribute.rarities)\n            parts.append(f\"Rarities: {rarity_names}\")\n\n        return \" | \".join(parts)\n\n    def add_tribute(self):\n        dialog = CreateTribute(self._existing_tribute_names())\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            tribute_filter = dialog.get_value()\n            self.tributes.append(tribute_filter)\n            self.tribute_list_widget.addItem(self._display_text(tribute_filter))\n\n    def add_rarity(self):\n        dialog = AddTributeRarity(self._existing_rarities())\n        if dialog.exec() == QDialog.DialogCode.Accepted:\n            tribute_filter = dialog.get_value()\n            self.tributes.append(tribute_filter)\n            self.tribute_list_widget.addItem(self._display_text(tribute_filter))\n\n    def remove_selected(self):\n        rows = sorted(\n            {self.tribute_list_widget.row(item) for item in self.tribute_list_widget.selectedItems()}, reverse=True\n        )\n        if not rows:\n            QMessageBox.warning(self, \"Warning\", \"Select at least one tribute rule to remove.\")\n            return\n\n        for row in rows:\n            self.tribute_list_widget.takeItem(row)\n            self.tributes.pop(row)\n\n    def _existing_tribute_names(self) -> list[str]:\n        return [tribute.name for tribute in self.tributes if tribute.name and not tribute.rarities]\n\n    def _existing_rarities(self) -> list[ItemRarity]:\n        return [\n            ItemRarity(tribute.rarities[0])\n            for tribute in self.tributes\n            if tribute.rarities and not tribute.name and len(tribute.rarities) == 1\n        ]\n"
  },
  {
    "path": "src/gui/profile_editor_window.py",
    "content": "import logging\nimport sys\nfrom pathlib import Path\n\nfrom PyQt6.QtCore import QPoint, QSettings, QSize, Qt, QTimer\nfrom PyQt6.QtGui import QIcon\nfrom PyQt6.QtWidgets import QMainWindow\n\nfrom src.gui.profile_tab import ProfileTab\n\nBASE_DIR = Path(sys.executable).parent if getattr(sys, \"frozen\", False) else Path(__file__).resolve().parent.parent\n\nICON_PATH = BASE_DIR / \"assets\" / \"logo.png\"\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass ProfileEditorWindow(QMainWindow):\n    \"\"\"Standalone window for Profile Editor.\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.settings = QSettings(\"d4lf\", \"profile_editor\")\n\n        if ICON_PATH.exists():\n            self.setWindowIcon(QIcon(str(ICON_PATH)))\n\n        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)\n        self.setWindowTitle(\"Profile Editor\")\n\n        self.resize(self.settings.value(\"size\", QSize(650, 800)))\n        self.move(self.settings.value(\"pos\", QPoint(0, 0)))\n\n        if self.settings.value(\"maximized\", \"true\") == \"true\":\n            self.showMaximized()\n\n        # Defer heavy construction\n        QTimer.singleShot(0, self._finish_construction)\n\n    def _finish_construction(self):\n        self.profile_tab = ProfileTab()\n        self.setCentralWidget(self.profile_tab)\n        self.profile_tab.show_tab()\n\n    def closeEvent(self, event):\n        \"\"\"Save window size/position and check if profile needs saving.\"\"\"\n        if not self.isMaximized():\n            self.settings.setValue(\"size\", self.size())\n            self.settings.setValue(\"pos\", self.pos())\n        self.settings.setValue(\"maximized\", self.isMaximized())\n\n        if self.profile_tab.check_close_save():\n            event.accept()\n        else:\n            event.ignore()\n"
  },
  {
    "path": "src/gui/profile_tab.py",
    "content": "import copy\nimport logging\nimport pathlib\n\nimport yaml\nfrom pydantic import ValidationError\nfrom PyQt6.QtCore import QSettings, Qt\nfrom PyQt6.QtWidgets import (\n    QFileDialog,\n    QGroupBox,\n    QHBoxLayout,\n    QLabel,\n    QMessageBox,\n    QPushButton,\n    QScrollArea,\n    QTextBrowser,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src.config.loader import IniConfigLoader\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.gui_common import ProfileModel\nfrom src.gui.profile_editor.profile_editor import ProfileEditor\nfrom src.item.filter import _UniqueKeyLoader\n\nLOGGER = logging.getLogger(__name__)\n\nPROFILE_TABNAME = \"edit profile (beta)\"\n\n\nclass ProfileTab(QWidget):\n    def __init__(self):\n        super().__init__()\n        self.settings = QSettings(\"d4lf\", \"profile_editor\")\n\n        self.root = None\n        self.file_path = None\n        self.model_editor = None\n        self.first_show = True\n        self.main_layout = QVBoxLayout(self)\n\n        scroll_area = QScrollArea(self)\n        scroll_widget = QWidget(scroll_area)\n        self.scrollable_layout = QVBoxLayout(scroll_widget)\n        scroll_area.setWidgetResizable(True)\n\n        info_layout = QHBoxLayout()\n        info_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)\n\n        profile_groupbox = QGroupBox(\"Profile Loaded\")\n        profile_groupbox_layout = QVBoxLayout()\n        self.filenameLabel = QLabel(\"\")\n        self.filenameLabel.setStyleSheet(\"font-size: 12pt;\")\n        profile_groupbox_layout.addWidget(self.filenameLabel)\n        profile_groupbox.setLayout(profile_groupbox_layout)\n        info_layout.addWidget(profile_groupbox)\n\n        tools_groupbox = QGroupBox(\"Tools\")\n        tools_groupbox_layout = QHBoxLayout()\n        self.file_button = QPushButton(\"Open\")\n        self.save_button = QPushButton(\"Save\")\n        self.refresh_button = QPushButton(\"Undo Changes\")\n        self.file_button.clicked.connect(self.load_file)\n        self.save_button.clicked.connect(self.save_yaml)\n        self.refresh_button.clicked.connect(self.refresh)\n        tools_groupbox_layout.addWidget(self.file_button)\n        tools_groupbox_layout.addWidget(self.save_button)\n        tools_groupbox_layout.addWidget(self.refresh_button)\n        tools_groupbox.setLayout(tools_groupbox_layout)\n        info_layout.addWidget(tools_groupbox)\n        self.main_layout.addLayout(info_layout)\n\n        self.itemTypes = Dataloader().item_types_dict\n        self.affixesNames = Dataloader().affix_dict\n\n        self.profile_editor_created = False\n        scroll_widget.setLayout(self.scrollable_layout)\n        scroll_area.setWidget(scroll_widget)\n        self.main_layout.addWidget(scroll_area)\n        instructions_label = QLabel(\"Instructions\")\n        self.main_layout.addWidget(instructions_label)\n\n        instructions_text = QTextBrowser()\n        instructions_text.append(\n            \"You load a profile by clicking the 'Open' button. Click 'Save' to save your changes. Click 'Undo Changes' to revert your changes.\"\n        )\n\n        instructions_text.setFixedHeight(50)\n        self.main_layout.addWidget(instructions_text)\n        self.setLayout(self.main_layout)\n\n    def confirm_discard_changes(self):\n        reply = QMessageBox.warning(\n            self,\n            \"Unsaved Changes\",\n            \"You have unsaved changes. Do you want to save them before closing?\",\n            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,\n        )\n        if reply == QMessageBox.StandardButton.Yes:\n            self.save_yaml()\n            return True\n        return reply == QMessageBox.StandardButton.No\n\n    def create_alert(self, msg: str):\n        reply = QMessageBox.warning(self, \"Alert\", msg, QMessageBox.StandardButton.Ok)\n        return reply == QMessageBox.StandardButton.Ok\n\n    def show_tab(self):\n        if self.first_show:\n            self.first_show = False\n            return\n\n    def load_file(self):\n        if self.open_file():\n            if self.model_editor:\n                self.scrollable_layout.removeWidget(self.model_editor)\n            self.model_editor = ProfileEditor(self.root)\n            self.scrollable_layout.addWidget(self.model_editor)\n            LOGGER.info(f\"Profile {self.root.name} loaded into profile editor.\")\n\n    def open_file(self):\n        custom_profile_path = IniConfigLoader().user_dir / \"profiles\"\n        file_path, _ = QFileDialog.getOpenFileName(\n            self, \"Open YAML File\", str(custom_profile_path), \"YAML Files (*.yaml *.yml)\"\n        )\n        if file_path:\n            self.file_path = file_path\n            return self.load_yaml()\n        return False\n\n    def load(self):\n        profiles: list[str] = IniConfigLoader().general.profiles\n        custom_profile_path = IniConfigLoader().user_dir / \"profiles\"\n\n        # Try to load last opened profile first\n        last_opened = self.settings.value(\"last_opened_profile\", None, type=str)\n        if last_opened and not self.file_path:\n            custom_file_path = custom_profile_path / f\"{last_opened}.yaml\"\n            if custom_file_path.is_file():\n                self.file_path = custom_file_path\n                return self.load_yaml()\n\n        if not self.file_path and profiles:  # at start, set default file to build in params.ini\n            custom_file_path = custom_profile_path / f\"{profiles[0]}.yaml\"\n            if not custom_file_path.is_file():\n                LOGGER.error(f\"Could not load profile {profiles[0]}. Checked: {custom_file_path}\")\n                return False\n            self.file_path = custom_file_path\n            return self.load_yaml()\n        if not self.file_path and not profiles:\n            return self.open_file()\n        return False\n\n    def create_profile_editor(self):\n        if not self.profile_editor_created and self.root:\n            self.model_editor = ProfileEditor(self.root)\n            self.scrollable_layout.addWidget(self.model_editor)\n            self.profile_editor_created = True\n            LOGGER.info(f\"Profile {self.root.name} loaded into profile editor.\")\n\n    def load_yaml(self):\n        if not self.file_path:\n            LOGGER.debug(\"No profile loaded, cannot refresh.\")\n            return False\n        filename = pathlib.Path(self.file_path).name  # Get the filename from the full path\n        filename_without_extension = filename.rsplit(\".\", 1)[0]  # Remove the extension\n        profile_str = filename_without_extension.replace(\"_\", \" \")  # Replace underscores with spaces\n        self.root = None\n        with pathlib.Path(self.file_path).open(encoding=\"utf-8\") as f:\n            try:\n                config = yaml.load(stream=f, Loader=_UniqueKeyLoader)\n            except Exception as e:\n                LOGGER.error(f\"Error in the YAML file {self.file_path}: {e}\")\n                return False\n            if config is None:\n                LOGGER.error(f\"Empty YAML file {self.file_path}, please remove it\")\n                return False\n            try:\n                self.root = ProfileModel(name=profile_str, **config)\n                self.original_root = copy.deepcopy(self.root)\n                LOGGER.info(f\"File {self.file_path} loaded.\")\n                self.update_filename_label()\n\n                # Save last opened profile\n                self.settings.setValue(\"last_opened_profile\", filename_without_extension)\n\n            except ValidationError as e:\n                if \"minGreaterAffixCount\" in str(e):\n                    error_text = (\n                        f\"PROFILE VALIDATION FAILED: {self.file_path}\\n\\n\"\n                        \"You are using an old, outdated field that must be removed from your profile.\\n\\n\"\n                        \"WRONG (old way - pool level):\\n\"\n                        \"- Ring:\\n\"\n                        \"    itemType: [ring]\\n\"\n                        \"    minPower: 100\\n\"\n                        \"    affixPool:\\n\"\n                        \"    - count:\\n\"\n                        \"      - {name: strength}\\n\"\n                        \"      minCount: 2\\n\"\n                        \"      minGreaterAffixCount: 1  ← DELETE THIS LINE\\n\\n\"\n                        \"CORRECT (new way - item level):\\n\"\n                        \"- Ring:\\n\"\n                        \"    itemType: [ring]\\n\"\n                        \"    minPower: 100\\n\"\n                        \"    minGreaterAffixCount: 1  ← PUT IT HERE INSTEAD\\n\"\n                        \"    affixPool:\\n\"\n                        \"    - count:\\n\"\n                        \"      - {name: strength}\\n\"\n                        \"      minCount: 2\\n\"\n                        \"      # NO minGreaterAffixCount here anymore!\\n\\n\"\n                        f\"ACTION REQUIRED: Please make the above adjustments in:\\n{self.file_path}\"\n                    )\n                    QMessageBox.critical(self, \"Profile Validation Failed\", error_text)\n                else:\n                    QMessageBox.critical(self, \"Validation Error\", f\"Validation error in {self.file_path}:\\n\\n{e}\")\n                return False\n        return True\n\n    def update_filename_label(self):\n        if self.file_path:\n            filename = pathlib.Path(self.file_path).name  # Get the filename from the full path\n            filename_without_extension = filename.rsplit(\".\", 1)[0]  # Remove the extension\n            display_name = filename_without_extension.replace(\"_\", \" \")  # Replace underscores with spaces\n            self.filenameLabel.setText(display_name)\n\n    def save_yaml(self):\n        self.original_root = copy.deepcopy(self.root)\n        self.model_editor.save_all()\n\n    def check_close_save(self):\n        if self.root and self.original_root != self.root:\n            return self.confirm_discard_changes()\n        return True\n\n    def refresh(self):\n        if not self.load_yaml():\n            return\n        self.scrollable_layout.removeWidget(self.model_editor)\n        self.model_editor = ProfileEditor(self.root)\n        self.scrollable_layout.addWidget(self.model_editor)\n        LOGGER.info(f\"Profile {self.root.name} refreshed.\")\n"
  },
  {
    "path": "src/gui/themes.py",
    "content": "\"\"\"Original simple gray theme with dynamic asset paths.\"\"\"\n\nfrom src.config import BASE_DIR\n\n# Convert paths to use forward slashes for Qt\nCHECKMARK_DARK = str(BASE_DIR / \"assets\" / \"checkmark_dark.svg\").replace(\"\\\\\", \"/\")\nCHECKMARK_LIGHT = str(BASE_DIR / \"assets\" / \"checkmark_light.svg\").replace(\"\\\\\", \"/\")\n\n\nDARK_THEME = f\"\"\"\nQWidget {{\n    background-color: #121212;\n    color: #e0e0e0;\n}}\nQPushButton {{\n    background-color: #1f1f1f;\n    border: 1px solid #3c3c3c;\n    border-radius: 5px;\n    padding: 3px 8px;\n    font-size: 14px;\n}}\nQPushButton:hover {{\n    background-color: #2c2c2c;\n    border: 1px solid #5c5c5c;\n}}\nQPushButton:pressed {{\n    background-color: #3c3c3c;\n}}\nQTextEdit {{\n    background-color: #1e1e1e;\n    color: #e0e0e0;\n    border: 1px solid #3c3c3c;\n    border-radius: 5px;\n    padding: 8px;\n}}\nQLineEdit {{\n    background-color: #1e1e1e;\n    color: #e0e0e0;\n    border: 1px solid #3c3c3c;\n    border-radius: 5px;\n    padding: 3px;\n}}\nQTabBar::tab {{\n    background-color: #1f1f1f;\n    color: #e0e0e0;\n    padding: 5px 15px;\n    margin: 2px;\n    border-top-left-radius: 5px;\n    border-top-right-radius: 5px;\n    min-width: 80px;\n}}\nQTabBar::tab:selected {{\n    background-color: #3c3c3c;\n    border: 1px solid #5c5c5c;\n    border-bottom: none;\n    border-top-left-radius: 5px;\n    border-top-right-radius: 5px;\n}}\nQTabBar::tab:hover {{\n    background-color: #2c2c2c;\n    border: 1px solid #5c5c5c;\n}}\nQTabBar::tab:!selected {{\n    margin-top: 3px;\n}}\nQCheckBox {{\n    color: #e0e0e0;\n    spacing: 8px;\n}}\nQCheckBox::indicator {{\n    width: 18px;\n    height: 18px;\n    border: 1px solid #5c5c5c;\n    background-color: #1e1e1e;\n    border-radius: 2px;\n}}\nQCheckBox::indicator:hover {{\n    border: 1px solid #7c7c7c;\n}}\nQCheckBox::indicator:checked {{\n    background-color: #5c5c5c;\n    border: 1px solid #5c5c5c;\n    image: url({CHECKMARK_DARK});\n}}\n\n/* Disabled checkbox styling */\nQCheckBox:disabled {{\n    color: gray;\n}}\nQCheckBox::indicator:disabled {{\n    background-color: #555;\n    border: 1px solid #444;\n}}\n\nQScrollBar:vertical {{\n    background-color: #1f1f1f;\n    width: 16px;\n    margin: 16px 0 16px 0;\n    border: 1px solid #3c3c3c;\n}}\nQScrollBar::handle:vertical {{\n    background-color: #3c3c3c;\n    min-height: 20px;\n    border-radius: 4px;\n}}\nQScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{\n    background-color: #1f1f1f;\n    height: 16px;\n    subcontrol-origin: margin;\n    border: 1px solid #3c3c3c;\n}}\nQScrollBar::add-line:vertical:hover, QScrollBar::sub-line:vertical:hover {{\n    background-color: #3c3c3c;\n}}\nQScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{\n    background: none;\n}}\nQComboBox {{\n    background-color: #1f1f1f;\n    color: #e0e0e0;\n    border: 1px solid #3c3c3c;\n    border-radius: 5px;\n    padding: 3px;\n}}\nQComboBox QAbstractItemView {{\n    background-color: #1f1f1f;\n    color: #e0e0e0;\n    border: 1px solid #3c3c3c;\n    selection-background-color: #3c3c3c;\n}}\nQListWidget {{\n    background-color: #1e1e1e;\n    color: #e0e0e0;\n    border: 1px solid #3c3c3c;\n}}\nQListWidget::item:selected {{\n    background-color: #3c3c3c;\n}}\nQToolTip {{\n    background-color: #1f1f1f;\n    color: #e0e0e0;\n    border: 1px solid #3c3c3c;\n    padding: 3px;\n    border-radius: 5px;\n}}\n\n/* Affix editor / GA helper styling */\nQLabel[greaterCountLabel=\"true\"] {{\n    color: gray;\n    font-style: italic;\n}}\n\nQSpinBox[autoSyncSpin=\"true\"] {{\n    background-color: #3c3c3c;\n    color: #888888;\n}}\n\nQLabel[affixHeaderLabel=\"true\"] {{\n    color: #e0e0e0;\n}}\n\nQCheckBox[greaterCheckbox=\"true\"] {{\n    background-color: transparent;\n}}\n\n/* Hotkey button styling */\nQPushButton[hotkeyButton=\"true\"] {{\n    text-align: left;\n    padding-left: 5px;\n}}\n\"\"\"\n\n\nLIGHT_THEME = f\"\"\"\nQWidget {{\n    background-color: #ededed;\n    color: #1f1f1f;\n}}\nQPushButton {{\n    background-color: #e0e0e0;\n    border: 1px solid #c3c3c3;\n    border-radius: 5px;\n    padding: 3px 8px;\n    font-size: 14px;\n}}\nQPushButton:hover {{\n    background-color: #d3d3d3;\n    border: 1px solid #a3a3a3;\n}}\nQPushButton:pressed {{\n    background-color: #c3c3c3;\n}}\nQTextEdit {{\n    background-color: #e1e1e1;\n    color: #1f1f1f;\n    border: 1px solid #c3c3c3;\n    border-radius: 5px;\n    padding: 8px;\n}}\nQLineEdit {{\n    background-color: #e1e1e1;\n    color: #1f1f1f;\n    border: 1px solid #c3c3c3;\n    border-radius: 5px;\n    padding: 3px;\n}}\nQTabBar::tab {{\n    background-color: #e0e0e0;\n    color: #1f1f1f;\n    padding: 5px 15px;\n    margin: 2px;\n    border-top-left-radius: 5px;\n    border-top-right-radius: 5px;\n    min-width: 80px;\n}}\nQTabBar::tab:selected {{\n    background-color: #c3c3c3;\n    border: 1px solid #a3a3a3;\n    border-bottom: none;\n    border-top-left-radius: 5px;\n    border-top-right-radius: 5px;\n}}\nQTabBar::tab:hover {{\n    background-color: #d3d3d3;\n    border: 1px solid #a3a3a3;\n}}\nQTabBar::tab:!selected {{\n    margin-top: 3px;\n}}\nQCheckBox {{\n    color: #1f1f1f;\n    spacing: 8px;\n}}\nQCheckBox::indicator {{\n    width: 18px;\n    height: 18px;\n    border: 2px solid #5c5c5c;\n    background-color: #ffffff;\n    border-radius: 2px;\n}}\nQCheckBox::indicator:hover {{\n    border: 2px solid #3c3c3c;\n}}\nQCheckBox::indicator:checked {{\n    background-color: #3c3c3c;\n    border: 2px solid #1f1f1f;\n    image: url({CHECKMARK_LIGHT});\n}}\n\n/* Disabled checkbox styling */\nQCheckBox:disabled {{\n    color: gray;\n}}\nQCheckBox::indicator:disabled {{\n    background-color: #555;\n    border: 1px solid #444;\n}}\n\nQScrollBar:vertical {{\n    background-color: #e0e0e0;\n    width: 16px;\n    margin: 16px 0 16px 0;\n    border: 1px solid #c3c3c3;\n}}\nQScrollBar::handle:vertical {{\n    background-color: #c3c3c3;\n    min-height: 20px;\n    border-radius: 4px;\n}}\nQScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{\n    background-color: #e0e0e0;\n    height: 16px;\n    subcontrol-origin: margin;\n    border: 1px solid #c3c3c3;\n}}\nQScrollBar::add-line:vertical:hover, QScrollBar::sub-line:vertical:hover {{\n    background-color: #c3c3c3;\n}}\nQScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{\n    background: none;\n}}\nQComboBox {{\n    background-color: #e0e0e0;\n    color: #1f1f1f;\n    border: 1px solid #c3c3c3;\n    border-radius: 5px;\n    padding: 3px;\n}}\nQComboBox QAbstractItemView {{\n    background-color: #e0e0e0;\n    color: #1f1f1f;\n    border: 1px solid #c3c3c3;\n    selection-background-color: #c3c3c3;\n}}\nQListWidget {{\n    background-color: #e1e1e1;\n    color: #1f1f1f;\n    border: 1px solid #c3c3c3;\n}}\nQListWidget::item:selected {{\n    background-color: #c3c3c3;\n}}\nQToolTip {{\n    background-color: #e0e0e0;\n    color: #1f1f1f;\n    border: 1px solid #c3c3c3;\n    padding: 3px;\n    border-radius: 5px;\n}}\n\n/* Affix editor / GA helper styling */\nQLabel[greaterCountLabel=\"true\"] {{\n    color: gray;\n    font-style: italic;\n}}\n\nQSpinBox[autoSyncSpin=\"true\"] {{\n    background-color: #d3d3d3;\n    color: #555555;\n}}\n\nQLabel[affixHeaderLabel=\"true\"] {{\n    color: #1f1f1f;\n}}\n\nQCheckBox[greaterCheckbox=\"true\"] {{\n    background-color: transparent;\n}}\n\n/* Hotkey button styling */\nQPushButton[hotkeyButton=\"true\"] {{\n    text-align: left;\n    padding-left: 5px;\n}}\n\"\"\"\n"
  },
  {
    "path": "src/gui/unified_window.py",
    "content": "import logging\nimport re\nimport sys\nimport time\nfrom contextlib import suppress\nfrom pathlib import Path\n\nfrom PyQt6.QtCore import QObject, QPoint, QSettings, QSize, Qt, QThread, pyqtSignal\nfrom PyQt6.QtGui import QIcon, QTextCursor\nfrom PyQt6.QtWidgets import (\n    QApplication,\n    QMainWindow,\n    QMessageBox,\n    QPlainTextEdit,\n    QStackedWidget,\n    QTabBar,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom src import __version__, tts\nfrom src.autoupdater import notify_if_update\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.gui.activity_log_widget import ActivityLogWidget\nfrom src.gui.config_window import ConfigWindow\nfrom src.gui.importer_window import ImporterWindow\nfrom src.gui.profile_editor_window import ProfileEditorWindow\nfrom src.gui.themes import DARK_THEME, LIGHT_THEME\nfrom src.item.filter import Filter\nfrom src.logger import ThreadNameFilter, create_formatter\nfrom src.logger import setup as setup_logging\nfrom src.main import check_for_proper_tts_configuration\nfrom src.overlay import Overlay\nfrom src.scripts.handler import ScriptHandler\nfrom src.utils.window import WindowSpec, start_detecting_window\n\nBASE_DIR = (\n    Path(sys.executable).parent if getattr(sys, \"frozen\", False) else Path(__file__).resolve().parent.parent.parent\n)\n\nICON_PATH = BASE_DIR / \"assets\" / \"logo.png\"\n\nLOGGER = logging.getLogger(__name__)\n\nANSI_PATTERN = re.compile(r\"\\x1b\\[(\\d+)(;\\d+)*m\")\n\nANSI_COLORS = {\n    \"30\": \"#000000\",\n    \"31\": \"#AA0000\",\n    \"32\": \"#00AA00\",\n    \"33\": \"#AA5500\",\n    \"34\": \"#0000AA\",\n    \"35\": \"#AA00AA\",\n    \"36\": \"#00AAAA\",\n    \"37\": \"#AAAAAA\",\n    \"90\": \"#555555\",\n    \"91\": \"#FF5555\",\n    \"92\": \"#55FF55\",\n    \"93\": \"#FFFF55\",\n    \"94\": \"#5555FF\",\n    \"95\": \"#FF55FF\",\n    \"96\": \"#55FFFF\",\n    \"97\": \"#FFFFFF\",\n}\n\n\ndef ansi_to_html(text: str) -> str:\n    html = \"\"\n    last_end = 0\n    current_color = None\n\n    for match in ANSI_PATTERN.finditer(text):\n        start, end = match.span()\n        html += text[last_end:start].replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n\n        codes = match.group(0)[2:-1].split(\";\")\n        for code in codes:\n            if code in ANSI_COLORS:\n                current_color = ANSI_COLORS[code]\n            elif code == \"0\":\n                current_color = None\n\n        if current_color:\n            html += f'<span style=\"color:{current_color}\">'\n        else:\n            html += \"</span>\"\n\n        last_end = end\n\n    html += text[last_end:].replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n\n    if current_color:\n        html += \"</span>\"\n\n    return html\n\n\nclass ANSIConsoleWidget(QPlainTextEdit):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setReadOnly(True)\n        self.setStyleSheet(\"background-color: black; color: white; font-family: Consolas, monospace; font-size: 12px;\")\n\n    def append_ansi_text(self, text: str):\n        html = ansi_to_html(text)\n        self.appendHtml(html)\n        self.moveCursor(QTextCursor.MoveOperation.End)\n\n\nclass QtConsoleHandler(logging.Handler, QObject):\n    log_signal = pyqtSignal(str)\n\n    def __init__(self):\n        logging.Handler.__init__(self)\n        QObject.__init__(self)\n\n    def emit(self, record):\n        msg = self.format(record)\n        self.log_signal.emit(msg)\n\n\nclass QtActivityHandler(logging.Handler, QObject):\n    log_signal = pyqtSignal(str)\n\n    def __init__(self):\n        logging.Handler.__init__(self)\n        QObject.__init__(self)\n\n    def emit(self, record):\n        msg = self.format(record)\n        self.log_signal.emit(msg)\n\n\nclass BackendWorker(QObject):\n    finished = pyqtSignal()\n\n    def run(self):\n        Filter().load_files()\n\n        running_from_source = not getattr(sys, \"frozen\", False)\n        if running_from_source:\n            LOGGER.debug(\"Skipping autoupdate check as code is being run from source.\")\n        else:\n            notify_if_update()\n\n        win_spec = WindowSpec(IniConfigLoader().advanced_options.process_name)\n        start_detecting_window(win_spec)\n\n        while not Cam().is_offset_set():\n            time.sleep(0.2)\n\n        time.sleep(0.5)\n\n        ScriptHandler()\n\n        check_for_proper_tts_configuration()\n        tts.start_connection()\n\n        overlay = Overlay()\n        overlay.run()\n\n        self.finished.emit()\n\n\nclass UnifiedMainWindow(QMainWindow):\n    def __init__(self):\n        super().__init__()\n        # Track child windows by type for singleton behavior\n        self._config_window: ConfigWindow | None = None\n        self._importer_window: ImporterWindow | None = None\n        self._profile_editor_window: ProfileEditorWindow | None = None\n\n        if ICON_PATH.exists():\n            self.setWindowIcon(QIcon(str(ICON_PATH)))\n\n        # --- Theme setup ---\n        config = IniConfigLoader()\n        theme_name = getattr(config.general, \"theme\", None) or \"dark\"\n        stylesheet = DARK_THEME if theme_name == \"dark\" else LIGHT_THEME\n        QApplication.instance().setStyleSheet(stylesheet)\n        # --- Logging setup ---\n        running_from_source = not getattr(sys, \"frozen\", False)\n        root_logger = logging.getLogger()\n\n        # Ensure file logging stays enabled. unified_window previously removed all handlers (including the file handler),\n        # which stopped live log writing to d4lf/logs.\n        if not any(getattr(h, \"name\", \"\") == \"D4LF_FILE\" for h in root_logger.handlers):\n            setup_logging(log_level=config.advanced_options.log_lvl.value, enable_stdout=running_from_source)\n\n        # Remove existing handlers, but keep file handler and (optionally) stdout when running from source\n        for h in list(root_logger.handlers):\n            if getattr(h, \"name\", \"\") == \"D4LF_FILE\":\n                continue  # Keep file logging\n            if running_from_source and isinstance(h, logging.StreamHandler) and h.stream.name == \"<stdout>\":\n                continue  # Keep stdout handler for IDE terminal\n            root_logger.removeHandler(h)\n\n        self.console_handler = QtConsoleHandler()\n        self.console_handler.setFormatter(create_formatter(colored=True))\n        self.console_handler.setLevel(config.advanced_options.log_lvl.value.upper())\n        self.console_handler.addFilter(ThreadNameFilter())\n\n        self.activity_handler = QtActivityHandler()\n        activity_formatter = logging.Formatter(\"%(message)s\")\n        self.activity_handler.setFormatter(activity_formatter)\n        self.activity_handler.setLevel(logging.INFO)\n\n        root_logger.addHandler(self.console_handler)\n        root_logger.addHandler(self.activity_handler)\n        root_logger.setLevel(config.advanced_options.log_lvl.value.upper())\n\n        # --- Window setup: version in title bar ---\n        self.setWindowTitle(f\"D4LF - Diablo 4 Loot Filter v{__version__}\")\n        self.setMinimumSize(800, 600)\n\n        central = QWidget()\n        layout = QVBoxLayout(central)\n\n        # ActivityLogWidget is the whole page (with buttons + hotkeys)\n        self.activity_tab = ActivityLogWidget(parent=self)\n        layout.addWidget(self.activity_tab)\n        self.setCentralWidget(central)\n\n        # --- Build console widget and inject stack into ActivityLogWidget ---\n        # 1) Build console widget\n        self.console_output = ANSIConsoleWidget()\n\n        # 2) Get the layout of ActivityLogWidget\n        act_layout = self.activity_tab.layout()\n\n        # 3) Find the index of the existing log_viewer\n        #    (the little log box under \"Activity Log:\")\n        idx = act_layout.indexOf(self.activity_tab.log_viewer)\n\n        # 4) Remove the original log_viewer from layout\n        act_layout.removeWidget(self.activity_tab.log_viewer)\n\n        # 5) Create a stacked widget that holds:\n        #    - original log_viewer\n        #    - console_output\n        self.log_stack = QStackedWidget()\n        self.log_stack.addWidget(self.activity_tab.log_viewer)  # index 0: Log View\n        self.log_stack.addWidget(self.console_output)  # index 1: Console View\n\n        # 6) Insert the stack back where the log_viewer was\n        act_layout.insertWidget(idx, self.log_stack)\n\n        # 7) Create a small tab bar for Log / Console and put it just above the stack\n        self.log_tabbar = QTabBar()\n        self.log_tabbar.addTab(\"Log View\")\n        self.log_tabbar.addTab(\"Console View\")\n\n        # Insert the tabbar just before the stack\n        act_layout.insertWidget(idx, self.log_tabbar)\n\n        # 8) Wire tabbar to stacked widget\n        self.log_tabbar.currentChanged.connect(self.log_stack.setCurrentIndex)\n\n        # --- Logging connections ---\n        # Console handler → console_output\n        self.console_handler.log_signal.connect(self.console_output.append_ansi_text)\n        # Activity handler → original log_viewer\n        self.activity_handler.log_signal.connect(self.activity_tab.log_viewer.appendPlainText)\n\n        # --- Startup banner ---\n        self.emit_startup_direct_to_console()\n\n        self._emit_deferred_config_cleanup_logs(config)\n\n        # --- Backend worker thread ---\n        self.thread = QThread()\n        self.worker = BackendWorker()\n        self.worker.moveToThread(self.thread)\n\n        self.thread.started.connect(self.worker.run)\n        self.worker.finished.connect(self.thread.quit)\n\n        # --- Final setup ---\n        self.restore_geometry()\n        self.thread.start()\n\n    def _show_singleton_modal(self, window_attr: str, window_class, *args, **kwargs):\n        \"\"\"Helper to show a singleton modal window.\n\n        If window already exists and is visible, bring it to front.\n        Otherwise create a new one.\n        \"\"\"\n        existing_window = getattr(self, window_attr)\n\n        # If window exists and is visible, just bring it to front\n        if existing_window is not None and existing_window.isVisible():\n            existing_window.raise_()\n            existing_window.activateWindow()\n            return existing_window\n\n        # Create new window\n        win = window_class(*args, **kwargs)\n        win.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)\n\n        # Make it modal\n        win.setWindowModality(Qt.WindowModality.ApplicationModal)\n\n        # Track the window\n        setattr(self, window_attr, win)\n\n        # Clear reference when window is destroyed\n        def on_destroyed():\n            setattr(self, window_attr, None)\n\n        win.destroyed.connect(on_destroyed)\n\n        win.show()\n        return win\n\n    def _emit_deferred_config_cleanup_logs(self, config):\n        for record in config.consume_deferred_cleanup_log_records():\n            if not logging.getLogger(record.name).isEnabledFor(record.levelno):\n                continue\n            if record.levelno >= self.console_handler.level:\n                self.console_handler.handle(record)\n            if record.levelno >= self.activity_handler.level:\n                self.activity_handler.handle(record)\n\n    def open_import_dialog(self):\n        try:\n            self._show_singleton_modal(\"_importer_window\", ImporterWindow)\n        except Exception as e:\n            LOGGER.error(f\"Failed to open importer: {e}\")\n            QMessageBox.critical(self, \"Import Error\", str(e))\n\n    def open_settings_dialog(self):\n        try:\n            self._show_singleton_modal(\"_config_window\", ConfigWindow, theme_changed_callback=self.apply_theme)\n        except Exception as e:\n            LOGGER.error(f\"Failed to open settings: {e}\")\n            QMessageBox.critical(self, \"Settings Error\", str(e))\n\n    def open_profile_editor(self):\n        try:\n            self._show_singleton_modal(\"_profile_editor_window\", ProfileEditorWindow)\n        except Exception as e:\n            LOGGER.error(f\"Failed to open profile editor: {e}\")\n\n    def restore_geometry(self):\n\n        settings = QSettings(\"d4lf\", \"mainwindow\")\n\n        size = settings.value(\"size\", QSize(1000, 800))\n        pos = settings.value(\"pos\", QPoint(100, 100))\n        maximized = settings.value(\"maximized\", \"false\") == \"true\"\n\n        self.resize(size)\n        self.move(pos)\n\n        if maximized:\n            self.showMaximized()\n\n        selected = settings.value(\"selected_view\", 0, int)\n        self.log_tabbar.setCurrentIndex(selected)\n        self.log_stack.setCurrentIndex(selected)\n\n    def save_geometry(self):\n        settings = QSettings(\"d4lf\", \"mainwindow\")\n\n        if not self.isMaximized():\n            settings.setValue(\"size\", self.size())\n            settings.setValue(\"pos\", self.pos())\n\n        settings.setValue(\"maximized\", self.isMaximized())\n        settings.setValue(\"selected_view\", self.log_tabbar.currentIndex())\n\n    def closeEvent(self, event):\n        # Close all child windows\n        for window_attr in (\"_config_window\", \"_importer_window\", \"_profile_editor_window\"):\n            win = getattr(self, window_attr)\n            if win is not None:\n                with suppress(Exception):\n                    win.close()\n\n        # --- Existing behavior ---\n        self.save_geometry()\n\n        root_logger = logging.getLogger()\n\n        with suppress(Exception):\n            root_logger.removeHandler(self.console_handler)\n            root_logger.removeHandler(self.activity_handler)\n\n        with suppress(Exception):\n            logging._handlerList.clear()\n\n        super().closeEvent(event)\n\n    def emit_startup_direct_to_console(self):\n        banner = (\n            \"═══════════════════════════════════════════════════════════════════════════════\\n\"\n            \"D4LF - Diablo 4 Loot Filter\\n\"\n            \"═══════════════════════════════════════════════════════════════════════════════\"\n        )\n\n        self.console_output.appendPlainText(banner)\n        self.console_output.appendPlainText(\"\")  # one blank line for spacing\n\n    def apply_theme(self):\n        theme_name = IniConfigLoader().general.theme\n        stylesheet = DARK_THEME if theme_name == \"dark\" else LIGHT_THEME\n        QApplication.instance().setStyleSheet(stylesheet)\n"
  },
  {
    "path": "src/item/__init__.py",
    "content": ""
  },
  {
    "path": "src/item/data/__init__.py",
    "content": ""
  },
  {
    "path": "src/item/data/affix.py",
    "content": "import enum\nfrom dataclasses import dataclass\n\n\nclass AffixType(enum.Enum):\n    greater = enum.auto()\n    inherent = enum.auto()\n    normal = enum.auto()\n    rerolled = enum.auto()\n    tempered = enum.auto()\n\n\n@dataclass\nclass Affix:\n    loc: tuple[int, int] = None\n    max_value: float | None = None\n    min_value: float | None = None\n    name: str = \"\"\n    text: str = \"\"\n    type: AffixType = AffixType.normal\n    value: float | None = None\n\n    def __eq__(self, other: Affix) -> bool:\n        if not isinstance(other, Affix):\n            return False\n        return (\n            self.max_value == other.max_value\n            and self.min_value == other.min_value\n            and self.name == other.name\n            and self.value == other.value\n            and self.type == other.type\n        )\n"
  },
  {
    "path": "src/item/data/aspect.py",
    "content": "from dataclasses import dataclass\n\n\n@dataclass\nclass Aspect:\n    name: str\n    loc: tuple[int, int] = None\n    min_value: float = None\n    max_value: float = None\n    text: str = \"\"\n    value: float = None\n\n    def __eq__(self, other: Aspect) -> bool:\n        if not isinstance(other, Aspect):\n            return False\n        return self.name == other.name and self.value == other.value\n"
  },
  {
    "path": "src/item/data/item_type.py",
    "content": "from enum import Enum\n\n\n# The values will be overwritten depending on which language is loaded\nclass ItemType(Enum):\n    Amulet = \"amulet\"\n    Axe = \"axe\"\n    Axe2H = \"two-handed axe\"\n    Boots = \"boots\"\n    Bow = \"bow\"\n    ChestArmor = \"chest armor\"\n    Crossbow2H = \"crossbow\"\n    Dagger = \"dagger\"\n    Elixir = \"elixir\"\n    Flail = \"flail\"\n    Focus = \"focus\"\n    Glaive = \"glaive\"\n    Gloves = \"gloves\"\n    Helm = \"helm\"\n    Legs = \"pants\"\n    Mace = \"mace\"\n    Mace2H = \"two-handed mace\"\n    OffHandTotem = \"totem\"\n    Polearm = \"polearm\"\n    Quarterstaff = \"quarterstaff\"\n    Ring = \"ring\"\n    Scythe = \"scythe\"\n    Scythe2H = \"two-handed scythe\"\n    Shield = \"shield\"\n    Staff = \"staff\"\n    Sword = \"sword\"\n    Sword2H = \"two-handed sword\"\n    Tome = \"tome\"\n    Wand = \"wand\"\n    # Seals and charms\n    HoradricSeal = \"horadric seal\"\n    Charm = \"charm\"\n    # Custom Types\n    Cache = \"cache\"\n    Compass = \"compass\"\n    Consumable = \"consumable\"\n    Cosmetic = \"cosmetic\"\n    EscalationSigil = \"escalation sigil\"\n    Gem = \"gem\"\n    Incense = \"incense\"\n    LairBossKey = \"lairbosskey\"\n    Material = \"material\"\n    Rune = \"rune\"\n    Sigil = \"nightmare sigil\"\n    TemperManual = \"temper manual\"\n    Tribute = \"tribute\"\n    WhisperingWood = \"whispering wood\"\n\n\ndef is_armor(item_type: ItemType) -> bool:\n    return item_type in [\n        ItemType.Boots,\n        ItemType.ChestArmor,\n        ItemType.Gloves,\n        ItemType.Helm,\n        ItemType.Legs,\n        ItemType.Shield,\n    ]\n\n\ndef is_consumable(item_type: ItemType) -> bool:\n    return item_type in [ItemType.Consumable, ItemType.Elixir, ItemType.Incense, ItemType.TemperManual]\n\n\ndef is_non_sigil_mapping(item_type: ItemType) -> bool:\n    return item_type in [ItemType.Compass, ItemType.WhisperingWood]\n\n\ndef is_sigil(item_type: ItemType) -> bool:\n    return item_type in [ItemType.Sigil, ItemType.EscalationSigil]\n\n\ndef is_jewelry(item_type: ItemType) -> bool:\n    return item_type in [ItemType.Amulet, ItemType.Ring]\n\n\ndef is_socketable(item_type: ItemType) -> bool:\n    return item_type in [ItemType.Gem, ItemType.Rune]\n\n\ndef is_weapon(item_type: ItemType) -> bool:\n    return item_type in WEAPON_TYPES\n\n\nWEAPON_TYPES = [\n    ItemType.Axe,\n    ItemType.Axe2H,\n    ItemType.Bow,\n    ItemType.Crossbow2H,\n    ItemType.Dagger,\n    ItemType.Flail,\n    ItemType.Focus,\n    ItemType.Glaive,\n    ItemType.Mace,\n    ItemType.Mace2H,\n    ItemType.OffHandTotem,\n    ItemType.Polearm,\n    ItemType.Quarterstaff,\n    ItemType.Scythe,\n    ItemType.Scythe2H,\n    ItemType.Staff,\n    ItemType.Sword,\n    ItemType.Sword2H,\n    ItemType.Tome,\n    ItemType.Wand,\n]\n"
  },
  {
    "path": "src/item/data/rarity.py",
    "content": "from enum import Enum\n\n\nclass ItemRarity(Enum):\n    Common = \"common\"\n    Legendary = \"legendary\"\n    Magic = \"magic\"\n    Mythic = \"mythic\"\n    Rare = \"rare\"\n    Unique = \"unique\"\n"
  },
  {
    "path": "src/item/data/seasonal_attribute.py",
    "content": "from enum import Enum\n\n\nclass SeasonalAttribute(Enum):\n    bloodied = \"bloodied\"\n    sanctified = \"sanctified\"\n"
  },
  {
    "path": "src/item/descr/__init__.py",
    "content": "def keep_letters_and_spaces(text):\n    return \"\".join(char for char in text if char.isalpha() or char.isspace()).strip().replace(\"  \", \" \")\n"
  },
  {
    "path": "src/item/descr/read_descr_tts.py",
    "content": "import copy\nimport logging\nimport re\nfrom typing import TYPE_CHECKING\n\nimport rapidfuzz\n\nimport src.tts\nfrom src import TP\nfrom src.dataloader import Dataloader\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import (\n    ItemType,\n    is_armor,\n    is_consumable,\n    is_jewelry,\n    is_non_sigil_mapping,\n    is_sigil,\n    is_socketable,\n    is_weapon,\n)\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.data.seasonal_attribute import SeasonalAttribute\nfrom src.item.descr import keep_letters_and_spaces\nfrom src.item.descr.text import find_number\nfrom src.item.descr.texture import find_affix_bullets, find_aspect_bullet, find_seperator_short, find_seperators_long\nfrom src.item.models import Item\nfrom src.scripts import correct_name\nfrom src.tts import ItemIdentifiers\nfrom src.utils.window import screenshot\n\nif TYPE_CHECKING:\n    import numpy as np\n\n    from src.template_finder import TemplateMatch\n\n_AFFIX_RE = re.compile(\n    r\"(?P<affixvalue1>[0-9]+)[^0-9]+\\[(?P<minvalue1>[0-9]+) - (?P<maxvalue1>[0-9]+)]|\"\n    r\"(?P<affixvalue2>[0-9]+\\.[0-9]+).+?\\[(?P<minvalue2>[0-9]+\\.[0-9]+) - (?P<maxvalue2>[0-9]+\\.[0-9]+)]|\"\n    r\"(?P<affixvalue3>[.0-9]+)[^0-9]+\\[(?P<onlyvalue>[.0-9]+)]|\"\n    r\".?![^\\[\\]]*[\\[\\]](?P<affixvalue4>\\d+.?:\\.\\d+?)(?P<greateraffix1>[ ]*)|\"\n    r\"(?P<greateraffix2>[0-9]+[.0-9]*)(?![^\\[]*\\[).*\",\n    re.DOTALL,\n)\n\n_ASPECT_RE = re.compile(\n    r\"(?P<affixvalue>[0-9]+[.]?[0-9]*)[^0-9]+\\[(?P<minvalue>[0-9]+[.]?[0-9]*)\"\n    r\" - (?P<maxvalue>[0-9]+[.]?[0-9]*)]\"\n)\n\n_FOR_SECONDS_RE = re.compile(r\"for (?P<forsecondsvalue>\\d+(?:\\.\\d+)?) Seconds\")\n\n_REPLACE_COMPARE_RE = re.compile(r\"\\(.*\\)\")\n\n_AFFIX_REPLACEMENTS = [\"%\", \"+\", \",\", \"[+]\", \"[x]\", \"per 5 Seconds\"]\nLOGGER = logging.getLogger(__name__)\n\n\n# Returns a tuple with the number of affixes.  It's in the format (inherent_num, affixes_num)\ndef _get_affix_counts(tts_section: list[str], item: Item, start: int) -> tuple[int, int]:\n    inherent_num = 0\n    affixes_num = 4\n    # We assume these objects have the minimum number of affixes and then try to determine if they have more.\n    if item.rarity == ItemRarity.Rare:\n        affixes_num = 3\n    elif item.rarity == ItemRarity.Magic:\n        affixes_num = 1\n    elif item.rarity == ItemRarity.Common:\n        affixes_num = 0\n\n    if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic]:\n        # Uniques can have variable amounts of inherents.\n        unique_inherents = Dataloader().aspect_unique_dict.get(item.name)[\"num_inherents\"]\n        if unique_inherents is not None:\n            inherent_num = unique_inherents\n\n    # Rares have either 3 or 4 affixes so we have to do special handling to figure out where exactly the affixes end.\n    # This will also grab up slotted gems but we really don't have much choice\n    if item.rarity in [ItemRarity.Magic, ItemRarity.Rare] and not any(\n        tts_section[start + inherent_num + affixes_num].lower().startswith(x)\n        for x in [\"empty socket\", \"requires level\", \"properties lost when equipped\", \"rampage:\", \"feast:\", \"hunger:\"]\n    ):\n        affixes_num = affixes_num + 1\n    elif item.rarity == ItemRarity.Legendary and tts_section[start + inherent_num + affixes_num - 1].lower().startswith(\n        \"imprinted:\"\n    ):\n        # Additionally, if someone imprinted a 3 affix rare we'd think it was a legendary so we need to catch those here\n        affixes_num = 3\n\n    if item.seasonal_attribute == SeasonalAttribute.bloodied:\n        affixes_num = affixes_num + 1\n\n    return inherent_num, affixes_num\n\n\ndef _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item:\n    starting_index = _get_affix_starting_location_from_tts_section(tts_section, item)\n    inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index)\n    affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num)\n    aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes))\n    for i, affix_text in enumerate(affixes):\n        if i < inherent_num:\n            affix = _get_affix_from_text(affix_text)\n            affix.type = AffixType.inherent\n            item.inherent.append(affix)\n        elif i < inherent_num + affixes_num:\n            affix = _get_affix_from_text(affix_text)\n            item.affixes.append(affix)\n\n    if aspect_text:\n        if item.rarity == ItemRarity.Mythic:\n            item.aspect = Aspect(name=item.name, text=aspect_text, value=find_number(aspect_text))\n        elif item.rarity == ItemRarity.Unique:\n            item.aspect = _get_aspect_from_text(aspect_text, item.name)\n        else:\n            item.aspect = _get_aspect_from_name(aspect_text, item.name)\n    return item\n\n\ndef _add_affixes_from_tts_mixed(\n    tts_section: list[str],\n    item: Item,\n    affix_bullets: list[TemplateMatch],\n    img_item_descr: np.ndarray,\n    aspect_bullet: TemplateMatch | None,\n) -> Item:\n    starting_index = _get_affix_starting_location_from_tts_section(tts_section, item)\n    inherent_num, affixes_num = _get_affix_counts(tts_section, item, starting_index)\n    affixes = _get_affixes_from_tts_section(tts_section, starting_index, inherent_num + affixes_num)\n    aspect_text = _get_aspect_from_tts_section(tts_section, item, starting_index, len(affixes))\n\n    # With advanced item compare on we'll actually find more bullets than we need, so we don't rely on them for\n    # number of affixes\n    if len(affixes) > len(affix_bullets):\n        _raise_index_error(affixes, affix_bullets, item, img_item_descr)\n\n    for i, affix_text in enumerate(affixes):\n        if i < inherent_num:\n            affix = _get_affix_from_text(affix_text)\n            affix.type = AffixType.inherent\n            affix.loc = affix_bullets[i].center\n            item.inherent.append(affix)\n        elif i < inherent_num + affixes_num:\n            affix = _get_affix_from_text(affix_text)\n            affix.loc = affix_bullets[i].center\n            if affix_bullets[i].name.startswith(\"greater_affix\"):\n                affix.type = AffixType.greater\n            elif affix_bullets[i].name.startswith(\"rerolled\"):\n                affix.type = AffixType.rerolled\n            else:\n                affix.type = AffixType.normal\n            item.affixes.append(affix)\n\n    if aspect_text:\n        if item.rarity == ItemRarity.Mythic:\n            item.aspect = Aspect(name=item.name, text=aspect_text, value=find_number(aspect_text))\n        elif item.rarity == ItemRarity.Unique:\n            item.aspect = _get_aspect_from_text(aspect_text, item.name)\n        else:\n            item.aspect = _get_aspect_from_name(aspect_text, item.name)\n        if item.aspect and aspect_bullet:\n            item.aspect.loc = aspect_bullet.center\n    return item\n\n\ndef _raise_index_error(affixes, affix_bullets, item, img_item_descr: np.ndarray):\n    LOGGER.error(\"About to raise index error, dumping information for debug:\")\n    LOGGER.error(f\"Affixes ({len(affixes)}): {affixes}\")\n    LOGGER.error(f\"Affix Bullets ({len(affix_bullets)}): {affix_bullets}\")\n    LOGGER.error(f\"Item: {item}\")\n    LOGGER.error(\"Placed screenshot of item in screenshot folder. Screenshot will start with 'not_enough_bullets'\")\n    screenshot(\"not_enough_bullets\", img=img_item_descr)\n\n    msg = (\n        \"Found more affixes than we found bullets to represent those affixes. \"\n        \"This could be a temporary issue finding bullet positions on the screen, \"\n        \"but if it happens consistently please open a bug report with a full screen \"\n        \"screenshot with the item hovered on and vision mode disabled. Additionally, \"\n        \"include the ~10 log lines above this message and the screenshot in the screenshot folder.\"\n    )\n    raise IndexError(msg)\n\n\ndef _add_sigil_affixes_from_tts(tts_section: list[str], item: Item) -> Item:\n    name_index = (\n        3 if item.item_type == ItemType.EscalationSigil or item.seasonal_attribute == SeasonalAttribute.bloodied else 2\n    )\n    name = tts_section[name_index].split(\" in \")[0]\n    item.name = correct_name(name)\n\n    start = next((i for i, s in enumerate(tts_section) if \"AFFIXES\" in s), None)\n    if start:\n        first_affix_index = start + 1\n        second_affix_index = start + 3\n    else:\n        msg = f\"Could not find string AFFIXES in TTS provided by Diablo. Sigil filtering may be unstable, please open a bug with this info: {tts_section}\"\n        LOGGER.error(msg)\n        first_affix_index = 4\n        second_affix_index = 6\n\n    affixes = [tts_section[first_affix_index], tts_section[second_affix_index]]\n\n    for affix_name in affixes:\n        affix = Affix(name=correct_name(keep_letters_and_spaces(affix_name)))\n        affix.type = AffixType.normal\n        item.affixes.append(affix)\n\n    return item\n\n\ndef _create_base_item_from_tts(tts_item: list[str]) -> Item | None:\n    item = Item(original_name=tts_item[0])\n    if tts_item[1].endswith(ItemIdentifiers.COMPASS.value):\n        return _update_item_object(item, rarity=ItemRarity.Common, item_type=ItemType.Compass)\n    if ItemIdentifiers.NIGHTMARE_SIGIL.value.upper() in tts_item[0].upper():\n        if \"Nightmare Sigil is used\" in tts_item[0]:  # This is actually the crafting screen\n            return None\n        if \"bloodied\" in tts_item[1].lower():\n            item.seasonal_attribute = SeasonalAttribute.bloodied\n        return _update_item_object(item, rarity=ItemRarity.Common, item_type=ItemType.Sigil)\n    if tts_item[0].startswith(ItemIdentifiers.ESCALATION_SIGIL.value):\n        return _update_item_object(item, rarity=ItemRarity.Common, item_type=ItemType.EscalationSigil)\n    if ItemIdentifiers.TRIBUTE.value in tts_item[0]:\n        item.item_type = ItemType.Tribute\n        search_string_split = tts_item[1].split(\" \")\n        item.rarity = _get_item_rarity(search_string_split[0])\n        item.name = correct_name(\" \".join(search_string_split[1:]))\n        return item\n    if tts_item[0].startswith(ItemIdentifiers.WHISPERING_KEY.value):\n        return _update_item_object(item, item_type=ItemType.Consumable)\n    if any(tts_item[1].lower().endswith(x) for x in [\"summoning\"]):\n        return _update_item_object(item, item_type=ItemType.Material)\n    if any(tts_item[1].lower().endswith(x) for x in [\"gem\"]):\n        return _update_item_object(item, item_type=ItemType.Gem)\n    if any(tts_item[1].lower().endswith(x) for x in [\"whispering wood\"]):\n        return _update_item_object(item, item_type=ItemType.WhisperingWood)\n    if any(tts_item[1].lower().startswith(x) for x in [\"cosmetic\"]):\n        return _update_item_object(item, item_type=ItemType.Cosmetic)\n    if any(tts_item[1].lower().endswith(x) for x in [\"boss key\"]):\n        return _update_item_object(item, item_type=ItemType.LairBossKey)\n    if \"rune of\" in tts_item[1].lower():\n        item.item_type = ItemType.Rune\n        search_string_split = tts_item[1].lower().split(\" rune of \")\n        item.rarity = _get_item_rarity(search_string_split[0])\n        return item\n    if any(\"Cost : \" in value or \"Cost:\" in value for value in tts_item):\n        item.is_in_shop = True\n    if any(tts_item[1].lower().endswith(x) for x in [\"cache\"]):\n        item.item_type = ItemType.Cache\n        return item\n    if tts_item[1].lower().endswith(\"elixir\"):\n        item.item_type = ItemType.Elixir\n    elif tts_item[1].lower().endswith(\"incense\"):\n        item.item_type = ItemType.Incense\n    elif \"temper manual\" in tts_item[1].lower():\n        item.item_type = ItemType.TemperManual\n    elif any(tts_item[1].lower().endswith(x) for x in [\"consumable\", \"scroll\"]):\n        item.item_type = ItemType.Consumable\n    if is_consumable(item.item_type):\n        search_string_split = tts_item[1].split(\" \")\n        item.rarity = _get_item_rarity(search_string_split[0])\n        return item\n\n    if \"bloodied\" in tts_item[1].lower():\n        item.seasonal_attribute = SeasonalAttribute.bloodied\n\n    # Check lines 3-6 instead of just line 4 (handles variable name lengths and gives us flexibility to search for the sanctified marker)\n    if any(\"sanctified\" in tts_item[i].lower() for i in range(3, min(7, len(tts_item)))):\n        item.seasonal_attribute = SeasonalAttribute.sanctified\n\n    search_string = tts_item[1].lower().replace(\"ancestral\", \"\").replace(\"bloodied\", \"\").strip()\n    search_string = _REPLACE_COMPARE_RE.sub(\"\", search_string).strip()\n    search_string_split = search_string.split(\" \")\n    item.rarity = _get_item_rarity(search_string_split[0])\n    starting_item_type_index = 1\n    if item.rarity == ItemRarity.Mythic:\n        starting_item_type_index = 2\n    elif item.rarity == ItemRarity.Common:\n        starting_item_type_index = 0\n    item.item_type = _get_item_type(\" \".join(search_string_split[starting_item_type_index:]))\n    item.name = correct_name(tts_item[0])\n    if item.name in Dataloader().bad_tts_uniques:\n        item.name = Dataloader().bad_tts_uniques[item.name]\n    for line in tts_item:\n        if \"item power\" in line.lower():\n            item_power = find_number(line)\n            if item_power is None:\n                return None\n            item.power = int(item_power)\n            break\n    return item\n\n\ndef _update_item_object(item: Item, rarity=None, item_type=None) -> Item:\n    if rarity:\n        item.rarity = rarity\n    if item_type:\n        item.item_type = item_type\n\n    return item\n\n\ndef _get_affix_starting_location_from_tts_section(tts_section: list[str], item: Item) -> int:\n    start = 0\n\n    if is_weapon(item.item_type):\n        start = _get_index_of_armor_dps_or_all_resist(tts_section, \"damage per second\") + 2\n    elif is_jewelry(item.item_type):\n        start = _get_index_of_armor_dps_or_all_resist(tts_section, \"all resist\")\n    elif item.item_type == ItemType.Shield:\n        start = _get_index_of_armor_dps_or_all_resist(tts_section, \"armor\") + 2\n    elif is_armor(item.item_type):\n        start = _get_index_of_armor_dps_or_all_resist(tts_section, \"armor\")\n    start += 1\n\n    return start\n\n\ndef _get_index_of_armor_dps_or_all_resist(tts_section: list[str], indicator: str) -> int:\n    for i, line in enumerate(tts_section):\n        if indicator == keep_letters_and_spaces(_REPLACE_COMPARE_RE.sub(\"\", line.lower())).strip():\n            return i\n\n    return 0\n\n\ndef _get_affixes_from_tts_section(tts_section: list[str], start: int, length: int):\n    return tts_section[start : start + length]\n\n\ndef _get_aspect_from_tts_section(tts_section: list[str], item: Item, start: int, num_affixes: int):\n    # Grab the aspect as well in this case\n    if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique, ItemRarity.Legendary]:\n        aspect_index = start + num_affixes\n        return tts_section[aspect_index]\n\n    return None\n\n\ndef _get_affix_from_text(text: str) -> Affix:\n    result = Affix(text=text)\n    for x in _AFFIX_REPLACEMENTS:\n        text = text.replace(x, \"\")\n    text = _REPLACE_COMPARE_RE.sub(\"\", text).strip()\n\n    # A semi-hacky way to handle \"for X Seconds\", which will get read as a GA if we do nothing\n    for_seconds_matches = _FOR_SECONDS_RE.findall(text)\n    for for_seconds_match in for_seconds_matches:\n        for x in [f\"for {for_seconds_match} Seconds\", f\"[{for_seconds_match}]\"]:\n            text = text.replace(x, \"\")\n\n    matched_groups = {}\n    for match in _AFFIX_RE.finditer(text):\n        matched_groups = {name: value for name, value in match.groupdict().items() if value is not None}\n    if not matched_groups and _has_numbers(text):\n        msg = f\"Could not match affix text: {text}\"\n        raise Exception(msg)\n    for x in [\"minvalue1\", \"minvalue2\"]:\n        if matched_groups.get(x) is not None:\n            result.min_value = float(matched_groups[x])\n            break\n    for x in [\"maxvalue1\", \"maxvalue2\"]:\n        if matched_groups.get(x) is not None:\n            result.max_value = float(matched_groups[x])\n            break\n    for x in [\"affixvalue1\", \"affixvalue2\", \"affixvalue3\", \"affixvalue4\"]:\n        if matched_groups.get(x) is not None:\n            result.value = float(matched_groups[x])\n            break\n    for x in [\"greateraffix1\", \"greateraffix2\"]:\n        if matched_groups.get(x) is not None:\n            result.type = AffixType.greater\n            if x == \"greateraffix2\":\n                result.value = float(matched_groups[x])\n            break\n    if matched_groups.get(\"onlyvalue\") is not None:\n        result.min_value = float(matched_groups.get(\"onlyvalue\"))\n        result.max_value = float(matched_groups.get(\"onlyvalue\"))\n    result.name = rapidfuzz.process.extractOne(\n        keep_letters_and_spaces(_REPLACE_COMPARE_RE.sub(\"\", result.text).strip()),\n        list(Dataloader().affix_dict),\n        scorer=rapidfuzz.distance.Levenshtein.distance,\n    )[0]\n    return result\n\n\ndef _has_numbers(affix_text):\n    return any(char.isdigit() for char in affix_text)\n\n\n# For unique aspects\ndef _get_aspect_from_text(text: str, name: str) -> Aspect:\n    result = Aspect(text=text, name=name)\n    for x in _AFFIX_REPLACEMENTS:\n        text = text.replace(x, \"\")\n    text = _REPLACE_COMPARE_RE.sub(\"\", text).strip()\n\n    match = _ASPECT_RE.search(text)\n    if match:  # No match means the aspect is text only, there are no values to filter on\n        matched_groups = {name: value for name, value in match.groupdict().items() if value is not None}\n        if not matched_groups:\n            msg = f\"Could not match aspect text: {text}\"\n            raise Exception(msg)\n\n        if matched_groups.get(\"minvalue\") is not None:\n            result.min_value = float(matched_groups[\"minvalue\"])\n        if matched_groups.get(\"maxvalue\") is not None:\n            result.max_value = float(matched_groups[\"maxvalue\"])\n        if matched_groups.get(\"affixvalue\") is not None:\n            result.value = float(matched_groups[\"affixvalue\"])\n\n    return result\n\n\n# For legendary aspects\ndef _get_aspect_from_name(text: str, name: str) -> Aspect | None:\n    for aspect_name in Dataloader().aspect_list:\n        if aspect_name in name:\n            return Aspect(text=text, name=aspect_name)\n\n    LOGGER.warning(f\"Could not find an aspect representing {name} in our data.\")\n    return None\n\n\ndef _get_item_rarity(data: str) -> ItemRarity | None:\n    return next((rar for rar in ItemRarity if rar.value == data.lower()), ItemRarity.Common)\n\n\ndef _get_item_type(data: str):\n    return next((it for it in ItemType if it.value == data.lower()), None)\n\n\ndef _is_codex_upgrade(tts_section: list[str]) -> bool:\n    return any(\n        \"upgrades an aspect in the codex of power\" in line.lower() or \"unlocks new aspect\" in line.lower()\n        for line in tts_section\n    )\n\n\ndef _is_cosmetic_upgrade(tts_section: list[str]):\n    return any(\"unlocks new look on salvage\" in line.lower() for line in tts_section)\n\n\ndef read_descr_mixed(img_item_descr: np.ndarray) -> Item | None:\n    tts_section = copy.copy(src.tts.LAST_ITEM)\n    if not tts_section:\n        return None\n    if (item := _create_base_item_from_tts(tts_section)) is None:\n        return None\n    if any([\n        is_consumable(item.item_type),\n        is_non_sigil_mapping(item.item_type),\n        is_sigil(item.item_type),\n        is_socketable(item.item_type),\n        item.item_type in [ItemType.Material, ItemType.Tribute],\n    ]):\n        return item\n    if all([not is_armor(item.item_type), not is_jewelry(item.item_type), not is_weapon(item.item_type)]):\n        return None\n\n    if (sep_short_match := find_seperator_short(img_item_descr)) is None:\n        LOGGER.warning(\"Could not detect item_seperator_short.\")\n        screenshot(\"failed_seperator_short\", img=img_item_descr)\n        return None\n    futures = {\n        \"sep_long\": TP.submit(find_seperators_long, img_item_descr, sep_short_match),\n        \"aspect_bullet\": (\n            TP.submit(find_aspect_bullet, img_item_descr, sep_short_match)\n            if item.rarity in [ItemRarity.Legendary, ItemRarity.Unique, ItemRarity.Mythic]\n            else None\n        ),\n    }\n\n    affix_bullets = find_affix_bullets(img_item_descr, sep_short_match)\n\n    if item.rarity == ItemRarity.Unique and item.name not in Dataloader().aspect_unique_dict:\n        msg = (\n            f\"Unrecognized unique {item.name}. This most likely means the name of it reported \"\n            f\"from Diablo 4 is wrong. Please report a bug with this message.\"\n        )\n        raise IndexError(msg)\n\n    item.codex_upgrade = _is_codex_upgrade(tts_section)\n    item.cosmetic_upgrade = _is_cosmetic_upgrade(tts_section)\n    aspect_bullet = futures[\"aspect_bullet\"].result() if futures[\"aspect_bullet\"] else None\n    return _add_affixes_from_tts_mixed(tts_section, item, affix_bullets, img_item_descr, aspect_bullet=aspect_bullet)\n\n\ndef read_descr() -> Item | None:\n    tts_section = copy.copy(src.tts.LAST_ITEM)\n    if not tts_section:\n        return None\n    if (item := _create_base_item_from_tts(tts_section)) is None:\n        return None\n    if is_sigil(item.item_type):\n        return _add_sigil_affixes_from_tts(tts_section, item)\n    if item.item_type == ItemType.Cosmetic:\n        item.cosmetic_upgrade = True\n        return item\n    if any([\n        is_consumable(item.item_type),\n        is_non_sigil_mapping(item.item_type),\n        is_socketable(item.item_type),\n        item.item_type in [ItemType.Material, ItemType.Tribute, ItemType.Cache, ItemType.LairBossKey],\n        item.seasonal_attribute == SeasonalAttribute.sanctified,\n    ]):\n        return item\n\n    if all([\n        not is_armor(item.item_type),\n        not is_jewelry(item.item_type),\n        not is_weapon(item.item_type),\n        item.item_type != ItemType.Shield,\n    ]):\n        return None\n\n    if item.rarity == ItemRarity.Mythic and item.is_in_shop:\n        return None\n\n    if item.rarity in [ItemRarity.Unique, ItemRarity.Mythic] and item.name not in Dataloader().aspect_unique_dict:\n        msg = f\"Unrecognized unique {item.name}. This most likely means the name of it reported from Diablo 4 is wrong. Please report a bug with this message. TTS: {tts_section}\"\n        raise IndexError(msg)\n\n    item.codex_upgrade = _is_codex_upgrade(tts_section)\n    item.cosmetic_upgrade = _is_cosmetic_upgrade(tts_section)\n    return _add_affixes_from_tts(tts_section, item)\n"
  },
  {
    "path": "src/item/descr/text.py",
    "content": "import re\n\nimport rapidfuzz\nimport rapidfuzz.distance.Levenshtein\n\nfrom src.dataloader import Dataloader\n\n\ndef closest_match(target, candidates):\n    keys, values = zip(*candidates.items(), strict=False)\n    result = rapidfuzz.process.extractOne(\n        target, values, scorer=rapidfuzz.distance.Levenshtein.distance, score_cutoff=100\n    )\n    return keys[values.index(result[0])] if result else None\n\n\ndef closest_to(value, choices):\n    return min(choices, key=lambda x: abs(x - value))\n\n\ndef find_number(s: str, idx: int = 0) -> float | None:\n    s = remove_text_after_first_keyword(s, Dataloader().filter_after_keyword)\n    s = s.replace(r\",\", \"\")  # remove commas because of large numbers having a comma seperator\n    matches = re.findall(r\"[+-]?(\\d+\\.\\d+|\\.\\d+|\\d+\\.?|\\d+)\\%?\", s)\n    number = (\n        (matches[1] if len(matches) > 1 else None)\n        if \"up to a 5%\" in s\n        else matches[idx]\n        if matches and len(matches) > idx\n        else None\n    )\n    if number is not None:\n        number = re.sub(r\"[+%]\", \"\", number)\n        return float(number)\n    return None\n\n\ndef remove_text_after_first_keyword(text: str, keywords: list[str]) -> str:\n    start_pos = None\n    for keyword in keywords:\n        match = re.search(re.escape(keyword), text)\n        if match and (start_pos is None or start_pos > match.start()):\n            start_pos = match.start() if start_pos is None or start_pos > match.start() else start_pos\n    if start_pos is not None:\n        return text[:start_pos]\n    return text\n\n\ndef clean_str(s: str) -> str:\n    cleaned_str = re.sub(r\"(\\d)[, ]+(\\d)\", r\"\\1\\2\", s)  # Remove , between numbers (large number seperator)\n    cleaned_str = re.sub(r\"(\\+)?\\d+(\\.\\d+)?%?\", \"\", cleaned_str)  # Remove numbers and trailing % or preceding +\n    cleaned_str = cleaned_str.replace(\"[x]\", \"\")  # Remove all [x]\n    cleaned_str = cleaned_str.replace(\"durability:\", \"\")\n    cleaned_str = re.sub(r\"[\\[\\]+\\-:%\\'#]\", \"\", cleaned_str)  # Remove [ and ] and leftover +, -, %, :, '\n    cleaned_str = remove_text_after_first_keyword(cleaned_str, Dataloader().filter_after_keyword)\n    for s in Dataloader().filter_words:\n        cleaned_str = cleaned_str.replace(s, \"\")\n    if \"(\" in cleaned_str:\n        cleaned_str = cleaned_str[: cleaned_str.rfind(\"(\")]\n    return \" \".join(cleaned_str.split()).strip().lower()  # Remove extra spaces\n"
  },
  {
    "path": "src/item/descr/texture.py",
    "content": "import math\n\nimport numpy as np\n\nfrom src.config.data import COLORS\nfrom src.config.ui import ResManager\nfrom src.template_finder import TemplateMatch, search\nfrom src.utils.image_operations import color_filter, crop\n\n\ndef find_seperators_long(img_item_descr: np.ndarray, sep_short_match: TemplateMatch) -> list[TemplateMatch]:\n    refs = [\"item_seperator_long_legendary\", \"item_seperator_long_mythic\"]\n    roi = [0, sep_short_match.center[1], img_item_descr.shape[1], img_item_descr.shape[0] - sep_short_match.center[1]]\n    if not (sep_long := search(refs, img_item_descr, 0.80, roi, True, mode=\"all\", do_multi_process=False)).success:\n        return None\n    matches_dict = {}\n    for match in sep_long.matches:\n        match_exists = False\n        for center in matches_dict:\n            if math.sqrt((center[0] - match.center[0]) ** 2 + (center[1] - match.center[1]) ** 2) <= 10:\n                if match.score > matches_dict[center].score:\n                    matches_dict[center] = match\n                match_exists = True\n                break\n        if not match_exists:\n            matches_dict[match.center] = match\n    filtered_matches = list(matches_dict.values())\n    return sorted(filtered_matches, key=lambda match: match.center[1])\n\n\ndef find_seperator_short(img_item_descr: np.ndarray) -> TemplateMatch:\n    refs = [\"item_seperator_short_rare\", \"item_seperator_short_legendary\", \"item_seperator_short_mythic\"]\n    roi = [\n        0,\n        int(ResManager().offsets.find_seperator_short_offset_top / 5),\n        img_item_descr.shape[1],\n        ResManager().offsets.find_seperator_short_offset_top,\n    ]\n    if not (sep_short := search(refs, img_item_descr, 0.62, roi, True, mode=\"all\", do_multi_process=False)).success:\n        return None\n    sorted_matches = sorted(sep_short.matches, key=lambda match: match.center[1])\n    return sorted_matches[0]\n\n\ndef _filter_outliers(template_matches: list[TemplateMatch]) -> list[TemplateMatch]:\n    # Extract center[0] values\n    centers_x = [tm.center[0] for tm in template_matches]\n    # Calculate the median\n    if not centers_x:\n        return []\n    target_center_x = np.min(centers_x)\n    # Filter out the outliers\n    return [tm for tm in template_matches if abs(tm.center[0] - target_center_x) < 1.2 * tm.region[2]]\n\n\ndef _find_bullets(\n    img_item_descr: np.ndarray, sep_short_match: TemplateMatch, template_list: list[str], threshold: float, mode: str\n) -> list[TemplateMatch]:\n    img_height = img_item_descr.shape[0]\n    roi_bullets = [0, sep_short_match.center[1], ResManager().offsets.find_bullet_points_width, img_height]\n    all_bullets = search(\n        ref=template_list, inp_img=img_item_descr, threshold=threshold, roi=roi_bullets, use_grayscale=True, mode=mode\n    )\n    if not all_bullets.success:\n        return []\n    all_bullets.matches = _filter_outliers(all_bullets.matches)\n    # go through the matches and filter out the ones that are too close to each other. only keep the one with higher probability\n    matches_dict = {}\n    for match in all_bullets.matches:\n        match_exists = False\n        for center in matches_dict:\n            if math.sqrt((center[0] - match.center[0]) ** 2 + (center[1] - match.center[1]) ** 2) <= 10:\n                if match.score > matches_dict[center].score:\n                    matches_dict[center] = match\n                match_exists = True\n                break\n        if not match_exists:\n            matches_dict[match.center] = match\n    filtered_matches = list(matches_dict.values())\n    return sorted(filtered_matches, key=lambda match: match.center[1])\n\n\ndef find_affix_bullets(img_item_descr: np.ndarray, sep_short_match: TemplateMatch) -> list[TemplateMatch]:\n    affix_icons = [f\"affix_bullet_point_{x}\" for x in range(1, 3)]\n    rerolled_icons = [f\"rerolled_bullet_point_{x}\" for x in range(1, 3)]\n    tempered_icons = [f\"tempered_affix_bullet_point_{x}\" for x in range(1, 7)]\n    template_list = (\n        [\n            \"greater_affix_bullet_point_1\",\n            \"greater_affix_bullet_point_masterworked\",\n            \"masterworking_affix_bullet\",\n            \"masterworking_affix_bullet_2\",\n        ]\n        + affix_icons\n        + rerolled_icons\n        + tempered_icons\n    )\n    all_templates = [f\"{x}_medium\" for x in template_list] + template_list\n    search_threshold = 0.80\n    if ResManager().resolution[1] <= 1200:\n        all_templates += [\n            \"greater_affix_bullet_point_1080p_special\",\n            \"greater_affix_bullet_point_masterworked_medium_1080p_special\",\n            \"masterworking_affix_bullet_medium_1080p_special\",\n        ]\n        # At lower resolutions it starts reading text as bullet points. We'll see if this fixes it\n        search_threshold = 0.85\n    return _find_bullets(\n        img_item_descr=img_item_descr,\n        sep_short_match=sep_short_match,\n        template_list=all_templates,\n        threshold=search_threshold,\n        mode=\"all\",\n    )\n\n\ndef find_aspect_bullet(img_item_descr: np.ndarray, sep_short_match: TemplateMatch) -> TemplateMatch | None:\n    template_list = [\"legendary_bullet_point\", \"unique_bullet_point\", \"mythic_bullet_point\"]\n    all_templates = [f\"{x}_medium\" for x in template_list] + template_list\n    if ResManager().resolution[1] <= 1200:\n        all_templates += [\"mythic_bullet_point_1080p_special\", \"mythic_bullet_point_medium_1080p_special\"]\n    aspect_bullets = _find_bullets(\n        img_item_descr=img_item_descr,\n        sep_short_match=sep_short_match,\n        template_list=all_templates,\n        threshold=0.8,\n        mode=\"all\",\n    )\n    if aspect_bullets:\n        return next(match for match in aspect_bullets if match.score == max(match.score for match in aspect_bullets))\n    return None\n\n\ndef find_aspect_search_area(img_item_descr: np.ndarray, aspect_bullet: TemplateMatch) -> list[int]:\n    line_height = ResManager().offsets.item_descr_line_height\n    img_height, img_width = img_item_descr.shape[:2]\n    offset_x = aspect_bullet.center[0] + int(line_height // 5)\n    top = aspect_bullet.center[1] - int(line_height * 0.8)\n    roi_aspect = [offset_x, top, int(img_width * 0.99) - offset_x, int(img_height * 0.95) - top]\n    cropped_bottom = crop(img_item_descr, roi_aspect)\n    filtered, _ = color_filter(cropped_bottom, COLORS.unique_gold, False)\n    bounding_values = np.nonzero(filtered)\n    if len(bounding_values[0]) > 0:\n        roi_aspect[3] = bounding_values[0].max() + int(line_height * 0.4)\n    return roi_aspect\n\n\ndef find_codex_upgrade_icon(img_item_descr: np.ndarray, aspect_bullet: TemplateMatch) -> bool:\n    top_limit = img_item_descr.shape[0] // 2\n    right_limit = img_item_descr.shape[1] // 2\n    if aspect_bullet is not None:\n        top_limit = aspect_bullet.center[1]\n    cut_item_descr = img_item_descr[top_limit:, :right_limit]\n    # TODO small font template fallback\n    result = search([\"codex_upgrade_icon_medium\"], cut_item_descr, 0.78, use_grayscale=True, mode=\"first\")\n    if not result.success:\n        result = search([\"codex_upgrade_icon\"], cut_item_descr, 0.78, use_grayscale=True, mode=\"first\")\n    return result.success\n"
  },
  {
    "path": "src/item/filter.py",
    "content": "import logging\nimport pathlib\nimport re\nimport sys\nimport time\nfrom dataclasses import dataclass, field\nfrom typing import TYPE_CHECKING\n\nimport yaml\nfrom pydantic import ValidationError\nfrom yaml import MappingNode, MarkedYAMLError\n\nfrom src.config.loader import IniConfigLoader\nfrom src.config.profile_models import (\n    AffixAspectFilterModel,\n    AffixFilterCountModel,\n    AffixFilterModel,\n    DynamicItemFilterModel,\n    GlobalUniqueModel,\n    ProfileModel,\n    SigilConditionModel,\n    SigilFilterModel,\n    SigilPriority,\n    TributeFilterModel,\n)\nfrom src.config.settings_models import AspectFilterType, CosmeticFilterType, UnfilteredUniquesType\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.item_type import ItemType, is_sigil\nfrom src.item.data.rarity import ItemRarity\nfrom src.scripts.common import ASPECT_UPGRADES_LABEL\n\nif TYPE_CHECKING:\n    from src.item.data.aspect import Aspect\n    from src.item.models import Item\n\nLOGGER = logging.getLogger(__name__)\n\n\n@dataclass\nclass MatchedFilter:\n    profile: str\n    matched_affixes: list[Affix] = field(default_factory=list)\n    aspect_match: bool = False\n\n\n@dataclass\nclass FilterResult:\n    keep: bool\n    matched: list[MatchedFilter]\n\n\nclass _UniqueKeyLoader(yaml.SafeLoader):\n    def construct_mapping(self, node: MappingNode, deep=False):\n        mapping = set()\n        for key_node, _ in node.value:\n            if \":merge\" in key_node.tag:\n                continue\n            key = self.construct_object(key_node, deep=deep)\n            if key in mapping:\n                raise MarkedYAMLError(problem=f\"Duplicate {key!r} key found in YAML\", problem_mark=key_node.start_mark)\n            mapping.add(key)\n        return super().construct_mapping(node, deep)\n\n\nclass Filter:\n    affix_filters = {}\n    aspect_upgrade_filters = {}\n    paragon_filters = {}\n    global_unique_filters = {}\n    sigil_filters = {}\n    tribute_filters = {}\n\n    files_loaded = False\n    all_file_paths = []\n    last_loaded = None\n    last_profile_list = None\n\n    _initialized: bool = False\n    _instance = None\n\n    def __new__(cls):\n        if cls._instance is None:\n            cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def _check_affixes(self, item: Item) -> FilterResult:\n        res = FilterResult(False, [])\n        if not self.affix_filters:\n            return FilterResult(False, [])\n        non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered]\n        for profile_name, profile_filter in self.affix_filters.items():\n            for filter_item in profile_filter:\n                filter_name = next(iter(filter_item.root.keys()))\n                filter_spec = filter_item.root[filter_name]\n                # check item type\n                if not self._match_item_type(expected_item_types=filter_spec.itemType, item_type=item.item_type):\n                    continue\n                # check item power\n                if not self._match_item_power(min_power=filter_spec.minPower, item_power=item.power):\n                    continue\n                # check greater affixes\n                if not self._match_greater_affix_count(\n                    expected_min_count=filter_spec.minGreaterAffixCount, item_affixes=non_tempered_affixes\n                ):\n                    continue\n                # check the unique aspect\n                if not self._match_item_aspect_or_affix(\n                    expected_aspect=filter_spec.uniqueAspect, item_aspect=item.aspect\n                ):\n                    continue\n                # check the aspect matches the min percent\n                if filter_spec.uniqueAspect and not self._match_item_roll_is_in_percent_range(\n                    expected_percent=filter_spec.uniqueAspect.minPercentOfAspect, item_aspect_or_affix=item.aspect\n                ):\n                    continue\n                # check affixes\n                matched_affixes = []\n                if filter_spec.affixPool:\n                    matched_affixes = self._match_affixes_count(\n                        expected_affixes=filter_spec.affixPool,\n                        item_affixes=non_tempered_affixes,\n                        min_greater_affix_count=filter_spec.minGreaterAffixCount,\n                    )\n                    if not matched_affixes:\n                        continue\n                # check inherent\n                matched_inherents = []\n                if filter_spec.inherentPool:\n                    matched_inherents = self._match_affixes_count(\n                        expected_affixes=filter_spec.inherentPool,\n                        item_affixes=item.inherent,\n                        min_greater_affix_count=filter_spec.minGreaterAffixCount,\n                    )\n                    if not matched_inherents:\n                        continue\n                all_matches = matched_affixes + matched_inherents\n                # Build a detailed string showing which affixes are GAs\n                match_details = []\n                for affix in all_matches:\n                    if affix.type == AffixType.greater:\n                        match_details.append(f\"{affix.name} (GA)\")\n                    else:\n                        match_details.append(affix.name)\n                LOGGER.info(f\"{item.original_name} -- Matched {profile_name}.Affixes.{filter_name}: {match_details}\")\n                if filter_spec.uniqueAspect:\n                    LOGGER.info(f\"{item.original_name} -- Matched {profile_name}.Affixes.{filter_name}: Unique aspect\")\n                res.keep = True\n                res.matched.append(\n                    MatchedFilter(f\"{profile_name}.{filter_name}\", all_matches, bool(filter_spec.uniqueAspect))\n                )\n        return res\n\n    def _check_legendary_aspect(self, item: Item) -> FilterResult:\n        res = FilterResult(False, [])\n\n        if item.codex_upgrade and self.aspect_upgrade_filters:\n            # See if the item matches any legendary aspects that were in the profile\n            for profile_name, profile_filter in self.aspect_upgrade_filters.items():\n                if item.aspect and any(\n                    legendary_aspect_name == item.aspect.name for legendary_aspect_name in profile_filter\n                ):\n                    LOGGER.info(f\"{item.original_name} -- Matched build-specific aspects that updates codex\")\n                    res.keep = True\n                    res.matched.append(MatchedFilter(f\"{profile_name}.{ASPECT_UPGRADES_LABEL}\", aspect_match=True))\n\n            if res.keep:\n                return res\n\n        if IniConfigLoader().general.keep_aspects == AspectFilterType.none or (\n            IniConfigLoader().general.keep_aspects == AspectFilterType.upgrade and not item.codex_upgrade\n        ):\n            return res\n        LOGGER.info(f\"{item.original_name} -- Matched Aspects that updates codex\")\n        res.keep = True\n        res.matched.append(MatchedFilter(ASPECT_UPGRADES_LABEL, aspect_match=True))\n        return res\n\n    @staticmethod\n    def _check_cosmetic(item: Item) -> FilterResult:\n        res = FilterResult(False, [])\n        if IniConfigLoader().general.handle_cosmetics == CosmeticFilterType.junk or (\n            IniConfigLoader().general.handle_cosmetics == CosmeticFilterType.ignore and not item.cosmetic_upgrade\n        ):\n            return res\n        if not item.cosmetic_upgrade:\n            return res\n        LOGGER.info(f\"{item.original_name} -- Matched new cosmetic\")\n        res.keep = True\n        res.matched.append(MatchedFilter(\"Cosmetics\"))\n        return res\n\n    def _check_sigil(self, item: Item) -> FilterResult:\n        res = FilterResult(False, [])\n        if not self.sigil_filters.items():\n            LOGGER.info(f\"{item.original_name} -- Matched Sigils\")\n            res.keep = True\n            res.matched.append(MatchedFilter(\"Sigils not filtered\"))\n        for profile_name, profile_filter in self.sigil_filters.items():\n            blacklist_empty = not profile_filter.blacklist\n            is_in_blacklist = self._match_affixes_sigils(\n                expected_affixes=profile_filter.blacklist,\n                sigil_name=item.name,\n                sigil_affixes=item.affixes + item.inherent,\n            )\n            blacklist_ok = True if blacklist_empty else not is_in_blacklist\n            whitelist_empty = not profile_filter.whitelist\n            is_in_whitelist = self._match_affixes_sigils(\n                expected_affixes=profile_filter.whitelist,\n                sigil_name=item.name,\n                sigil_affixes=item.affixes + item.inherent,\n            )\n            whitelist_ok = True if whitelist_empty else is_in_whitelist\n\n            if (blacklist_empty and not whitelist_empty and not whitelist_ok) or (\n                whitelist_empty and not blacklist_empty and not blacklist_ok\n            ):\n                continue\n            if not blacklist_empty and not whitelist_empty:\n                if not blacklist_ok and not whitelist_ok:\n                    continue\n                if is_in_blacklist and is_in_whitelist:\n                    if profile_filter.priority == SigilPriority.whitelist and not whitelist_ok:\n                        continue\n                    if profile_filter.priority == SigilPriority.blacklist and not blacklist_ok:\n                        continue\n                elif (is_in_blacklist and not blacklist_ok) or (not is_in_whitelist and not whitelist_ok):\n                    continue\n            LOGGER.info(f\"{item.original_name} -- Matched {profile_name}.Sigils\")\n            res.keep = True\n            res.matched.append(MatchedFilter(f\"{profile_name}\"))\n        return res\n\n    def _check_tribute(self, item: Item) -> FilterResult:\n        res = FilterResult(False, [])\n        if not self.tribute_filters.items():\n            LOGGER.info(f\"{item.original_name} -- Matched Tributes\")\n            res.keep = True\n            res.matched.append(MatchedFilter(\"Tributes not filtered\"))\n\n        if item.rarity == ItemRarity.Mythic:\n            LOGGER.info(f\"{item.original_name} -- Matched mythic tribute, always kept\")\n            res.keep = True\n            res.matched.append(MatchedFilter(\"Mythic Tribute\"))\n\n        for profile_name, profile_filter in self.tribute_filters.items():\n            for filter_item in profile_filter:\n                if filter_item.name and not item.name.startswith(filter_item.name):\n                    continue\n\n                if filter_item.rarities and item.rarity not in filter_item.rarities:\n                    continue\n\n                LOGGER.info(f\"{item.original_name} -- Matched {profile_name}.Tributes\")\n                res.keep = True\n                res.matched.append(MatchedFilter(f\"{profile_name}\"))\n        return res\n\n    def _check_global_unique_filter(self, item: Item) -> FilterResult:\n        res = FilterResult(False, [])\n\n        if not self.global_unique_filters:\n            keep = IniConfigLoader().general.handle_uniques != UnfilteredUniquesType.junk\n            return FilterResult(keep, [])\n        for profile_name, profile_filter in self.global_unique_filters.items():\n            for filter_item in profile_filter:\n                # check item power\n                if not self._match_item_power(min_power=filter_item.minPower, item_power=item.power):\n                    continue\n                # check greater affixes - Checks total item-level GAs\n                if not self._match_greater_affix_count(\n                    expected_min_count=filter_item.minGreaterAffixCount, item_affixes=item.affixes\n                ):\n                    continue\n                # check aspect is in percent range\n                if not self._match_item_roll_is_in_percent_range(\n                    expected_percent=filter_item.minPercentOfAspect, item_aspect_or_affix=item.aspect\n                ):\n                    continue\n                LOGGER.info(f\"{item.original_name} -- Matched {profile_name}.GlobalUniques: {item.aspect.name}\")\n                res.keep = True\n                matched_full_name = f\"{profile_name}.{item.aspect.name}\"\n                if filter_item.profileAlias:\n                    matched_full_name = f\"{filter_item.profileAlias}.{item.aspect.name}\"\n                res.matched.append(MatchedFilter(matched_full_name, aspect_match=True))\n\n        return res\n\n    def _did_files_change(self) -> bool:\n        if self.last_loaded is None:\n            return True\n\n        # Force reload config from disk to get latest profile list\n        IniConfigLoader().load()\n\n        # Check if profile list changed (filter out empty strings)\n        current_profiles = [p.strip() for p in IniConfigLoader().general.profiles if p.strip()]\n        if self.last_profile_list != current_profiles:\n            LOGGER.info(f\"Profile list changed: {self.last_profile_list} → {current_profiles}\")\n            return True\n\n        # Check if any profile files were modified\n        return any(pathlib.Path(file_path).stat().st_mtime > self.last_loaded for file_path in self.all_file_paths)\n\n    def _match_affixes_count(\n        self, expected_affixes: list[AffixFilterCountModel], item_affixes: list[Affix], min_greater_affix_count: int = 0\n    ) -> list[Affix]:\n        result = []\n        for count_group in expected_affixes:\n            group_res = []\n\n            # Do the normal affix matching first\n            for affix in count_group.count:\n                matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None)\n                if matched_item_affix is not None and self._match_item_aspect_or_affix(affix, matched_item_affix):\n                    group_res.append(matched_item_affix)\n\n            # Check minCount and maxCount\n            if not (count_group.minCount <= len(group_res) <= count_group.maxCount):\n                return []  # if one group fails, everything fails\n\n            # Check want_greater requirements (2-mode system)\n            want_greater_affixes = [a for a in count_group.count if getattr(a, \"want_greater\", False)]\n            want_greater_count = len(want_greater_affixes)\n\n            if want_greater_count > 0 and min_greater_affix_count > 0:\n                if min_greater_affix_count > want_greater_count:\n                    # Mode 1: ALL flagged affixes MUST be GA (hard requirement)\n                    for affix in want_greater_affixes:\n                        matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None)\n                        if matched_item_affix is None or matched_item_affix.type != AffixType.greater:\n                            return []  # Flagged affix is missing or not GA, fail\n                else:\n                    # Mode 2: At least min_greater_affix_count of the flagged affixes must be GA (flexible)\n                    flagged_ga_count = sum(\n                        1\n                        for affix in want_greater_affixes\n                        if (matched := next((a for a in item_affixes if a.name == affix.name), None))\n                        and matched.type == AffixType.greater\n                    )\n                    if flagged_ga_count < min_greater_affix_count:\n                        return []  # Not enough flagged affixes are GA\n\n            result.extend(group_res)\n        return result\n\n    @staticmethod\n    def _match_affixes_sigils(\n        expected_affixes: list[SigilConditionModel], sigil_name: str, sigil_affixes: list[Affix]\n    ) -> bool:\n        for expected_affix in expected_affixes:\n            if sigil_name != expected_affix.name and not [\n                affix for affix in sigil_affixes if affix.name == expected_affix.name\n            ]:\n                continue\n            if expected_affix.condition and not any(affix.name in expected_affix.condition for affix in sigil_affixes):\n                continue\n            return True\n        return False\n\n    def _match_affixes_uniques(\n        self, expected_affixes: list[AffixFilterModel], item_affixes: list[Affix], min_greater_affix_count: int = 0\n    ) -> bool:\n        # First, check if all expected affixes are present with correct values\n        for expected_affix in expected_affixes:\n            matched_item_affix = next((a for a in item_affixes if a.name == expected_affix.name), None)\n            if matched_item_affix is None or not self._match_item_aspect_or_affix(expected_affix, matched_item_affix):\n                return False\n\n        # Then, check want_greater requirements (2-mode system)\n        want_greater_affixes = [a for a in expected_affixes if getattr(a, \"want_greater\", False)]\n        want_greater_count = len(want_greater_affixes)\n\n        if want_greater_count > 0 and min_greater_affix_count > 0:\n            if min_greater_affix_count > want_greater_count:\n                # Mode 1: ALL flagged affixes MUST be GA (hard requirement)\n                for affix in want_greater_affixes:\n                    matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None)\n                    if matched_item_affix is None or matched_item_affix.type != AffixType.greater:\n                        return False  # Flagged affix is missing or not GA\n            else:\n                # Mode 2: At least min_greater_affix_count of the flagged affixes must be GA (flexible)\n                flagged_ga_count = sum(\n                    1\n                    for affix in want_greater_affixes\n                    if (matched := next((a for a in item_affixes if a.name == affix.name), None))\n                    and matched.type == AffixType.greater\n                )\n                if flagged_ga_count < min_greater_affix_count:\n                    return False  # Not enough flagged affixes are GA\n\n        return True\n\n    @staticmethod\n    def _match_greater_affix_count(expected_min_count: int, item_affixes: list[Affix]) -> bool:\n        return expected_min_count <= len([x for x in item_affixes if x.type == AffixType.greater])\n\n    @staticmethod\n    def _match_item_roll_is_in_percent_range(expected_percent: int, item_aspect_or_affix: Aspect | Affix) -> bool:\n        if expected_percent == 0 or item_aspect_or_affix.max_value is None or item_aspect_or_affix.min_value is None:\n            return True\n\n        if item_aspect_or_affix.max_value == item_aspect_or_affix.min_value:\n            return True\n\n        if not Filter._is_smaller_roll_better(item_aspect_or_affix):\n            percent_float = expected_percent / 100.0\n            return (item_aspect_or_affix.value - item_aspect_or_affix.min_value) / (\n                item_aspect_or_affix.max_value - item_aspect_or_affix.min_value\n            ) >= percent_float\n\n        # This is the case where a smaller number is better\n        percent_float = (100 - expected_percent) / 100.0\n        return (item_aspect_or_affix.value - item_aspect_or_affix.max_value) / (\n            item_aspect_or_affix.min_value - item_aspect_or_affix.max_value\n        ) <= percent_float\n\n    @staticmethod\n    def _is_smaller_roll_better(item_aspect_or_affix: Aspect | Affix) -> bool:\n        return (\n            item_aspect_or_affix.max_value is not None\n            and item_aspect_or_affix.min_value is not None\n            and item_aspect_or_affix.max_value < item_aspect_or_affix.min_value\n        )\n\n    @staticmethod\n    def _match_item_value_threshold(expected_value: float, item_aspect_or_affix: Aspect | Affix) -> bool:\n        if Filter._is_smaller_roll_better(item_aspect_or_affix):\n            return item_aspect_or_affix.value <= expected_value\n        return item_aspect_or_affix.value >= expected_value\n\n    def _match_item_aspect_or_affix(\n        self,\n        expected_aspect: AffixAspectFilterModel | None,\n        item_aspect: Aspect | Affix | None,\n        is_fixed_aspect_value: bool = False,\n    ) -> bool:\n        if expected_aspect is None:\n            return True\n        if item_aspect is None:\n            return False\n        if expected_aspect.name != item_aspect.name:\n            return False\n\n        if expected_aspect.value is not None:\n            if item_aspect.value is None:\n                # Chaos uniques and probably bloodied items have a fixed aspect number.\n                # There is no reason to compare it, it is always at max\n                return bool(is_fixed_aspect_value)\n            if not self._match_item_value_threshold(expected_aspect.value, item_aspect):\n                return False\n        expected_affix_percent = getattr(expected_aspect, \"minPercentOfAffix\", 0)\n        if expected_affix_percent:\n            if isinstance(item_aspect, Affix) and item_aspect.type == AffixType.greater:\n                return True\n            if not self._match_item_roll_is_in_percent_range(\n                expected_percent=expected_affix_percent, item_aspect_or_affix=item_aspect\n            ):\n                return False\n        return True\n\n    @staticmethod\n    def _match_item_power(min_power: int, item_power: int, max_power: int = sys.maxsize) -> bool:\n        return min_power <= item_power <= max_power\n\n    @staticmethod\n    def _match_item_type(expected_item_types: list[ItemType], item_type: ItemType) -> bool:\n        if not expected_item_types:\n            return True\n        return item_type in expected_item_types\n\n    def load_files(self):\n        self.files_loaded = True\n        self.affix_filters: dict[str, list[DynamicItemFilterModel]] = {}\n        self.aspect_upgrade_filters: dict[str, list[str]] = {}\n        self.paragon_filters: dict[str, object] = {}\n        self.sigil_filters: dict[str, SigilFilterModel] = {}\n        self.tribute_filters: dict[str, list[TributeFilterModel]] = {}\n        self.global_unique_filters: dict[str, list[GlobalUniqueModel]] = {}\n        profiles: list[str] = IniConfigLoader().general.profiles\n\n        # Filter out empty strings\n        profiles = [p.strip() for p in profiles if p.strip()]\n\n        if not profiles:\n            LOGGER.warning(\n                \"No profiles are currently loaded. Please load a profile via the Importer, Settings, or Edit Profile sections to begin using the tool.\"\n            )\n            self.last_loaded = time.time()\n            self.last_profile_list = []\n            return\n\n        custom_profile_path = IniConfigLoader().user_dir / \"profiles\"\n        self.all_file_paths = []\n\n        for profile_str in profiles:\n            custom_file_path = custom_profile_path / f\"{profile_str}.yaml\"\n            if custom_file_path.is_file():\n                profile_path = custom_file_path\n            else:\n                LOGGER.error(f\"Could not load profile {profile_str}. Checked: {custom_file_path}\")\n                continue\n\n            self.all_file_paths.append(profile_path)\n            with pathlib.Path(profile_path).open(encoding=\"utf-8\") as f:\n                try:\n                    config = yaml.load(stream=f, Loader=_UniqueKeyLoader)\n                except Exception as e:\n                    LOGGER.error(f\"Error in the YAML file {profile_path}: {e}\")\n                    continue\n                if config is None:\n                    LOGGER.error(f\"Empty YAML file {profile_path}, please remove it\")\n                    continue\n\n                info_str = f\"Loading profile {profile_str}: \"\n                try:\n                    data = ProfileModel(name=profile_str, **config)\n                except ValidationError as e:\n                    LOGGER.error(\n                        f\"There were errors validating the profile at {profile_path}. This most likely means it is an old profile and the code has changed since it was created. The easiest solution is to delete the profile and import it again, or edit it manually using the errors below to guide you. The profile is skipped.\"\n                    )\n                    profile_errors = re.sub(\n                        r\"For further information visit https://errors\\.pydantic\\.dev/\\d+(\\.\\d+)+/v/value_error\\s*\",\n                        \"\",\n                        str(e),\n                    )\n                    LOGGER.error(f\"Validation error in {profile_path}: {profile_errors}\")\n                    continue\n\n                sections: list[str] = []\n                if data.Affixes:\n                    self.affix_filters[data.name] = data.Affixes\n                    sections.append(\"Affixes\")\n                if data.AspectUpgrades:\n                    self.aspect_upgrade_filters[data.name] = data.AspectUpgrades\n                    sections.append(ASPECT_UPGRADES_LABEL)\n                if data.Sigils and (data.Sigils.blacklist or data.Sigils.whitelist):\n                    self.sigil_filters[data.name] = data.Sigils\n                    sections.append(\"Sigils\")\n                if data.Tributes:\n                    self.tribute_filters[data.name] = data.Tributes\n                    sections.append(\"Tributes\")\n                if data.GlobalUniques:\n                    self.global_unique_filters[data.name] = data.GlobalUniques\n                    sections.append(\"GlobalUniques\")\n                if data.Paragon:\n                    self.paragon_filters[data.name] = data.Paragon\n                    sections.append(\"Paragon\")\n\n                info_str += \" \".join(sections)\n                LOGGER.info(info_str.rstrip())\n            self.last_loaded = time.time()\n            self.last_profile_list = IniConfigLoader().general.profiles.copy()\n\n    def get_paragon_filters(self) -> dict[str, object]:\n        \"\"\"Return the loaded Paragon payloads, reloading profiles when needed.\"\"\"\n        if not self.files_loaded or self._did_files_change():\n            self.load_files()\n        return self.paragon_filters\n\n    def should_keep(self, item: Item) -> FilterResult:\n        if not self.files_loaded or self._did_files_change():\n            self.load_files()\n\n        res = FilterResult(False, [])\n\n        if is_sigil(item.item_type):\n            return self._check_sigil(item)\n\n        if item.item_type == ItemType.Tribute:\n            return self._check_tribute(item)\n\n        if item.item_type is None or item.power is None:\n            return res\n\n        keep_affixes = self._check_affixes(item)\n        if keep_affixes.keep:\n            return keep_affixes\n        if item.rarity == ItemRarity.Legendary:\n            res = self._check_legendary_aspect(item)\n        elif item.rarity == ItemRarity.Unique:\n            res = self._check_global_unique_filter(item)\n        elif item.rarity == ItemRarity.Mythic:\n            # We always keep mythics\n            res = FilterResult(keep=True, matched=[MatchedFilter(profile=\"Mythics always kept\", aspect_match=True)])\n\n        # After checking all possible options, if we still don't match, we check for a cosmetic upgrade\n        if not res.keep:\n            return self._check_cosmetic(item)\n\n        return res\n"
  },
  {
    "path": "src/item/find_descr.py",
    "content": "from copy import copy\nfrom typing import TYPE_CHECKING\n\nfrom src.config.ui import ResManager\nfrom src.item.data.rarity import ItemRarity\nfrom src.template_finder import SearchResult, search\nfrom src.utils.image_operations import crop\nfrom src.utils.roi_operations import fit_roi_to_window_size\n\nif TYPE_CHECKING:\n    import numpy as np\n\nmap_template_rarity = {\n    \"item_common_top_left\": ItemRarity.Common,\n    \"item_leg_top_left\": ItemRarity.Legendary,\n    \"item_magic_top_left\": ItemRarity.Magic,\n    \"item_mythic_top_left\": ItemRarity.Mythic,\n    \"item_rare_top_left\": ItemRarity.Rare,\n    \"item_unique_top_left\": ItemRarity.Unique,\n}\n\n\ndef _choose_best_result(res_left: SearchResult, res_right: SearchResult) -> SearchResult:\n    if res_left.success and not res_right.success:\n        return res_left\n    if res_right.success and not res_left.success:\n        return res_right\n    if res_left.success and res_right.success:\n        return res_left if res_left.matches[0].score > res_right.matches[0].score else res_right\n    return SearchResult(success=False)\n\n\ndef _template_search(img: np.ndarray, anchor: int, roi: np.ndarray, take_debug_screenshot: bool = False):\n    roi_copy = copy(roi)\n    roi_copy[0] += anchor\n    ok, roi_left = fit_roi_to_window_size(roi_copy, ResManager().pos.window_dimensions)\n    if ok:\n        return search(\n            ref=list(map_template_rarity.keys()),\n            inp_img=img,\n            roi=roi_left,\n            threshold=0.8,\n            mode=\"all\",\n            take_debug_screenshot=take_debug_screenshot,\n        )\n    return SearchResult(success=False)\n\n\ndef find_descr(\n    img: np.ndarray, anchor: tuple[int, int]\n) -> tuple[bool, ItemRarity, np.ndarray, tuple[int, int, int, int]]:\n    item_descr_width = ResManager().offsets.item_descr_width\n    item_descr_pad = ResManager().offsets.item_descr_pad\n    _, window_height = ResManager().pos.window_dimensions\n\n    res_left = _template_search(img, anchor[0], ResManager().roi.rel_descr_search_left)\n    res_right = _template_search(img, anchor[0], ResManager().roi.rel_descr_search_right)\n\n    res = _choose_best_result(res_left, res_right)\n\n    if res is not None and res.success:\n        match = res.matches[0]\n        rarity = map_template_rarity[match.name.lower()]\n        # find equipe template\n        offset_top = int(window_height * 0.03)\n        roi_y = match.region[1] + offset_top\n        search_height = window_height - roi_y - offset_top\n        delta_x = int(item_descr_width * 0.03)\n        roi = [match.region[0] - delta_x, roi_y, item_descr_width + 2 * delta_x, search_height]\n\n        refs = [\"item_seperator_short_rare\", \"item_seperator_short_legendary\", \"item_seperator_short_mythic\"]\n        sep_short = search(refs, img, 0.8, roi, True, mode=\"first\", do_multi_process=False)\n\n        if sep_short.success:\n            off_bottom_of_descr = ResManager().offsets.item_descr_off_bottom_edge\n            roi_height = ResManager().pos.window_dimensions[1] - (2 * off_bottom_of_descr) - match.region[1]\n            if (\n                res_bottom := search(\n                    ref=[\"item_bottom_edge\"], inp_img=img, roi=roi, threshold=0.54, use_grayscale=True, mode=\"all\"\n                )\n            ).success:\n                roi_height = res_bottom.matches[0].center[1] - off_bottom_of_descr - match.region[1]\n            crop_roi = [\n                match.region[0] + item_descr_pad,\n                match.region[1] + item_descr_pad,\n                item_descr_width - 2 * item_descr_pad,\n                roi_height,\n            ]\n            cropped_descr = crop(img, crop_roi)\n            return True, rarity, cropped_descr, crop_roi\n\n    return False, None, None, None\n"
  },
  {
    "path": "src/item/models.py",
    "content": "import json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from src.item.data.affix import Affix\n    from src.item.data.aspect import Aspect\n    from src.item.data.item_type import ItemType\n    from src.item.data.rarity import ItemRarity\n    from src.item.data.seasonal_attribute import SeasonalAttribute\n\nLOGGER = logging.getLogger(__name__)\n\n\n@dataclass\nclass Item:\n    affixes: list[Affix] = field(default_factory=list)\n    aspect: Aspect | None = None\n    codex_upgrade: bool = False\n    cosmetic_upgrade: bool = False\n    inherent: list[Affix] = field(default_factory=list)\n    is_in_shop: bool = False\n    item_type: ItemType | None = None\n    name: str | None = None\n    original_name: str | None = None\n    power: int | None = None\n    rarity: ItemRarity | None = None\n    seasonal_attribute: SeasonalAttribute | None = None\n\n    def __eq__(self, other):\n        if not isinstance(other, Item):\n            return False\n        res = True\n        if self.affixes != other.affixes:\n            # LOGGER.debug(\"Affixes do not match\")\n            res = False\n        if self.aspect != other.aspect:\n            # LOGGER.debug(\"Aspect not the same\")\n            res = False\n        if self.codex_upgrade != other.codex_upgrade:\n            # LOGGER.debug(\"Codex upgrade not the same\")\n            res = False\n        if self.cosmetic_upgrade != other.cosmetic_upgrade:\n            # LOGGER.debug(\"Cosmetic upgrade not the same\")\n            res = False\n        if self.inherent != other.inherent:\n            # LOGGER.debug(\"Inherent affixes do not match\")\n            res = False\n        if self.item_type != other.item_type:\n            # LOGGER.debug(\"Type not the same\")\n            res = False\n        if self.power != other.power:\n            # LOGGER.debug(\"Power not the same\")\n            res = False\n        if self.name != other.name:\n            # LOGGER.debug(\"Names do not match\")\n            res = False\n        if self.rarity != other.rarity:\n            # LOGGER.debug(\"Rarity not the same\")\n            res = False\n        if self.is_in_shop != other.is_in_shop:\n            res = False\n        if self.seasonal_attribute != other.seasonal_attribute:\n            res = False\n        return res\n\n\nclass ItemJSONEncoder(json.JSONEncoder):\n    def default(self, o):\n        if isinstance(o, Item):\n            return {\n                \"affixes\": [affix.__dict__ for affix in o.affixes],\n                \"aspect\": o.aspect.__dict__ if o.aspect else None,\n                \"codex_upgrade\": o.codex_upgrade,\n                \"cosmetic_upgrade\": o.cosmetic_upgrade,\n                \"inherent\": [affix.__dict__ for affix in o.inherent],\n                \"item_type\": o.item_type.value if o.item_type else None,\n                \"name\": o.name or None,\n                \"power\": o.power or None,\n                \"rarity\": o.rarity.value if o.rarity else None,\n            }\n        return super().default(o)\n"
  },
  {
    "path": "src/logger.py",
    "content": "from __future__ import annotations\n\nimport datetime\nimport logging\nimport logging.handlers\nimport sys\nimport threading\nimport typing\n\nimport colorama\n\nfrom src import __version__\nfrom src.config import BASE_DIR\n\nlogging.getLogger(\"httpcore\").setLevel(logging.WARNING)\nlogging.getLogger(\"httpx\").setLevel(logging.WARNING)\nlogging.getLogger(\"selenium\").setLevel(logging.WARNING)\nlogging.getLogger(\"urllib3\").setLevel(logging.WARNING)\n\nLOGGER = logging.getLogger(__name__)\n\nLOG_DIR = BASE_DIR / \"logs\"\n\n_setup_called = False\n\n\nclass ThreadNameFilter(logging.Filter):\n    def filter(self, record):\n        if record.threadName.startswith(\"Dummy-\"):\n            record.threadName = record.threadName.replace(\"Dummy-\", \"Thread-\")\n        return True\n\n\nclass ColoredFormatter(logging.Formatter):\n    def __init__(\n        self,\n        fmt: str | None = None,\n        datefmt: str | None = None,\n        style: str = \"%\",\n        validate: bool = True,\n        *,\n        defaults: dict[str, typing.Any] | None = None,\n    ) -> None:\n        colorama.just_fix_windows_console()\n        super().__init__(fmt=fmt, datefmt=datefmt, style=style, validate=validate, defaults=defaults)\n\n    COLORS = {\n        \"DEBUG\": colorama.Fore.BLUE,\n        \"INFO\": colorama.Fore.GREEN,\n        \"WARNING\": colorama.Fore.YELLOW,\n        \"ERROR\": colorama.Fore.RED,\n        \"CRITICAL\": colorama.Fore.MAGENTA + colorama.Back.YELLOW,\n    }\n\n    def format(self, record: logging.LogRecord) -> str:\n        log_message = super().format(record)\n        return self.COLORS.get(record.levelname, \"\") + log_message + colorama.Style.RESET_ALL\n\n\ndef _setup_log_filename(fmt: str) -> str:\n    current_datetime = datetime.datetime.now(tz=datetime.UTC)\n\n    filename = fmt.format(date=current_datetime.strftime(\"%Y-%m-%d\"), time=current_datetime.strftime(\"%H-%M-%S\"))\n    if filename and not filename.lower().endswith(\".log\"):\n        filename += \".log\"\n    return filename\n\n\ndef create_formatter(colored=False):\n    fmt = \"%(asctime)s | %(threadName)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s\"\n    if colored:\n        return ColoredFormatter(fmt)\n    return logging.Formatter(fmt)\n\n\ndef setup(log_level: str = \"DEBUG\", *, enable_stdout: bool = True) -> None:\n    LOG_DIR.mkdir(exist_ok=True)\n\n    logger = logging.getLogger()\n    threading.excepthook = _log_unhandled_exceptions\n    # create rotating file handler\n    rotating_handler = logging.handlers.RotatingFileHandler(\n        LOG_DIR / f\"log_{datetime.datetime.now(tz=datetime.UTC).strftime('%Y_%m_%d_%H_%M_%S')}.txt\",\n        mode=\"w\",\n        maxBytes=10 * 1024**2,\n        backupCount=1000,\n        encoding=\"utf8\",\n    )\n    rotating_handler.set_name(\"D4LF_FILE\")\n    rotating_handler.setLevel(log_level.upper())\n\n    # create StreamHandler for console output (optional)\n    if enable_stdout:\n        stream_handler = logging.StreamHandler(stream=sys.stdout)\n        stream_handler.addFilter(ThreadNameFilter())\n        stream_handler.set_name(\"D4LF_CONSOLE\")\n        stream_handler.setLevel(log_level.upper())\n        stream_handler.setFormatter(create_formatter(colored=True))\n        logger.addHandler(stream_handler)\n\n    rotating_handler.setFormatter(create_formatter(colored=False))\n\n    # add rotating file handler\n    logger.addHandler(rotating_handler)\n\n    # Set default log level for root logger\n    logger.setLevel(\"DEBUG\")\n\n    global _setup_called\n    if not _setup_called:\n        LOGGER.info(f\"Running version v{__version__}\")\n        _setup_called = True\n\n    # Clean up old log files\n    clean_up_old_log_files()\n\n\ndef clean_up_old_log_files():\n    max_to_keep = 10\n\n    files = [f for f in LOG_DIR.iterdir() if f.is_file() and f.name.startswith(\"log_\")]\n    sorted_files = sorted(files, key=lambda f: f.stat().st_mtime)  # Oldest first\n    files_to_delete = sorted_files[:-max_to_keep] if len(sorted_files) > max_to_keep else []\n\n    for file in files_to_delete:\n        file.unlink()\n        LOGGER.debug(f\"Cleaned up old log file: {file}\")\n\n\ndef _log_unhandled_exceptions(args: typing.Any) -> None:\n    if len(args) >= 2 and isinstance(args[1], SystemExit):\n        return\n    LOGGER.critical(\n        f\"Unhandled exception caused by thread '{args.thread.name}'\",\n        exc_info=(args.exc_type, args.exc_value, args.exc_traceback),\n    )\n"
  },
  {
    "path": "src/loot_mover.py",
    "content": "import logging\nfrom typing import TYPE_CHECKING\n\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import MoveItemsType\nfrom src.ui.char_inventory import CharInventory\nfrom src.ui.stash import Stash\nfrom src.utils.custom_mouse import mouse\n\nif TYPE_CHECKING:\n    from src.ui.inventory_base import ItemSlot\n\nLOGGER = logging.getLogger(__name__)\n\n\ndef move_items_to_stash():\n    LOGGER.info(\"Move inventory items to stash\")\n\n    inv = CharInventory()\n    stash = Stash()\n\n    if not stash.is_open():\n        LOGGER.error(\"Moving items only works if stash is open\")\n        return\n\n    unhandled_slots, _ = inv.get_item_slots()\n    move_item_types = IniConfigLoader().general.move_to_stash_item_type\n    if not unhandled_slots:\n        LOGGER.info(\"No items to move\")\n        return\n\n    for i in IniConfigLoader().general.check_chest_tabs:\n        stash.switch_to_tab(i)\n\n        _, empty_chest = stash.get_item_slots()\n\n        if not empty_chest:\n            continue\n\n        _, unhandled_slots = _move_items(inv, unhandled_slots, len(empty_chest), move_item_types)\n\n        if not unhandled_slots:\n            break\n\n    mouse.move(*Cam().abs_window_to_monitor((0, 0)))\n    LOGGER.info(\"Completed move\")\n\n\ndef move_items_to_inventory():\n    LOGGER.info(\"Move stash items to inventory\")\n\n    inv = CharInventory()\n    stash = Stash()\n\n    if not stash.is_open():\n        LOGGER.error(\"Moving items only works if stash is open\")\n        return\n\n    _, empty_inv = inv.get_item_slots()\n    empty_slot_count = len(empty_inv)\n    move_item_type = IniConfigLoader().general.move_to_inv_item_type\n    if not empty_slot_count:\n        LOGGER.info(\"No empty slots in inventory\")\n        return\n\n    for i in IniConfigLoader().general.check_chest_tabs:\n        stash.switch_to_tab(i)\n        unhandled_slots, _ = stash.get_item_slots()\n\n        LOGGER.debug(\n            f\"Stash tab {i} - Number of stash items: {len(unhandled_slots)} - Number of empty inventory spaces: {empty_slot_count}\"\n        )\n\n        item_move_count, _ = _move_items(inv, unhandled_slots, empty_slot_count, move_item_type)\n        empty_slot_count = empty_slot_count - item_move_count\n        LOGGER.debug(f\"Moved {item_move_count} items, now have {empty_slot_count} empty slots left.\")\n\n        if empty_slot_count < 1:\n            break\n\n    mouse.move(*Cam().abs_window_to_monitor((0, 0)))\n    LOGGER.info(\"Completed move\")\n\n\ndef _move_items(\n    inv: CharInventory, occupied: list[ItemSlot], num_to_move: int, move_item_types: list[MoveItemsType]\n) -> tuple[int, list[ItemSlot]]:\n    \"\"\"Handles actually moving items to or from the stash, based on a parameter.\n\n    :param inv: The Inventory object, used for hovering over the item\n    :param occupied: The ItemSlot list of occupied items to move\n    :param num_to_move: The maximum number of items to move\n    :return: A tuple of the number of items that were moved and a list of unhandled occupied ItemSlots\n    \"\"\"\n    item_move_count = 0\n    remaining_unhandled_slots = occupied.copy()\n    for item in occupied:\n        remaining_unhandled_slots.remove(item)\n\n        if (\n            (MoveItemsType.favorites in move_item_types and item.is_fav)\n            or (MoveItemsType.junk in move_item_types and item.is_junk)\n            or (MoveItemsType.unmarked in move_item_types and not item.is_fav and not item.is_junk)\n            or MoveItemsType.everything in move_item_types\n        ):\n            inv.hover_item(item)\n            mouse.click(\"right\")\n            item_move_count = item_move_count + 1\n\n        if item_move_count == num_to_move:\n            break\n\n    return item_move_count, remaining_unhandled_slots\n"
  },
  {
    "path": "src/main.py",
    "content": "import ctypes\nimport logging\nimport os\nimport pathlib\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport psutil\nfrom beautifultable import BeautifulTable\nfrom PyQt6.QtGui import QIcon\nfrom PyQt6.QtWidgets import QApplication\n\nimport src.logger\nfrom src import __version__, tts\nfrom src.autoupdater import notify_if_update, start_auto_update\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import VisionModeType\nfrom src.item.filter import Filter\nfrom src.logger import LOG_DIR\nfrom src.overlay import Overlay\nfrom src.scripts.common import SETUP_INSTRUCTIONS_URL\nfrom src.scripts.handler import ScriptHandler\nfrom src.utils.window import WindowSpec, start_detecting_window\n\nBASE_DIR = Path(sys.executable).parent if getattr(sys, \"frozen\", False) else Path(__file__).resolve().parent.parent\n\nICON_PATH = BASE_DIR / \"assets\" / \"logo.png\"\n\nLOGGER = logging.getLogger(__name__)\n\n# Set DPI awareness before Qt loads\nif sys.platform == \"win32\":\n    try:\n        ctypes.windll.shcore.SetProcessDpiAwareness(2)\n    except AttributeError:\n        ctypes.windll.user32.SetProcessDPIAware()\n\n\ndef main():\n    for dir_name in [LOG_DIR / \"screenshots\", IniConfigLoader().user_dir, IniConfigLoader().user_dir / \"profiles\"]:\n        Path(dir_name).mkdir(exist_ok=True, parents=True)\n\n    # Detect if we're running locally and skip the autoupdate\n    running_from_source = not getattr(sys, \"frozen\", False)\n    if running_from_source:\n        LOGGER.debug(\"Skipping autoupdate check as code is being run from source.\")\n    else:\n        notify_if_update()\n\n    # --- OG D4LF STYLE HEADER (printed before other runtime logs) ---\n    print(f\"============ D4 Loot Filter {__version__} ============\")\n\n    table = BeautifulTable()\n    table.set_style(BeautifulTable.STYLE_BOX_ROUNDED)\n    table.rows.append([IniConfigLoader().advanced_options.run_vision_mode, \"Run/Stop Vision Mode\"])\n    table.rows.append([IniConfigLoader().advanced_options.toggle_paragon_overlay, \"Toggle Paragon Overlay\"])\n\n    if not IniConfigLoader().advanced_options.vision_mode_only:\n        table.rows.append([IniConfigLoader().advanced_options.run_filter, \"Run/Stop Auto Filter (no match = junk)\"])\n        table.rows.append([\n            IniConfigLoader().advanced_options.run_filter_drop,\n            \"Run/Stop Auto Filter (no match = drop)\",\n        ])\n        table.rows.append([\n            IniConfigLoader().advanced_options.run_filter_force_refresh,\n            \"Force Run/Stop Filter, Resetting Item Status\",\n        ])\n        table.rows.append([\n            IniConfigLoader().advanced_options.force_refresh_only,\n            \"Reset Item Statuses Without A Filter After\",\n        ])\n        table.rows.append([IniConfigLoader().advanced_options.move_to_inv, \"Move Items From Chest To Inventory\"])\n        table.rows.append([IniConfigLoader().advanced_options.move_to_chest, \"Move Items From Inventory To Chest\"])\n\n    table.rows.append([IniConfigLoader().advanced_options.exit_key, \"Exit\"])\n    table.columns.header = [\"hotkey\", \"action\"]\n\n    print(table)\n    print()  # blank line, just like OG D4LF\n    # --- END HEADER ---\n\n    if IniConfigLoader().advanced_options.vision_mode_only:\n        LOGGER.info(\"Vision mode only is enabled. All functionality that clicks the screen is disabled.\")\n\n    Filter().load_files()\n\n    win_spec = WindowSpec(IniConfigLoader().advanced_options.process_name)\n    start_detecting_window(win_spec)\n    while not Cam().is_offset_set():\n        time.sleep(0.2)\n    # The code gets ahead of itself and seems to try to start scanning the screen when the resolution isn't set yet\n    time.sleep(0.5)\n\n    ScriptHandler()\n\n    LOGGER.debug(f\"Vision mode type: {IniConfigLoader().general.vision_mode_type.value}\")\n    check_for_proper_tts_configuration()\n    tts.start_connection()\n\n    overlay = Overlay()\n    overlay.run()\n\n\ndef check_for_proper_tts_configuration():\n    # Check that the dll has been installed and is signed\n    d4_process_found = False\n    tts_dll = None\n    for proc in psutil.process_iter([\"name\", \"exe\"]):\n        if proc.name().lower() == \"diablo iv.exe\":\n            d4_dir = Path(proc.exe()).parent\n            tts_dll = d4_dir / \"saapi64.dll\"\n            if not tts_dll.exists():\n                LOGGER.warning(\n                    f\"TTS DLL was not found in {d4_dir}. Have you followed the instructions in {SETUP_INSTRUCTIONS_URL}?\"\n                )\n            else:\n                LOGGER.debug(f\"TTS DLL found at {tts_dll}\")\n            d4_process_found = True\n            break\n\n    if tts_dll and tts_dll.exists():\n        try:\n            powershell_cmd = [\"powershell\", \"-Command\", f\"(Get-AuthenticodeSignature '{tts_dll}').Status\"]\n            result = subprocess.run(powershell_cmd, capture_output=True, text=True, check=True)\n            status = result.stdout.strip()\n\n            if status == \"Valid\":\n                LOGGER.debug(f\"{tts_dll} is locally signed and valid.\")\n            else:\n                LOGGER.error(\n                    f\"As of season 12, the saapi64.dll must be locally signed. Follow all instructions in \"\n                    f\"{SETUP_INSTRUCTIONS_URL} to get the dll signed (specifically, run install_dll.bat). \"\n                    f\"It currently has a status of {status}\"\n                )\n        except subprocess.CalledProcessError as e:\n            LOGGER.error(f\"Error checking saapi64.dll signature: {e}\")\n\n    if not d4_process_found:\n        LOGGER.warning(\n            \"No process named Diablo IV.exe was found and unable to automatically determine if TTS DLL is installed.\"\n        )\n\n    if IniConfigLoader().advanced_options.disable_tts_warning:\n        LOGGER.debug(\"Disable TTS warning is enabled, skipping TTS local prefs check\")\n    else:\n        local_prefs = get_d4_local_prefs_file()\n        if local_prefs:\n            with Path(local_prefs).open(encoding=\"utf-8\") as file:\n                prefs = file.read()\n                if 'UseScreenReader \"1\"' not in prefs:\n                    LOGGER.error(\n                        f\"Use Screen Reader is not enabled in Accessibility Settings in D4. No items will be read. Read more about initial setup here: {SETUP_INSTRUCTIONS_URL}\"\n                    )\n                if 'UseThirdPartyReader \"1\"' not in prefs:\n                    LOGGER.error(\n                        f\"3rd Party Screen Reader is not enabled in Accessibility Settings in D4. No items will be read. Read more about initial setup here: {SETUP_INSTRUCTIONS_URL}\"\n                    )\n                if (\n                    'FontScale \"2\"' in prefs\n                    and IniConfigLoader().general.vision_mode_type == VisionModeType.highlight_matches\n                ):\n                    LOGGER.error(\n                        \"A font scale set to Large is not supported when using the highlight matches vision mode. Change to medium or small in the graphics options, or use the fast vision mode.\"\n                    )\n        else:\n            LOGGER.warning(\n                \"Unable to find a Diablo 4 local prefs file. Can't automatically check if TTS is configured properly in-game. \"\n                \"If d4lf is working without issue for you, you can disable this warning by enabling 'disable_tts_warning' in the Advanced settings.\"\n            )\n\n\ndef get_d4_local_prefs_file() -> Path | None:\n    all_potential_files: list[Path] = [\n        pathlib.Path.home() / \"Documents\" / \"Diablo IV\" / \"LocalPrefs.txt\",\n        pathlib.Path.home() / \"OneDrive\" / \"Documents\" / \"Diablo IV\" / \"LocalPrefs.txt\",\n        pathlib.Path.home() / \"OneDrive\" / \"MyDocuments\" / \"Diablo IV\" / \"LocalPrefs.txt\",\n    ]\n\n    existing_files: list[Path] = [file for file in all_potential_files if file.exists()]\n\n    if len(existing_files) == 0:\n        return None\n\n    if len(existing_files) == 1:\n        return existing_files[0]\n\n    most_recently_modified_file = existing_files[0]\n    for existing_file in existing_files[1:]:\n        if existing_file.stat().st_mtime > most_recently_modified_file.stat().st_mtime:\n            most_recently_modified_file = existing_file\n    return most_recently_modified_file\n\n\ndef hide_console():\n    \"\"\"Hide the console window (Windows only).\"\"\"\n    if sys.platform == \"win32\":\n        ctypes.windll.user32.ShowWindow(\n            ctypes.windll.kernel32.GetConsoleWindow(),\n            0,  # SW_HIDE\n        )\n\n\nif __name__ == \"__main__\":\n    if len(sys.argv) > 1 and sys.argv[1] == \"--autoupdate\":\n        src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True)\n        start_auto_update()\n\n    elif len(sys.argv) > 1 and sys.argv[1] == \"--autoupdatepost\":\n        src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True)\n        start_auto_update(postprocess=True)\n\n    elif len(sys.argv) > 1 and sys.argv[1] == \"--consoleonly\":\n        # Console-only mode: keep console visible\n        src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True)\n        main()\n\n    else:\n        # Enable stdout logging when running from source (for IDE terminal), hide console for compiled exe\n        running_from_source = not getattr(sys, \"frozen\", False)\n        if not running_from_source:\n            hide_console()\n        os.environ[\"QT_LOGGING_RULES\"] = \"qt.qpa.window=false\"\n        src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=running_from_source)\n\n        app = QApplication(sys.argv)\n        app.setWindowIcon(QIcon(str(ICON_PATH)))\n        # Has to be imported in line to avoid circular reference\n        from src.gui.unified_window import UnifiedMainWindow\n\n        window = UnifiedMainWindow()\n        window.show()\n        sys.exit(app.exec())\n"
  },
  {
    "path": "src/overlay.py",
    "content": "import logging\nimport threading\nimport tkinter as tk\n\nLOGGER = logging.getLogger(__name__)\n\nLOCK = threading.Lock()\n\n\nclass Overlay:\n    def __init__(self):\n        self.root = tk.Tk()\n        self.root.overrideredirect(True)\n        self.root.attributes(\"-topmost\", True)\n        self.root.attributes(\"-transparentcolor\", \"white\")\n        self.root.attributes(\"-alpha\", 1.0)\n        self.canvas = tk.Canvas(self.root, bg=\"white\", highlightthickness=0)\n        self.canvas.pack(fill=tk.BOTH, expand=True)\n        self.canvas.config(height=self.root.winfo_screenheight(), width=self.root.winfo_screenwidth())\n\n    def run(self):\n        self.root.mainloop()\n"
  },
  {
    "path": "src/paragon_overlay.py",
    "content": "\"\"\"Paragon overlay (tkinter).\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport configparser\nimport ctypes\nimport io\nimport logging\nimport queue\nimport re\nimport sys\nimport threading\nimport tkinter as tk\nfrom contextlib import suppress\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nfrom PIL import Image, ImageDraw, ImageFont\n\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.config.ui import ResManager\nfrom src.gui.importer.gui_common import BUILD_SOURCES, PLAYER_CLASSES\nfrom src.item.filter import Filter\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\nLOGGER = logging.getLogger(__name__)\n\n# =============================================================================\n# GLOBALS & UI THREAD HANDLING\n# =============================================================================\n\n_CURRENT_OVERLAY: ParagonOverlay | None = None\n_CLOSE_REQUESTED = threading.Event()\n_OVERLAY_LOCK = threading.Lock()\n\n_UI_THREAD: threading.Thread | None = None\n_UI_QUEUE: queue.Queue[tuple[object, threading.Event | None, dict[str, object]]] = queue.Queue()\n_UI_ROOT: tk.Tk | None = None\n_UI_READY = threading.Event()\n\n\ndef _tk_thread_main() -> None:\n    \"\"\"Own the dedicated Tk root and execute queued UI work on that thread.\"\"\"\n    global _UI_ROOT\n    # Create a hidden root window. The actual overlay is a Toplevel that is\n    # opened later, but Tk still needs one root that owns the event loop.\n    root = tk.Tk()\n    root.withdraw()\n    _UI_ROOT = root\n    _UI_READY.set()\n\n    def _pump_queue() -> None:\n        \"\"\"Run all queued UI callbacks and reschedule the queue pump.\"\"\"\n        while True:\n            try:\n                fn, done, box = _UI_QUEUE.get_nowait()\n            except queue.Empty:\n                break\n\n            try:\n                box[\"result\"] = fn()  # type: ignore[operator]\n            except Exception as exc:\n                box[\"error\"] = exc\n            finally:\n                if done:\n                    done.set()\n\n        root.after(25, _pump_queue)\n\n    root.after(0, _pump_queue)\n    root.mainloop()\n\n\ndef _ensure_ui_thread() -> None:\n    \"\"\"Start the shared Tk UI thread once and wait until it is ready.\"\"\"\n    global _UI_THREAD\n    if _UI_THREAD and _UI_THREAD.is_alive():\n        return\n    _UI_READY.clear()\n    _UI_THREAD = threading.Thread(target=_tk_thread_main, name=\"paragon-overlay-ui\", daemon=True)\n    _UI_THREAD.start()\n    if not _UI_READY.wait(timeout=5.0):\n        msg = \"Tk UI thread failed to init\"\n        raise RuntimeError(msg)\n\n\ndef _call_on_ui_thread(fn: object) -> object:\n    \"\"\"Execute a callback on the Tk thread and wait for its return value.\"\"\"\n    _ensure_ui_thread()\n    done, box = threading.Event(), {}\n    _UI_QUEUE.put((fn, done, box))\n    done.wait()\n    exc = box.get(\"error\")\n    if isinstance(exc, BaseException):\n        raise exc\n    return box.get(\"result\")\n\n\ndef _post_to_ui_thread(fn: object) -> None:\n    \"\"\"Queue work on the Tk thread without blocking the caller.\"\"\"\n    _ensure_ui_thread()\n    _UI_QUEUE.put((fn, None, {}))\n\n\ndef _is_alive(w: tk.Misc | None, mapped: bool = False) -> bool:\n    \"\"\"Helper to safely check if a widget exists (and optionally is mapped).\"\"\"\n    try:\n        return bool(w and w.winfo_exists() and (w.winfo_ismapped() if mapped else True))\n    except Exception:\n        return False\n\n\n# =============================================================================\n# THEME & CONSTANTS\n# =============================================================================\n\nTRANSPARENT_KEY = \"#ff00ff\"\nCARD_BG = \"#151515\"\nTEXT = \"#ffffff\"\nMUTED = \"#cfcfcf\"\nFS_ACCENT_GREEN = \"#34C410\"\nFS_ACCENT_BLUE = \"#56B4E9\"\nFS_ACCENT_GOLD = \"#cfa15b\"\nFS_GRID_COLOR = \"#3f3f3f\"\n\nGOLD = FS_ACCENT_GOLD\nSELECT_BG = \"#1f1f1f\"\nNODE_GREEN = FS_ACCENT_GREEN\nNODE_BLUE = FS_ACCENT_BLUE\nPANEL_W = 370\nGRID = 21\nNODES_LEN = GRID * GRID\n\nFS_PANEL_TITLE, FS_MODE_LABEL, FS_BUTTON, FS_BOARD_CARD = 13, 9, 12, 10\nFS_BUILDS_MENU, FS_SETTINGS_ICON, FS_SETTINGS_LABEL, FS_ZOOM_BTN, FS_HINT = (12, 13, 10, 15, 10)\nFS_CARD_FRAME, FS_GRID_FRAME = 1, 6\n\n\n# =============================================================================\n# UI FACTORY HELPERS\n# =============================================================================\n\n\ndef _tk_btn(parent: tk.Misc, text: str = \"\", cmd: Callable | None = None, **kw) -> tk.Button:\n    \"\"\"Creates a pre-styled Tkinter Button.\"\"\"\n    opts = {\n        \"bg\": CARD_BG,\n        \"fg\": TEXT,\n        \"activebackground\": SELECT_BG,\n        \"activeforeground\": GOLD,\n        \"bd\": 0,\n        \"highlightthickness\": 0,\n    }\n    opts.update(kw)\n    return tk.Button(parent, text=text, command=cmd, **opts)\n\n\ndef _tk_lbl(parent: tk.Misc, text: str = \"\", **kw) -> tk.Label:\n    \"\"\"Creates a pre-styled Tkinter Label.\"\"\"\n    opts = {\"bg\": CARD_BG, \"fg\": TEXT}\n    opts.update(kw)\n    return tk.Label(parent, text=text, **opts)\n\n\n# =============================================================================\n# WINDOWS DPI HELPERS\n# =============================================================================\n\n_TK_BASELINE_SCALING = 96 / 72\n\n\ndef _dpi_scale_for_widget(w: tk.Misc) -> float:\n    \"\"\"Read the effective DPI scale for a widget, falling back safely.\"\"\"\n    with suppress(Exception):\n        return float(ctypes.windll.user32.GetDpiForWindow(int(w.winfo_id()))) / 96.0\n    with suppress(Exception):\n        return float(w.tk.call(\"tk\", \"scaling\")) * 72 / 96.0\n    return 1.0\n\n\n# =============================================================================\n# SETTINGS & PROFILE LOADERS\n# =============================================================================\n\n\ndef _params_ini_path() -> Path:\n    \"\"\"Return the user-specific params.ini path and ensure the folder exists.\"\"\"\n    p = Path.home() / \".d4lf\"\n    p.mkdir(parents=True, exist_ok=True)\n    return p / \"params.ini\"\n\n\ndef _load_overlay_settings() -> dict[str, Any]:\n    \"\"\"Load persisted overlay state from params.ini.\n\n    Invalid or missing values are ignored so the overlay can continue with\n    sensible runtime defaults.\n    \"\"\"\n    ini = _params_ini_path()\n    if not ini.exists():\n        ini.write_text(\"\", encoding=\"utf-8\")\n    p = configparser.ConfigParser()\n    p.read(ini, encoding=\"utf-8\")\n    sec = p[\"paragon_overlay\"] if p.has_section(\"paragon_overlay\") else {}\n\n    def parse(k: str, t: type) -> Any:\n        \"\"\"Parse one INI value into the requested type or return None.\"\"\"\n        v = sec.get(k)\n        if not v:\n            return None\n        v = str(v).strip()\n        if t is bool:\n            if v.lower() in (\"true\", \"1\", \"yes\", \"on\"):\n                return True\n            if v.lower() in (\"false\", \"0\", \"no\", \"off\"):\n                return False\n            return None\n        try:\n            return t(v)\n        except Exception:\n            return None\n\n    return {\n        \"cell_size\": parse(\"cell_size\", int),\n        \"profile\": parse(\"profile\", str),\n        \"build_name\": parse(\"build_name\", str),\n        \"build_idx\": parse(\"build_idx\", int),\n        \"board_idx\": parse(\"board_idx\", int),\n        \"grid_x\": parse(\"grid_x\", int),\n        \"grid_y\": parse(\"grid_y\", int),\n        \"is_collapsed\": parse(\"is_collapsed\", bool),\n        \"cell_size_collapsed\": parse(\"cell_size_collapsed\", int),\n        \"grid_x_collapsed\": parse(\"grid_x_collapsed\", int),\n        \"grid_y_collapsed\": parse(\"grid_y_collapsed\", int),\n        \"grid_locked\": parse(\"grid_locked\", bool),\n        \"gold_frames\": parse(\"gold_frames\", bool),\n    }\n\n\ndef _save_overlay_settings(values: dict[str, Any]) -> None:\n    \"\"\"Persist the current overlay state without touching unrelated INI sections.\"\"\"\n    ini, p = _params_ini_path(), configparser.ConfigParser()\n    if not ini.exists():\n        ini.write_text(\"\", encoding=\"utf-8\")\n    p.read(ini, encoding=\"utf-8\")\n    if not p.has_section(\"paragon_overlay\"):\n        p.add_section(\"paragon_overlay\")\n    for k, v in values.items():\n        if v is not None:\n            p[\"paragon_overlay\"][str(k)] = str(v)\n    with ini.open(\"w\", encoding=\"utf-8\") as f:\n        p.write(f)\n\n\ndef _clamp_int(v: int | None, lo: int, hi: int, default: int) -> int:\n    \"\"\"Clamp an optional integer into a safe range, with a fallback default.\"\"\"\n    try:\n        return max(lo, min(hi, int(v))) if v is not None else default\n    except Exception:\n        return default\n\n\ndef _iter_paragon_payloads(paragon: object) -> list[dict[str, Any]]:\n    \"\"\"Normalize Paragon data so the rest of the loader can iterate one shape.\"\"\"\n    if isinstance(paragon, dict):\n        return [paragon]\n    if isinstance(paragon, list):\n        return [payload for payload in paragon if isinstance(payload, dict)]\n    return []\n\n\ndef _format_build_display_name(raw_name: object) -> str:\n    \"\"\"Convert stored build/profile names into a cleaner title-card label.\"\"\"\n    text = str(raw_name or \"\").strip()\n    if not text:\n        return \"\"\n\n    step_suffix = \"\"\n    if step_match := re.search(r\"(\\s+-\\s+Step\\s+\\d+)\\s*$\", text, flags=re.IGNORECASE):\n        step_suffix = step_match.group(1)\n        text = text[: step_match.start()].rstrip()\n\n    parts = [re.sub(r\"\\s+\", \" \", part).strip(\" _-\") for part in text.split(\"_\")]\n    parts = [part for part in parts if part]\n\n    if parts and parts[0].lower() in BUILD_SOURCES:\n        parts = parts[1:]\n    if parts and parts[0].lower() in PLAYER_CLASSES:\n        parts = parts[1:]\n\n    display_name = \" \".join(parts).strip()\n    if not display_name:\n        display_name = re.sub(r\"\\s+\", \" \", text.replace(\"_\", \" \")).strip()\n\n    return f\"{display_name}{step_suffix}\" if step_suffix else display_name\n\n\ndef _resolve_build_index(\n    builds: list[dict[str, Any]],\n    *,\n    profile_name: str | None = None,\n    build_name: str | None = None,\n    fallback_idx: int | None = None,\n) -> int:\n    \"\"\"Resolve the selected build from persisted identifiers with index fallback.\"\"\"\n    if build_name:\n        for idx, build in enumerate(builds):\n            if build.get(\"name\") != build_name:\n                continue\n            if profile_name and build.get(\"profile\") != profile_name:\n                continue\n            return idx\n\n    if profile_name:\n        for idx, build in enumerate(builds):\n            if build.get(\"profile\") == profile_name:\n                return idx\n\n    return _clamp_int(fallback_idx, 0, max(0, len(builds) - 1), 0)\n\n\ndef load_builds_from_path(preset_path: str | None = None) -> list[dict[str, Any]]:\n    \"\"\"Collect all available builds and flatten them into overlay-friendly rows.\n\n    Each returned entry contains the visible build name, its board list, and the\n    source profile name that is later used for grouping inside the popup.\n    \"\"\"\n    _ = preset_path\n    paragon_filters = Filter().get_paragon_filters()\n\n    builds: list[dict[str, Any]] = []\n    for pname, paragon_payload in paragon_filters.items():\n        # A profile can contain one or many Paragon payloads, and each payload can\n        # contain multiple step states. The overlay shows each step as its own\n        # selectable build entry.\n        for payload in _iter_paragon_payloads(paragon_payload):\n            payload_steps = payload.get(\"ParagonBoardsList\", [])\n            if payload_steps and isinstance(payload_steps, list) and isinstance(payload_steps[0], list):\n                steps = [step for step in payload_steps if isinstance(step, list) and step]\n            elif isinstance(payload_steps, list) and payload_steps:\n                # Older/smaller payloads may store only one board-state list instead\n                # of a list-of-steps. Wrap that shape so the overlay can iterate one way.\n                steps = [payload_steps]\n            else:\n                steps = []\n            bname = payload.get(\"Name\") or payload.get(\"name\") or \"Unknown Build\"\n            # Newest step first keeps the build selector aligned with the latest\n            # imported planner state while still exposing earlier progression steps.\n            for idx in range(len(steps) - 1, -1, -1):\n                sname = f\"{bname} - Step {idx + 1}\" if len(steps) > 1 else bname\n                builds.append({\"name\": sname, \"boards\": steps[idx], \"profile\": pname})\n    return builds\n\n\n# =============================================================================\n# GRID DATA HELPERS\n# =============================================================================\n\n\ndef parse_rotation(rot_str: str) -> int:\n    \"\"\"Extract and sanitize the supported board rotation angle.\"\"\"\n    m = re.search(r\"(\\d+)\", rot_str or \"\")\n    deg = int(m.group(1)) % 360 if m else 0\n    return deg if deg in (0, 90, 180, 270) else 0\n\n\ndef nodes_to_grid(nodes: list[int] | list[bool]) -> list[list[bool]]:\n    \"\"\"Convert the flat 21x21 node list into a 2D boolean grid.\"\"\"\n    return [[bool(nodes[y * GRID + x]) for x in range(GRID)] for y in range(GRID)]\n\n\n# =============================================================================\n# DATA CLASSES\n# =============================================================================\n\n\n@dataclass(slots=True)\nclass OverlayConfig:\n    \"\"\"Runtime configuration for overlay size, scaling, and persisted state.\"\"\"\n\n    cell_size: int = 24\n    grid_x_default: int = PANEL_W + 24\n    grid_y_default: int = 24\n\n    cell_size_collapsed: int = 16\n    grid_x_collapsed_default: int = 600\n    grid_y_collapsed_default: int = 300\n\n    ui_scale: float = 1.0\n    panel_w: int = PANEL_W\n    poll_ms: int = 500\n    window_alpha: float = 0.86\n\n    is_collapsed: bool = False\n    grid_locked: bool = False\n    gold_frames: bool = False\n\n\n# =============================================================================\n# PARAGON OVERLAY CLASS\n# =============================================================================\n\n\nclass ParagonOverlay(tk.Toplevel):\n    \"\"\"Tkinter paragon overlay window.\"\"\"\n\n    def __init__(\n        self,\n        parent: tk.Misc,\n        builds: list[dict[str, Any]],\n        *,\n        cfg: OverlayConfig | None = None,\n        on_close: Callable[[], None] | None = None,\n    ) -> None:\n        \"\"\"Initialize the overlay window, restore settings, and build the UI.\"\"\"\n        super().__init__(parent)\n        self._settings = _load_overlay_settings()\n        self._cfg = cfg or OverlayConfig()\n        self._apply_dpi_scaling()\n\n        # Persisted size/position values are trusted only after clamping so a bad\n        # INI value cannot create an unusable overlay.\n        self._cfg.cell_size = _clamp_int(self._settings.get(\"cell_size\"), 10, 80, self._cfg.cell_size)\n        self._cfg.cell_size_collapsed = _clamp_int(\n            self._settings.get(\"cell_size_collapsed\"), 8, 50, self._cfg.cell_size_collapsed\n        )\n        for key, attr in (\n            (\"is_collapsed\", \"is_collapsed\"),\n            (\"grid_locked\", \"grid_locked\"),\n            (\"gold_frames\", \"gold_frames\"),\n        ):\n            val = self._settings.get(key)\n            if isinstance(val, bool):\n                setattr(self._cfg, attr, val)\n\n        self._on_close = on_close\n        self._config_loader = IniConfigLoader()\n        self._config_listener = self._on_config_changed\n        self._config_loader.register_change_listener(self._config_listener)\n        self._cam = Cam()\n        self._res = ResManager()\n        self.builds = list(builds)\n\n        # Restore the previously selected build by its persisted identity first.\n        # Falling back to profile and then index keeps older settings compatible.\n        self.current_build_idx = _resolve_build_index(\n            self.builds,\n            profile_name=self._settings.get(\"profile\"),\n            build_name=self._settings.get(\"build_name\"),\n            fallback_idx=self._settings.get(\"build_idx\"),\n        )\n        self.boards = self.builds[self.current_build_idx][\"boards\"] if self.builds else []\n        self.selected_board_idx = _clamp_int(self._settings.get(\"board_idx\"), 0, max(0, len(self.boards) - 1), 0)\n\n        gx_val = self._settings.get(\"grid_x\")\n        gy_val = self._settings.get(\"grid_y\")\n        gxc_val = self._settings.get(\"grid_x_collapsed\")\n        gyc_val = self._settings.get(\"grid_y_collapsed\")\n        self.grid_x = gx_val if isinstance(gx_val, int) else (self._cfg.panel_w + 24)\n        self.grid_y = gy_val if isinstance(gy_val, int) else 24\n        self.grid_x_collapsed = gxc_val if isinstance(gxc_val, int) else self._cfg.grid_x_collapsed_default\n        self.grid_y_collapsed = gyc_val if isinstance(gyc_val, int) else self._cfg.grid_y_collapsed_default\n\n        (self._last_roi, self._last_res, self._border_rect, self._dragging_grid, self._border_grab) = (\n            None,\n            None,\n            None,\n            False,\n            12,\n        )\n\n        self.title(\"D4LF Paragon Overlay\")\n        self.attributes(\"-topmost\", True)\n        with suppress(tk.TclError):\n            self.attributes(\"-alpha\", float(self._cfg.window_alpha))\n            self.overrideredirect(True)\n            self.wm_attributes(\"-transparentcolor\", TRANSPARENT_KEY)\n        self.configure(bg=TRANSPARENT_KEY)\n        self.protocol(\"WM_DELETE_WINDOW\", self.close)\n\n        self._build_ui()\n        self._bind_events()\n        self._apply_geometry()\n        self._refresh_lists()\n        self.redraw()\n\n        self._warmup_after_id = self.after(600, self._warmup_settings_assets)\n        self.after(self._cfg.poll_ms, self._poll_window_state)\n        self.after(50, self._poll_close_request)\n\n    def _apply_dpi_scaling(self) -> None:\n        \"\"\"Apply DPI-aware sizing before widgets are created.\"\"\"\n        with suppress(Exception):\n            self.tk.call(\"tk\", \"scaling\", _TK_BASELINE_SCALING)\n        scale = _dpi_scale_for_widget(self) * float(self._cfg.ui_scale or 1.0)\n        self._cfg.ui_scale = eff = max(0.75, min(4.0, float(scale)))\n\n        # The panel width always scales with DPI. Cell sizes only scale\n        # automatically when the user has not stored an explicit override.\n        self._cfg.panel_w = round(self._cfg.panel_w * eff)\n        if self._settings.get(\"cell_size\") is None:\n            self._cfg.cell_size = round(self._cfg.cell_size * eff)\n        if self._settings.get(\"cell_size_collapsed\") is None:\n            self._cfg.cell_size_collapsed = round(self._cfg.cell_size_collapsed * eff)\n\n    # --- UI LAYOUT ---\n\n    def _build_ui(self) -> None:\n        \"\"\"Create the left control panel and the transparent drawing canvas.\"\"\"\n        accent = self._accent_frame_color()\n        outer = tk.Frame(self, bg=TRANSPARENT_KEY)\n        outer.pack(fill=\"both\", expand=True)\n\n        # The canvas owns all grid drawing. The left panel is a separate Frame\n        # placed on top of the same transparent outer container.\n        self.canvas = tk.Canvas(outer, highlightthickness=0, bg=TRANSPARENT_KEY)\n        self.canvas.pack(fill=\"both\", expand=True)\n\n        self.left = tk.Frame(outer, bg=TRANSPARENT_KEY)\n        self.left.place(x=0, y=0, width=self._cfg.panel_w, relheight=1.0)\n\n        # Title Card\n        self.card_title = tk.Frame(\n            self.left,\n            bg=CARD_BG,\n            highlightthickness=self._accent_frame_thickness(),\n            highlightbackground=accent,\n            highlightcolor=accent,\n        )\n        self.card_title.pack(\n            fill=\"x\",\n            padx=int(10 * self._cfg.ui_scale),\n            pady=(int(10 * self._cfg.ui_scale), int(8 * self._cfg.ui_scale)),\n        )\n\n        title_row = tk.Frame(self.card_title, bg=CARD_BG)\n        title_row.pack(\n            fill=\"both\", expand=True, padx=int(12 * self._cfg.ui_scale), pady=(0, int(4 * self._cfg.ui_scale))\n        )\n\n        self.lbl_title = _tk_lbl(\n            title_row,\n            font=(\"Segoe UI\", int(FS_PANEL_TITLE * self._cfg.ui_scale), \"bold\"),\n            anchor=\"w\",\n            wraplength=max(200, self._cfg.panel_w - 40),\n            justify=\"left\",\n        )\n        self.lbl_title.pack(side=\"left\", fill=\"x\", expand=True)\n\n        mode_frame = tk.Frame(self.card_title, bg=CARD_BG)\n        mode_frame.pack(fill=\"x\", padx=int(12 * self._cfg.ui_scale))\n\n        self.lbl_mode = _tk_lbl(\n            mode_frame,\n            text=\"Compact View\" if self._cfg.is_collapsed else \"Full View\",\n            fg=MUTED,\n            font=(\"Segoe UI\", int(FS_MODE_LABEL * self._cfg.ui_scale)),\n            anchor=\"w\",\n        )\n        self.lbl_mode.pack(side=\"left\")\n\n        self.btn_view_switch = _tk_btn(\n            mode_frame,\n            text=\"⤢\" if self._cfg.is_collapsed else \"⤡\",\n            cmd=self._toggle_collapsed_mode,\n            font=(\"Segoe UI\", int(FS_BUTTON * self._cfg.ui_scale), \"bold\"),\n            padx=int(8 * self._cfg.ui_scale),\n            pady=int(2 * self._cfg.ui_scale),\n        )\n        self.btn_view_switch.pack(side=\"left\", padx=(int(8 * self._cfg.ui_scale), 0))\n\n        # Buttons Card\n        self.card_buttons = tk.Frame(\n            self.left,\n            bg=CARD_BG,\n            highlightthickness=self._accent_frame_thickness(),\n            highlightbackground=accent,\n            highlightcolor=accent,\n        )\n        self.card_buttons.pack(fill=\"x\", padx=int(10 * self._cfg.ui_scale), pady=(0, int(8 * self._cfg.ui_scale)))\n\n        btn_cont = tk.Frame(self.card_buttons, bg=CARD_BG)\n        btn_cont.pack(expand=True, fill=\"both\", padx=int(12 * self._cfg.ui_scale), pady=int(8 * self._cfg.ui_scale))\n\n        self.btn_settings = _tk_btn(\n            btn_cont,\n            text=\"ParagonOverlay⚙ ▼\",\n            cmd=self._show_settings_dropdown,\n            font=(\"Segoe UI\", int(FS_BUTTON * self._cfg.ui_scale), \"bold\"),\n            padx=int(10 * self._cfg.ui_scale),\n            pady=int(6 * self._cfg.ui_scale),\n        )\n        self.btn_settings.pack(side=\"left\", padx=int(4 * self._cfg.ui_scale))\n\n        self.btn_build_menu = _tk_btn(\n            btn_cont,\n            text=\"Builds ▼\",\n            cmd=self._show_build_menu,\n            font=(\"Segoe UI\", int(FS_BUTTON * self._cfg.ui_scale), \"bold\"),\n            padx=int(12 * self._cfg.ui_scale),\n            pady=int(6 * self._cfg.ui_scale),\n        )\n        self.btn_build_menu.pack(side=\"right\", padx=int(5 * self._cfg.ui_scale))\n\n        # Boards Scroll Area\n        self.boards_canvas = tk.Canvas(self.left, bg=TRANSPARENT_KEY, highlightthickness=0)\n        self.boards_canvas.pack(\n            fill=\"both\", expand=True, padx=int(10 * self._cfg.ui_scale), pady=(0, int(12 * self._cfg.ui_scale))\n        )\n        self.board_container = tk.Frame(self.boards_canvas, bg=TRANSPARENT_KEY)\n        self._boards_window_id = self.boards_canvas.create_window((0, 0), window=self.board_container, anchor=\"nw\")\n\n        self.board_container.bind(\n            \"<Configure>\", lambda *_: self.boards_canvas.configure(scrollregion=self.boards_canvas.bbox(\"all\"))\n        )\n        self.boards_canvas.bind(\n            \"<Configure>\", lambda e: self.boards_canvas.itemconfigure(self._boards_window_id, width=int(e.width))\n        )\n\n    def _bind_events(self) -> None:\n        \"\"\"Bind scrolling and drag interactions after widgets exist.\"\"\"\n        for ev in (\"<MouseWheel>\", \"<Button-4>\", \"<Button-5>\"):\n            self.boards_canvas.bind(ev, self._on_boards_mousewheel)\n        self.canvas.bind(\"<ButtonPress-1>\", self._on_grid_drag_start)\n        self.canvas.bind(\"<B1-Motion>\", self._on_grid_drag_move)\n        self.canvas.bind(\"<ButtonRelease-1>\", self._on_grid_drag_end)\n\n    # --- POLLING & STATE MANAGEMENT ---\n\n    def _poll_close_request(self) -> None:\n        \"\"\"Check for external close requests coming from non-UI threads.\"\"\"\n        if _CLOSE_REQUESTED.is_set():\n            _CLOSE_REQUESTED.clear()\n            self.close()\n            return\n        if _is_alive(self):\n            self.after(50, self._poll_close_request)\n\n    def _poll_window_state(self) -> None:\n        \"\"\"Re-apply geometry when the tracked game window changes size or ROI.\"\"\"\n        try:\n            roi, res = self._get_cam_roi(), self._get_resolution()\n            if roi != self._last_roi or res != self._last_res:\n                self._last_roi, self._last_res = roi, res\n                self._apply_geometry()\n                self.redraw()\n        finally:\n            self.after(self._cfg.poll_ms, self._poll_window_state)\n\n    def _on_config_changed(self, changed_keys: set[str] | frozenset[str]) -> None:\n        \"\"\"Apply live overlay updates after runtime config changes.\"\"\"\n        if \"general.colorblind_mode\" not in changed_keys:\n            return\n        _post_to_ui_thread(self._apply_live_colorblind_change)\n\n    def _apply_live_colorblind_change(self) -> None:\n        \"\"\"Refresh overlay colors on the Tk UI thread after a colorblind change.\"\"\"\n        if not _is_alive(self):\n            return\n        self._apply_accent_frames(force=True)\n        self.redraw()\n        LOGGER.info(\n            \"Applied live Paragon overlay colorblind mode: %s\",\n            \"on\" if bool(getattr(self._config_loader.general, \"colorblind_mode\", False)) else \"off\",\n        )\n\n    def _select_build(self, idx: int) -> None:\n        \"\"\"Activate a build, reset the selected board, and redraw the overlay.\"\"\"\n        if not self.builds:\n            return\n        self.current_build_idx = _clamp_int(idx, 0, max(0, len(self.builds) - 1), 0)\n        self.boards = self.builds[self.current_build_idx][\"boards\"] if self.builds else []\n        self.selected_board_idx = 0\n        self._refresh_lists()\n        self.redraw()\n        self._persist_state()\n\n    def _toggle_grid_lock(self) -> None:\n        \"\"\"Enable or disable all grid movement and zoom controls.\"\"\"\n        self._cfg.grid_locked = not self._cfg.grid_locked\n        self._persist_state()\n\n    def _toggle_gold_frames(self) -> None:\n        \"\"\"Toggle the optional gold accent color override for all frames.\"\"\"\n        self._cfg.gold_frames = not getattr(self._cfg, \"gold_frames\", False)\n        self._persist_state()\n        self._apply_accent_frames(force=True)\n        self.redraw()\n\n    def _reset_grid_defaults(self) -> None:\n        \"\"\"Restore default grid size and position for both overlay modes.\"\"\"\n        s = float(self._cfg.ui_scale or 1.0)\n        self._cfg.cell_size, self._cfg.cell_size_collapsed = (\n            _clamp_int(round(24 * s), 10, 80, self._cfg.cell_size),\n            _clamp_int(round(16 * s), 8, 50, self._cfg.cell_size_collapsed),\n        )\n        self.grid_x, self.grid_y = self._cfg.panel_w + round(24 * s), round(24 * s)\n        self.grid_x_collapsed, self.grid_y_collapsed = (\n            self._cfg.grid_x_collapsed_default,\n            self._cfg.grid_y_collapsed_default,\n        )\n        self._persist_state()\n        self.redraw()\n\n    def _accent_frame_color(self) -> str:\n        \"\"\"Resolve the current accent color from settings and colorblind mode.\"\"\"\n        if getattr(self._cfg, \"gold_frames\", False):\n            return GOLD\n        try:\n            return NODE_BLUE if bool(getattr(IniConfigLoader().general, \"colorblind_mode\", False)) else NODE_GREEN\n        except Exception:\n            return NODE_GREEN\n\n    def _accent_frame_thickness(self) -> int:\n        \"\"\"Return the scaled border width for cards and popup frames.\"\"\"\n        return max(1, round(FS_CARD_FRAME * float(self._cfg.ui_scale or 1.0)))\n\n    def _grid_frame_thickness(self) -> int:\n        \"\"\"Return the scaled outer border width for the rendered node grid.\"\"\"\n        return max(1, round(FS_GRID_FRAME * float(self._cfg.ui_scale or 1.0)))\n\n    def _apply_accent_frames(self, *, force: bool = False) -> None:\n        \"\"\"Refresh accent borders on all existing cards and popups.\"\"\"\n        c = self._accent_frame_color()\n        if not force and getattr(self, \"_accent_frame_last\", None) == c:\n            return\n        self._accent_frame_last, th = c, self._accent_frame_thickness()\n\n        for w in (getattr(self, \"card_title\", None), getattr(self, \"card_buttons\", None)):\n            if _is_alive(w):\n                with suppress(Exception):\n                    w.configure(highlightthickness=th, highlightbackground=c, highlightcolor=c)\n\n        bc = getattr(self, \"board_container\", None)\n        if _is_alive(bc):\n            for child in bc.winfo_children():\n                if isinstance(child, tk.Frame):\n                    with suppress(Exception):\n                        child.configure(highlightthickness=th, highlightbackground=c, highlightcolor=c)\n\n        for p in (\"_settings_popup\", \"_build_popup\"):\n            if _is_alive(getattr(self, p, None)):\n                getattr(self, p).configure(highlightthickness=th, highlightbackground=c, highlightcolor=c)\n\n    def _reload_profiles(self) -> None:\n        \"\"\"Reload build data from disk and keep the current selection if possible.\"\"\"\n        try:\n            if not (new_builds := load_builds_from_path()):\n                return\n            current_build = self.builds[self.current_build_idx] if self.builds else {}\n            self.builds = new_builds\n            self.current_build_idx = _resolve_build_index(\n                self.builds,\n                profile_name=current_build.get(\"profile\"),\n                build_name=current_build.get(\"name\"),\n                fallback_idx=self.current_build_idx,\n            )\n            self.boards = self.builds[self.current_build_idx][\"boards\"] if self.builds else []\n            self.selected_board_idx = min(self.selected_board_idx, max(0, len(self.boards) - 1))\n            self._refresh_lists()\n            self.redraw()\n            self._persist_state()\n        except Exception:\n            LOGGER.exception(\"Failed to reload profiles\")\n\n    # --- DROPDOWNS: BUILDS & SETTINGS ---\n\n    def _is_descendant(self, child: tk.Misc, parent: tk.Misc) -> bool:\n        \"\"\"Return True when a widget belongs to the popup subtree.\"\"\"\n        w: tk.Misc | None = child\n        while w:\n            if w is parent:\n                return True\n            try:\n                w = getattr(w, \"master\", None)\n                if not isinstance(w, tk.Misc):\n                    break\n            except Exception:\n                break\n        return False\n\n    def _close_popup(self, attr_name: str, btn_widget: tk.Button, escape_id_attr: str, click_id_attr: str) -> None:\n        \"\"\"Hide one popup and remove its temporary global event bindings.\"\"\"\n        popup = getattr(self, attr_name, None)\n        if popup:\n            with suppress(Exception):\n                popup.place_forget()\n        with suppress(Exception):\n            btn_widget.config(fg=TEXT)\n        for attr, evt in ((click_id_attr, \"<Button-1>\"), (escape_id_attr, \"<Escape>\")):\n            if bid := getattr(self, attr, None):\n                with suppress(Exception):\n                    self.unbind(evt, bid)\n                setattr(self, attr, None)\n\n    def _handle_global_click(self, e: tk.Event, attr_name: str, btn_widget: tk.Button, close_func: Callable) -> None:\n        \"\"\"Close a popup when the user clicks outside of it and its button.\"\"\"\n        popup = getattr(self, attr_name, None)\n        if not _is_alive(popup, mapped=True):\n            return\n        w = None\n        with suppress(Exception):\n            w = self.winfo_containing(e.x_root, e.y_root)\n        if not w or (w is not btn_widget and not self._is_descendant(w, popup)):\n            close_func()\n\n    def _close_build_dropdown(self) -> None:\n        \"\"\"Destroy the floating builds popup and remove its temporary bindings.\"\"\"\n        popup = getattr(self, \"_build_popup\", None)\n        self._build_popup = None\n        self._build_popup_refresh = None\n\n        if popup:\n            with suppress(Exception):\n                popup.destroy()\n\n        with suppress(Exception):\n            self.btn_build_menu.config(fg=TEXT)\n\n        for attr, evt in ((\"_build_popup_bind_id\", \"<Button-1>\"), (\"_build_popup_escape_bind_id\", \"<Escape>\")):\n            if bid := getattr(self, attr, None):\n                with suppress(Exception):\n                    self.unbind(evt, bid)\n                setattr(self, attr, None)\n\n    def _close_settings_dropdown(self) -> None:\n        \"\"\"Hide the anchored settings popup and remove its temporary bindings.\"\"\"\n        self._close_popup(\n            \"_settings_popup\", self.btn_settings, \"_settings_popup_escape_bind_id\", \"_settings_popup_bind_id\"\n        )\n\n    def _show_dropdown(\n        self,\n        popup_attr: str,\n        btn_widget: tk.Button,\n        build_func: Callable,\n        close_func: Callable,\n        escape_attr: str,\n        click_attr: str,\n        click_handler: Callable,\n    ) -> None:\n        \"\"\"Open or close one of the overlay popups and position it near its button.\n\n        This shared helper only manages in-overlay ``Frame`` popups. The builds\n        menu now uses its own ``Toplevel`` because it may need more width than the\n        overlay itself can provide.\n        \"\"\"\n        self._close_build_dropdown()\n\n        popup = getattr(self, popup_attr, None)\n        if _is_alive(popup, mapped=True):\n            close_func()\n            return\n\n        # The settings popup uses lock icons that may be generated lazily.\n        # Warm them up once before the popup is shown so the first open feels\n        # instant and does not flicker.\n        if getattr(self, \"_warmup_after_id\", None):\n            with suppress(Exception):\n                self.after_cancel(self._warmup_after_id)\n            self._warmup_after_id = None\n        if not hasattr(self, \"_lock_img_cache\"):\n            self._warmup_settings_assets()\n\n        if not _is_alive(popup):\n            c = self._accent_frame_color()\n            popup = tk.Frame(\n                self,\n                bg=CARD_BG,\n                bd=0,\n                highlightthickness=self._accent_frame_thickness(),\n                highlightbackground=c,\n                highlightcolor=c,\n            )\n            setattr(self, popup_attr, popup)\n            # The builder returns a refresh callback. We keep that callback so the\n            # popup content can be rebuilt later without re-creating the container.\n            setattr(self, f\"{popup_attr}_refresh\", build_func(popup))\n\n        self._apply_accent_frames()\n        if callable(refresh := getattr(self, f\"{popup_attr}_refresh\", None)):\n            refresh()\n\n        # First place the popup off-screen and let Tk calculate the requested size.\n        # This avoids a visible resize flash before we know the final width/height.\n        popup.place(x=-9999, y=-9999)\n        self.update_idletasks()\n        popup.update_idletasks()\n        s = self._cfg.ui_scale\n        pw = min(max(1, popup.winfo_reqwidth()), max(1, self.winfo_width() - int(8 * s)))\n        ph = popup.winfo_reqheight()\n\n        # Start by aligning the popup below the button. If it would overflow the\n        # overlay bounds, move it left or above the button instead.\n        x, y = (\n            btn_widget.winfo_rootx() - self.winfo_rootx(),\n            btn_widget.winfo_rooty() - self.winfo_rooty() + btn_widget.winfo_height() + int(4 * s),\n        )\n        if x + pw > self.winfo_width():\n            x = max(0, self.winfo_width() - pw - int(4 * s))\n        if y + ph > self.winfo_height():\n            y = max(0, btn_widget.winfo_rooty() - self.winfo_rooty() - ph - int(4 * s))\n\n        popup.place(x=x, y=y, width=pw)\n        popup.lift()\n        with suppress(Exception):\n            btn_widget.config(fg=GOLD)\n\n        def _arm() -> None:\n            # The global bindings are attached only while the popup is open.\n            # Clicking outside or pressing Escape closes the current popup.\n            if not getattr(self, click_attr, None):\n                setattr(self, click_attr, self.bind(\"<Button-1>\", click_handler, add=\"+\"))\n            if not getattr(self, escape_attr, None):\n                setattr(self, escape_attr, self.bind(\"<Escape>\", lambda *_: close_func(), add=\"+\"))\n\n        self.after_idle(_arm)\n\n    def _virtual_screen_bounds(self) -> tuple[int, int, int, int]:\n        \"\"\"Return the virtual desktop bounds used for floating popup placement.\"\"\"\n        x = y = 0\n        w = self.winfo_screenwidth()\n        h = self.winfo_screenheight()\n\n        with suppress(Exception):\n            x = int(self.winfo_vrootx())\n            y = int(self.winfo_vrooty())\n            w = int(self.winfo_vrootwidth())\n            h = int(self.winfo_vrootheight())\n\n        return x, y, w, h\n\n    def _show_build_menu(self) -> None:\n        \"\"\"Open the floating build selector popup when build data is available.\"\"\"\n        if not self.builds:\n            return\n\n        self._close_settings_dropdown()\n        popup = getattr(self, \"_build_popup\", None)\n        if _is_alive(popup, mapped=True):\n            self._close_build_dropdown()\n            return\n\n        if not _is_alive(popup):\n            c = self._accent_frame_color()\n            popup = tk.Toplevel(self)\n            popup.withdraw()\n            popup.configure(\n                bg=CARD_BG,\n                bd=0,\n                highlightthickness=self._accent_frame_thickness(),\n                highlightbackground=c,\n                highlightcolor=c,\n            )\n            with suppress(tk.TclError):\n                popup.overrideredirect(True)\n                popup.attributes(\"-topmost\", True)\n            with suppress(Exception):\n                popup.transient(self)\n            popup.resizable(False, False)\n            popup.bind(\"<Escape>\", lambda *_: self._close_build_dropdown())\n            self._build_popup = popup\n            self._build_popup_refresh = self._build_build_popup(popup)\n\n        self._apply_accent_frames()\n        if callable(refresh := getattr(self, \"_build_popup_refresh\", None)):\n            refresh()\n\n        popup.update_idletasks()\n        s = self._cfg.ui_scale\n        margin = int(8 * s)\n        popup_width = max(popup.winfo_reqwidth(), self._measure_build_popup_width(popup))\n        vx, vy, vw, vh = self._virtual_screen_bounds()\n        pw = min(max(1, popup_width), max(1, vw - (margin * 2)))\n        ph = popup.winfo_reqheight()\n\n        x = self.btn_build_menu.winfo_rootx()\n        y = self.btn_build_menu.winfo_rooty() + self.btn_build_menu.winfo_height() + int(4 * s)\n\n        if x + pw > vx + vw - margin:\n            x = max(vx + margin, (vx + vw) - pw - margin)\n        x = max(x, vx + margin)\n        if y + ph > vy + vh - margin:\n            y = max(vy + margin, self.btn_build_menu.winfo_rooty() - ph - int(4 * s))\n\n        popup.geometry(f\"{pw}x{ph}+{x}+{y}\")\n        popup.deiconify()\n        popup.lift()\n        with suppress(Exception):\n            popup.focus_force()\n        with suppress(Exception):\n            self.btn_build_menu.config(fg=GOLD)\n\n        def _arm() -> None:\n            # The overlay keeps the outside-click handler because the popup itself\n            # is now a separate window. Escape is bound on both windows so the\n            # shortcut still works regardless of which one currently has focus.\n            if not getattr(self, \"_build_popup_bind_id\", None):\n                self._build_popup_bind_id = self.bind(\n                    \"<Button-1>\",\n                    lambda e: self._handle_global_click(\n                        e, \"_build_popup\", self.btn_build_menu, self._close_build_dropdown\n                    ),\n                    add=\"+\",\n                )\n            if not getattr(self, \"_build_popup_escape_bind_id\", None):\n                self._build_popup_escape_bind_id = self.bind(\n                    \"<Escape>\", lambda *_: self._close_build_dropdown(), add=\"+\"\n                )\n\n        self.after_idle(_arm)\n\n    def _show_settings_dropdown(self) -> None:\n        \"\"\"Open the settings popup anchored to the settings button.\"\"\"\n        self._show_dropdown(\n            \"_settings_popup\",\n            self.btn_settings,\n            self._build_settings_popup,\n            self._close_settings_dropdown,\n            \"_settings_popup_escape_bind_id\",\n            \"_settings_popup_bind_id\",\n            lambda e: self._handle_global_click(e, \"_settings_popup\", self.btn_settings, self._close_settings_dropdown),\n        )\n\n    def _measure_build_popup_width(self, popup: tk.Misc) -> int:\n        \"\"\"Return a text-driven width for the floating builds popup.\n\n        The first ``Toplevel`` version still relied on ``winfo_reqwidth()`` from the\n        scrollable widget tree. That is not reliable here because the canvas/embedded\n        frame can already be width-constrained before the popup geometry is finalized,\n        so long build names may report a smaller requested width than their real text\n        width. Instead, measure the visible label/button text directly via Tk's font\n        engine and then add the known padding, scrollbar, and container margins.\n        \"\"\"\n        scale = self._cfg.ui_scale\n        text_width = 0\n        scrollbar_width = 0\n        stack: list[tk.Misc] = [popup]\n\n        while stack:\n            widget = stack.pop()\n            stack.extend(widget.winfo_children())\n\n            with suppress(Exception):\n                if isinstance(widget, tk.Scrollbar):\n                    scrollbar_width = max(scrollbar_width, int(widget.winfo_reqwidth()))\n                    continue\n\n                if not isinstance(widget, tk.Button | tk.Label):\n                    continue\n\n                raw_text = str(widget.cget(\"text\") or \"\")\n                if not raw_text:\n                    continue\n\n                measured = int(widget.tk.call(\"font\", \"measure\", widget.cget(\"font\"), raw_text))\n                padx = int(widget.cget(\"padx\"))\n                border = int(widget.cget(\"bd\")) + int(widget.cget(\"highlightthickness\"))\n\n                # Add a small safety buffer so bold glyph overhang and anti-aliased\n                # text do not end up visually touching the right edge.\n                text_width = max(text_width, measured + (padx * 2) + (border * 2) + int(18 * scale))\n\n        outer_padding = int(24 * scale)\n        scrollbar_gap = int(8 * scale) if scrollbar_width else 0\n        return max(1, text_width + outer_padding + scrollbar_width + scrollbar_gap)\n\n    def _build_build_popup(self, host: tk.Misc) -> Any:\n        \"\"\"Create the scrollable builds popup and return its refresh callback.\"\"\"\n        scale = self._cfg.ui_scale\n        c = tk.Frame(host, bg=CARD_BG, padx=int(12 * scale), pady=int(10 * scale))\n        c.pack(fill=\"both\", expand=True)\n        max_h = int(360 * scale)\n\n        # Canvas + inner frame is the standard Tk pattern for a scrollable list.\n        # The canvas handles scrolling, while the inner frame holds the real widgets.\n        cv = tk.Canvas(c, bg=CARD_BG, highlightthickness=0, bd=0, height=max_h)\n        cv.pack(side=\"left\", fill=\"both\", expand=True)\n        sb = tk.Scrollbar(c, orient=\"vertical\", command=cv.yview)\n        sb.pack(side=\"right\", fill=\"y\")\n        cv.configure(yscrollcommand=sb.set)\n        lf = tk.Frame(cv, bg=CARD_BG)\n        wid = cv.create_window((0, 0), window=lf, anchor=\"nw\")\n\n        # Keep the canvas scroll region and embedded frame width synchronized with\n        # the real content size.\n        lf.bind(\"<Configure>\", lambda *_: cv.configure(scrollregion=cv.bbox(\"all\")))\n        cv.bind(\"<Configure>\", lambda e: cv.itemconfigure(wid, width=int(e.width)))\n\n        def _ref():\n            # Rebuild the visible list from scratch. This is simple and reliable for\n            # the current popup size and allows highlighting/grouping to stay in sync\n            # with the current overlay state.\n            for w in lf.winfo_children():\n                w.destroy()\n            grps: dict[str, list[tuple[int, dict[str, Any]]]] = {}\n            for i, b in enumerate(self.builds):\n                grps.setdefault(str(b.get(\"profile\") or \"Ungrouped\"), []).append((i, b))\n            mul = len(grps) > 1\n\n            for p in sorted(grps):\n                if mul:\n                    _tk_lbl(\n                        lf,\n                        text=p,\n                        fg=MUTED,\n                        font=(\"Segoe UI\", int(FS_BUILDS_MENU * scale), \"bold\"),\n                        anchor=\"w\",\n                        padx=int(6 * scale),\n                        pady=int(6 * scale),\n                    ).pack(fill=\"x\", pady=(int(4 * scale), int(2 * scale)))\n                for i, b in grps[p]:\n                    act = i == self.current_build_idx\n                    _tk_btn(\n                        lf,\n                        text=str(b.get(\"name\") or \"Unknown Build\"),\n                        cmd=lambda idx=i: (self._select_build(idx), self._close_build_dropdown()),\n                        bg=SELECT_BG if act else CARD_BG,\n                        fg=GOLD if act else TEXT,\n                        anchor=\"w\",\n                        padx=int(10 * scale),\n                        pady=int(6 * scale),\n                        font=(\"Segoe UI\", int(FS_BUILDS_MENU * scale), \"bold\" if act else \"normal\"),\n                    ).pack(fill=\"x\", pady=int(2 * scale))\n                if mul:\n                    # A divider line visually separates profile groups without having\n                    # to add more nested containers or special spacing logic.\n                    tk.Frame(lf, bg=MUTED, height=1).pack(fill=\"x\", pady=int(6 * scale))\n\n            with suppress(Exception):\n                host.update_idletasks()\n                # Limit popup height so long build lists stay scrollable instead of\n                # growing beyond the overlay window.\n                cv.configure(height=min(max_h, max(int(120 * scale), lf.winfo_reqheight())))\n                cv.yview_moveto(0.0)\n\n        return _ref  # Initial fill is triggered by the shared popup helper.\n\n    def _build_settings_popup(self, host: tk.Misc) -> Any:\n        \"\"\"Create the settings popup and return its refresh callback.\"\"\"\n        s = self._cfg.ui_scale\n        c = tk.Frame(host, bg=CARD_BG, padx=int(14 * s), pady=int(10 * s))\n        c.pack(fill=\"both\", expand=True)\n        imgs: dict[bool, tk.PhotoImage | None] = getattr(self, \"_lock_img_cache\", {})\n\n        def _row(txt: str, img: tk.PhotoImage | None, lbl_txt: str, cmd: Callable) -> tuple[tk.Button, tk.Label]:\n            \"\"\"Create one icon/text setting row with a button and description.\"\"\"\n            r = tk.Frame(c, bg=CARD_BG)\n            r.pack(fill=\"x\", pady=int(3 * s))\n            b = (\n                _tk_btn(r, image=img, cmd=cmd, padx=int(6 * s), pady=int(4 * s))\n                if img\n                else _tk_btn(\n                    r,\n                    text=txt,\n                    cmd=cmd,\n                    font=(\"Segoe UI\", int(FS_SETTINGS_ICON * s), \"bold\"),\n                    padx=int(6 * s),\n                    pady=int(4 * s),\n                )\n            )\n            if img:\n                b.image = img\n            b.pack(side=\"left\")\n            lbl = _tk_lbl(r, text=lbl_txt, font=(\"Segoe UI\", int(FS_SETTINGS_LABEL * s)), anchor=\"w\")\n            lbl.pack(side=\"left\", padx=(int(8 * s), int(24 * s)))\n            return b, lbl\n\n        btn_lock, lbl_lock = _row(\n            \"🔒\" if self._cfg.grid_locked else \"🔓\",\n            imgs.get(self._cfg.grid_locked),\n            \"Grid locked\",\n            lambda: (self._toggle_grid_lock(), _ref()),\n        )\n        btn_gold, lbl_gold = _row(\"★\", None, \"Golden frames\", lambda: (self._toggle_gold_frames(), _ref()))\n        _row(\"↻\", None, \"Reload profiles\", self._reload_profiles)\n        _row(\"↺\", None, \"Reset grid defaults\", lambda: (self._reset_grid_defaults(), _ref()))\n\n        tk.Frame(c, bg=MUTED, height=1).pack(fill=\"x\", pady=int(6 * s))\n\n        # Zoom controls change the active cell size for the current mode.\n        zr = tk.Frame(c, bg=CARD_BG)\n        zr.pack(fill=\"x\", pady=int(3 * s))\n        btn_zm = _tk_btn(\n            zr,\n            text=\"−\",\n            cmd=lambda: (self._zoom_grid(-1), _ref()),\n            font=(\"Segoe UI\", int(FS_ZOOM_BTN * s), \"bold\"),\n            padx=int(8 * s),\n            pady=int(2 * s),\n        )\n        btn_zm.pack(side=\"left\")\n        lbl_cell = _tk_lbl(zr, font=(\"Segoe UI\", int(FS_SETTINGS_LABEL * s), \"bold\"), width=5, anchor=\"center\")\n        lbl_cell.pack(side=\"left\")\n        btn_zp = _tk_btn(\n            zr,\n            text=\"+\",\n            cmd=lambda: (self._zoom_grid(1), _ref()),\n            font=(\"Segoe UI\", int(FS_ZOOM_BTN * s), \"bold\"),\n            padx=int(8 * s),\n            pady=int(2 * s),\n        )\n        btn_zp.pack(side=\"left\")\n        _tk_lbl(zr, text=\"Grid Zoom\", fg=MUTED, font=(\"Segoe UI\", int(FS_SETTINGS_LABEL * s)), anchor=\"w\").pack(\n            side=\"left\", padx=(int(8 * s), 0)\n        )\n\n        tk.Frame(c, bg=MUTED, height=1).pack(fill=\"x\", pady=int(4 * s))\n\n        # D-pad buttons provide pixel-precise movement without dragging.\n        dp = tk.Frame(c, bg=CARD_BG)\n        dp.pack(anchor=\"w\", pady=(int(2 * s), int(2 * s)))\n        dc = tk.Frame(dp, bg=CARD_BG)\n        dc.pack(side=\"left\")\n        sp = int(30 * s)\n\n        r0 = tk.Frame(dc, bg=CARD_BG)\n        r0.pack()\n        tk.Frame(r0, bg=CARD_BG, width=sp, height=1).pack(side=\"left\")\n        _tk_btn(\n            r0,\n            text=\"↑\",\n            cmd=lambda: (self._move_grid(0, -1), _ref()),\n            font=(\"Segoe UI\", int(FS_SETTINGS_ICON * s), \"bold\"),\n            width=2,\n            pady=int(2 * s),\n        ).pack(side=\"left\", padx=1, pady=1)\n        tk.Frame(r0, bg=CARD_BG, width=sp, height=1).pack(side=\"left\")\n\n        r1 = tk.Frame(dc, bg=CARD_BG)\n        r1.pack()\n        _tk_btn(\n            r1,\n            text=\"←\",\n            cmd=lambda: (self._move_grid(-1, 0), _ref()),\n            font=(\"Segoe UI\", int(FS_SETTINGS_ICON * s), \"bold\"),\n            width=2,\n            pady=int(2 * s),\n        ).pack(side=\"left\", padx=1, pady=1)\n        tk.Frame(r1, bg=CARD_BG, width=sp, height=1).pack(side=\"left\")\n        _tk_btn(\n            r1,\n            text=\"→\",\n            cmd=lambda: (self._move_grid(1, 0), _ref()),\n            font=(\"Segoe UI\", int(FS_SETTINGS_ICON * s), \"bold\"),\n            width=2,\n            pady=int(2 * s),\n        ).pack(side=\"left\", padx=1, pady=1)\n\n        r2 = tk.Frame(dc, bg=CARD_BG)\n        r2.pack()\n        tk.Frame(r2, bg=CARD_BG, width=sp, height=1).pack(side=\"left\")\n        _tk_btn(\n            r2,\n            text=\"↓\",\n            cmd=lambda: (self._move_grid(0, 1), _ref()),\n            font=(\"Segoe UI\", int(FS_SETTINGS_ICON * s), \"bold\"),\n            width=2,\n            pady=int(2 * s),\n        ).pack(side=\"left\", padx=1, pady=1)\n        tk.Frame(r2, bg=CARD_BG, width=sp, height=1).pack(side=\"left\")\n\n        _tk_lbl(dp, text=\"Move\\nGrid\", fg=MUTED, font=(\"Segoe UI\", int(FS_HINT * s)), anchor=\"w\", justify=\"left\").pack(\n            side=\"left\", padx=(int(8 * s), 0)\n        )\n        tk.Frame(c, bg=MUTED, height=1).pack(fill=\"x\", pady=int(6 * s))\n        _tk_lbl(\n            c,\n            text=\"• Drag frame to move grid\\n• D-Pad ↑ ↓ ← → moves grid per click\\n• Use − + buttons to zoom\\n• Use ★ to make all frames golden\\n• Use ↺ to reset to default size/position\\n• Use 🔓 to unlock/lock grid\",\n            fg=MUTED,\n            font=(\"Segoe UI\", int(FS_HINT * s)),\n            anchor=\"w\",\n            justify=\"left\",\n            padx=int(4 * s),\n            pady=int(6 * s),\n        ).pack(fill=\"x\")\n\n        def _ref():\n            \"\"\"Refresh labels, icons, and enabled states after a setting changes.\"\"\"\n            lk, gd = self._cfg.grid_locked, getattr(self._cfg, \"gold_frames\", False)\n            if imgs.get(lk):\n                btn_lock.configure(image=imgs[lk])\n                btn_lock.image = imgs[lk]\n            else:\n                btn_lock.configure(text=\"🔒\" if lk else \"🔓\", fg=GOLD if lk else TEXT)\n            lbl_lock.configure(text=\"Grid locked\" if lk else \"Grid unlocked\", fg=GOLD if lk else TEXT)\n            btn_gold.configure(fg=GOLD if gd else TEXT)\n            lbl_gold.configure(text=\"Golden frames (on)\" if gd else \"Golden frames (off)\", fg=GOLD if gd else TEXT)\n\n            for w in (btn_zm, btn_zp) + tuple(dc.winfo_children()):\n                for child in w.winfo_children():\n                    if isinstance(child, tk.Button):\n                        child.configure(state=tk.DISABLED if lk else tk.NORMAL, fg=MUTED if lk else TEXT)\n\n            lbl_cell.configure(\n                text=f\"{int(self._cfg.cell_size_collapsed if self._cfg.is_collapsed else self._cfg.cell_size)}px\",\n                fg=MUTED if lk else TEXT,\n            )\n            with suppress(Exception):\n                host.update_idletasks()\n                host.lift()\n                host.configure(bg=CARD_BG)\n\n        _ref()\n        return _ref\n\n    # --- BOARD CARDS ---\n\n    def _update_board_selection(self) -> None:\n        \"\"\"Recolor board cards in-place — no destroy/rebuild, no flicker.\"\"\"\n        for i, card in enumerate(self.board_container.winfo_children()):\n            selected = i == self.selected_board_idx\n            bg, fg = (SELECT_BG, GOLD) if selected else (CARD_BG, TEXT)\n            with suppress(Exception):\n                card.configure(bg=bg)\n            for child in card.winfo_children():\n                with suppress(Exception):\n                    child.configure(bg=bg, fg=fg)\n\n    def _select_board_card(self, idx: int) -> None:\n        \"\"\"Select one board card, then redraw and persist the new state.\"\"\"\n        self.selected_board_idx = _clamp_int(idx, 0, max(0, len(self.boards) - 1), 0)\n        self._update_board_selection()\n        self.redraw()\n        self._persist_state()\n\n    def _toggle_collapsed_mode(self) -> None:\n        \"\"\"Switch between the full and compact grid layouts.\"\"\"\n        self._cfg.is_collapsed = not self._cfg.is_collapsed\n        with suppress(Exception):\n            self.lbl_mode.config(text=\"Compact View\" if self._cfg.is_collapsed else \"Full View\")\n        if _is_alive(getattr(self, \"btn_view_switch\", None)):\n            self.btn_view_switch.config(text=\"⤢\" if self._cfg.is_collapsed else \"⤡\")\n        self.redraw()\n        self._persist_state()\n\n    def _refresh_lists(self) -> None:\n        \"\"\"Rebuild the board list and refresh the title for the active build.\"\"\"\n        for w in self.board_container.winfo_children():\n            w.destroy()\n\n        # The title prefers the profile name. When that is missing, a readable\n        # fallback is derived from the build name.\n        t = \"Paragon\"\n        if self.builds:\n            b = self.builds[self.current_build_idx]\n            t = _format_build_display_name(b.get(\"name\"))\n            if not t:\n                t = _format_build_display_name(b.get(\"profile\"))\n        self.lbl_title.config(text=t or \"Paragon\")\n\n        if not self.boards:\n            return\n        acc = self._accent_frame_color()\n\n        for idx, bd in enumerate(self.boards):\n            # Build a readable line that includes class, board name, glyph, and\n            # rotation so the user can identify each board without opening it.\n            rn, rg = str(bd.get(\"Name\", \"?\") or \"?\"), bd.get(\"Glyph\")\n            np = rn.split(\"-\", 1)\n            cs, bs = ((np[0] if np else rn).strip().lower(), (np[1] if len(np) > 1 else rn).strip())\n            cn = {c: c.title() for c in PLAYER_CLASSES}.get(cs, cs.title() if cs else \"?\")\n            gn = \"No Glyph\"\n            if rg:\n                gp = str(rg).strip().split(\"-\", 1)\n                g = gp[1] if len(gp) > 1 and gp[0].strip().lower() == cs else str(rg).strip()\n                gn = g.replace(\"-\", \" \").strip().title() if g else \"No Glyph\"\n\n            txt = f\"{cn} - {bs.replace('-', ' ').strip().title() if bs else '?'} - {gn} - {parse_rotation(str(bd.get('Rotation', '0')))}°\"\n            sel = idx == self.selected_board_idx\n            bg, fg = (SELECT_BG, GOLD) if sel else (CARD_BG, TEXT)\n\n            c = tk.Frame(\n                self.board_container,\n                bg=bg,\n                highlightthickness=self._accent_frame_thickness(),\n                highlightbackground=acc,\n                highlightcolor=acc,\n            )\n            c.pack(fill=\"x\", pady=8)\n            lbl = _tk_lbl(\n                c,\n                text=txt,\n                fg=fg,\n                bg=bg,\n                anchor=\"w\",\n                font=(\"Segoe UI\", int(FS_BOARD_CARD * self._cfg.ui_scale), \"bold\"),\n                wraplength=max(200, self._cfg.panel_w - 40),\n                justify=\"left\",\n            )\n            lbl.pack(fill=\"both\", expand=True, padx=14, pady=16)\n            lbl.bind(\"<Button-1>\", lambda _e, i=idx: self._select_board_card(i))\n            c.bind(\"<Button-1>\", lambda _e, i=idx: self._select_board_card(i))\n\n        self._apply_accent_frames()\n        with suppress(Exception):\n            self.btn_build_menu.config(state=(tk.NORMAL if len(self.builds) > 1 else tk.DISABLED))\n\n    # --- EVENT HANDLERS ---\n\n    def _on_boards_mousewheel(self, e: tk.Event) -> None:\n        \"\"\"Scroll the board list on Windows, Linux, and X11 wheel events.\"\"\"\n        delta = (\n            -1\n            if getattr(e, \"delta\", 0) > 0 or getattr(e, \"num\", 0) == 4\n            else 1\n            if getattr(e, \"delta\", 0) < 0 or getattr(e, \"num\", 0) == 5\n            else 0\n        )\n        if delta:\n            with suppress(Exception):\n                self.boards_canvas.yview_scroll(int(delta), \"units\")\n\n    def _move_grid(self, dx: int, dy: int) -> None:\n        \"\"\"Move the grid by a small step in the active layout mode.\"\"\"\n        if self._cfg.grid_locked:\n            return\n        if self._cfg.is_collapsed:\n            self.grid_x_collapsed += dx\n            self.grid_y_collapsed += dy\n        else:\n            self.grid_x += dx\n            self.grid_y += dy\n        self.redraw()\n        self._persist_state()\n\n    def _zoom_grid(self, delta: int) -> None:\n        \"\"\"Increase or decrease the active cell size within safe limits.\"\"\"\n        if self._cfg.grid_locked:\n            return\n        if self._cfg.is_collapsed:\n            self._cfg.cell_size_collapsed = max(8, min(50, self._cfg.cell_size_collapsed + delta))\n        else:\n            self._cfg.cell_size = max(10, min(80, self._cfg.cell_size + delta))\n        self.redraw()\n        self._persist_state()\n\n    def _warmup_settings_assets(self) -> None:\n        \"\"\"Pre-render lock icons so the settings popup opens without image lag.\"\"\"\n        self._warmup_after_id = None\n        if not _is_alive(self) or hasattr(self, \"_lock_img_cache\"):\n            return\n        if _is_alive(getattr(self, \"_settings_popup\", None), mapped=True):\n            self._warmup_after_id = self.after(400, self._warmup_settings_assets)\n            return\n\n        sz = max(12, int(14 * self._cfg.ui_scale))\n        if not Image or not ImageFont or not ImageDraw:\n            self._lock_img_cache = {True: None, False: None}\n            return\n\n        try:\n            # Segoe UI Emoji gives reliable lock/unlock glyphs on Windows and lets\n            # the popup use small crisp icons instead of text symbols.\n            fnt = ImageFont.truetype(r\"C:\\Windows\\Fonts\\seguiemj.ttf\", sz)\n\n            def _mk(locked: bool) -> tk.PhotoImage:\n                i = Image.new(\"RGBA\", (sz + 2, sz + 2), (0, 0, 0, 0))\n                try:\n                    ImageDraw.Draw(i).text((1, 1), \"🔒\" if locked else \"🔓\", font=fnt, embedded_color=True)\n                except TypeError:\n                    ImageDraw.Draw(i).text((1, 1), \"🔒\" if locked else \"🔓\", font=fnt)\n                b = io.BytesIO()\n                i.save(b, format=\"PNG\")\n                return tk.PhotoImage(data=base64.b64encode(b.getvalue()))\n\n            self._lock_img_cache = {True: _mk(True), False: _mk(False)}\n        except Exception:\n            self._lock_img_cache = {True: None, False: None}\n\n    def _on_grid_drag_start(self, e: tk.Event) -> None:\n        \"\"\"Start dragging only when the cursor grabs the outer grid border.\"\"\"\n        self.focus_set()\n        if self._cfg.grid_locked or not self._border_rect:\n            self._dragging_grid = False\n            return\n        x1, y1, x2, y2, g, x, y = (*self._border_rect, int(self._border_grab), int(e.x), int(e.y))\n        if (\n            not (x1 - g <= x <= x2 + g and y1 - g <= y <= y2 + g)\n            or min(abs(x - x1), abs(x - x2), abs(y - y1), abs(y - y2)) > g\n        ):\n            self._dragging_grid = False\n            return\n\n        self._dragging_grid, self._drag_start_xy = True, (int(e.x_root), int(e.y_root))\n        self._drag_start_grid = (\n            (int(self.grid_x_collapsed), int(self.grid_y_collapsed))\n            if self._cfg.is_collapsed\n            else (int(self.grid_x), int(self.grid_y))\n        )\n\n    def _on_grid_drag_move(self, e: tk.Event) -> None:\n        \"\"\"Move the grid live while the user drags the captured border.\"\"\"\n        if not self._dragging_grid:\n            return\n        dx, dy = (int(e.x_root) - self._drag_start_xy[0], int(e.y_root) - self._drag_start_xy[1])\n        if self._cfg.is_collapsed:\n            self.grid_x_collapsed, self.grid_y_collapsed = (\n                self._drag_start_grid[0] + dx,\n                self._drag_start_grid[1] + dy,\n            )\n        else:\n            self.grid_x, self.grid_y = (self._drag_start_grid[0] + dx, self._drag_start_grid[1] + dy)\n        self.redraw()\n\n    def _on_grid_drag_end(self, _: tk.Event) -> None:\n        \"\"\"Finish a drag operation and persist the final grid position.\"\"\"\n        if self._dragging_grid:\n            self._dragging_grid = False\n            self._persist_state()\n\n    # --- GEOMETRY & RENDERING ---\n\n    def _get_resolution(self) -> tuple[int, int]:\n        \"\"\"Return the tracked game resolution, falling back to the screen size.\"\"\"\n        with suppress(Exception):\n            return (int(self._res.resolution[0]), int(self._res.resolution[1]))\n        return (self.winfo_screenwidth(), self.winfo_screenheight())\n\n    def _get_cam_roi(self) -> tuple[int, int, int, int] | None:\n        \"\"\"Return the tracked game window ROI when the camera module exposes one.\"\"\"\n        try:\n            return (\n                (int(r[0]), int(r[1]), int(r[2]), int(r[3])) if (r := getattr(self._cam, \"window_roi\", None)) else None\n            )\n        except Exception:\n            return None\n\n    def _apply_geometry(self) -> None:\n        \"\"\"Resize the overlay to match the tracked game window or full screen.\"\"\"\n        # The floating builds popup is a separate Toplevel, so its screen-space\n        # coordinates become stale whenever the tracked game window moves/resizes.\n        self._close_build_dropdown()\n        roi = self._get_cam_roi()\n        rx, ry, rw, rh = roi or (0, 0, *self._get_resolution())\n        self.geometry(f\"{int(rw)}x{int(rh)}+{int(rx)}+{int(ry)}\")\n        with suppress(Exception):\n            self.canvas.config(width=int(rw), height=int(rh))\n\n    def redraw(self) -> None:\n        \"\"\"Redraw the entire transparent grid overlay for the selected board.\"\"\"\n        self.canvas.delete(\"all\")\n        if not self.boards or len(n := self.boards[self.selected_board_idx].get(\"Nodes\") or []) != NODES_LEN:\n            return\n\n        grid, acc = nodes_to_grid(n), self._accent_frame_color()\n        self._apply_accent_frames()\n\n        cs = int(self._cfg.cell_size_collapsed if self._cfg.is_collapsed else self._cfg.cell_size)\n        gx, gy = (\n            (int(self.grid_x_collapsed), int(self.grid_y_collapsed))\n            if self._cfg.is_collapsed\n            else (int(self.grid_x), int(self.grid_y))\n        )\n\n        # Compute the square grid size once and reuse it for both the border and\n        # the node cell rendering below.\n        gpx, bw = GRID * cs, self._grid_frame_thickness()\n        bp = max(2, bw)\n\n        self.canvas.create_rectangle(gx - bp, gy - bp, gx + gpx + bp, gy + gpx + bp, outline=acc, width=bw)\n        self._border_rect, self._border_grab = (\n            (int(gx - bp), int(gy - bp), int(gx + gpx + bp), int(gy + gpx + bp)),\n            max(12, (bw * 2) + 2),\n        )\n\n        for i in range(GRID + 1):\n            p = i * cs\n            self.canvas.create_line(gx, gy + p, gx + gpx, gy + p, fill=FS_GRID_COLOR, width=1)\n            self.canvas.create_line(gx + p, gy, gx + p, gy + gpx, fill=FS_GRID_COLOR, width=1)\n\n        ins, ow = max(2, cs // 4), max(2, cs // 10)\n        for y in range(GRID):\n            for x in range(GRID):\n                if grid[y][x]:\n                    self.canvas.create_rectangle(\n                        gx + x * cs + ins,\n                        gy + y * cs + ins,\n                        gx + (x + 1) * cs - ins,\n                        gy + (y + 1) * cs - ins,\n                        fill=TRANSPARENT_KEY,\n                        outline=acc,\n                        width=ow,\n                    )\n\n    # --- LIFECYCLE ---\n\n    def close(self) -> None:\n        \"\"\"Persist state, destroy the window, and clear the global overlay handle.\"\"\"\n        try:\n            with suppress(Exception):\n                self._config_loader.unregister_change_listener(self._config_listener)\n            self._close_build_dropdown()\n            self._close_settings_dropdown()\n            self._persist_state()\n            self.destroy()\n        finally:\n            if self._on_close:\n                self._on_close()\n            with _OVERLAY_LOCK:\n                global _CURRENT_OVERLAY\n                if _CURRENT_OVERLAY is self:\n                    _CURRENT_OVERLAY = None\n\n    def _persist_state(self) -> None:\n        \"\"\"Write the overlay's current user-facing state back to params.ini.\"\"\"\n        try:\n            _save_overlay_settings({\n                \"cell_size\": int(self._cfg.cell_size),\n                # Persist both the stable build identity and numeric index so old\n                # settings continue to restore sensibly.\n                \"profile\": str(self.builds[self.current_build_idx].get(\"profile\") or \"\") if self.builds else \"\",\n                \"build_name\": str(self.builds[self.current_build_idx].get(\"name\") or \"\") if self.builds else \"\",\n                \"build_idx\": int(self.current_build_idx),\n                \"board_idx\": int(self.selected_board_idx),\n                \"grid_x\": int(self.grid_x),\n                \"grid_y\": int(self.grid_y),\n                \"is_collapsed\": bool(self._cfg.is_collapsed),\n                \"cell_size_collapsed\": int(self._cfg.cell_size_collapsed),\n                \"grid_x_collapsed\": int(self.grid_x_collapsed),\n                \"grid_y_collapsed\": int(self.grid_y_collapsed),\n                \"grid_locked\": bool(self._cfg.grid_locked),\n                \"gold_frames\": bool(getattr(self._cfg, \"gold_frames\", False)),\n            })\n        except Exception:\n            LOGGER.debug(\"Failed to persist overlay state\", exc_info=True)\n\n\n# =============================================================================\n# PUBLIC API\n# =============================================================================\n\n\ndef run_paragon_overlay(preset_path: str | None = None, *, parent: tk.Misc | None = None) -> ParagonOverlay | None:\n    \"\"\"Open the overlay either on an existing Tk parent or on the shared UI thread.\"\"\"\n    try:\n        if not (builds := load_builds_from_path(preset_path or (sys.argv[1] if len(sys.argv) > 1 else None))):\n            LOGGER.warning(\"No Paragon data found in loaded profiles.\")\n            return None\n    except Exception:\n        LOGGER.exception(\"Failed to load Paragon preset\")\n        return None\n\n    if parent is not None:\n        # Embedding mode is used when another Tk application already owns the\n        # event loop and can host the overlay directly.\n        overlay = ParagonOverlay(parent, builds, on_close=None)\n        with _OVERLAY_LOCK:\n            global _CURRENT_OVERLAY\n            _CURRENT_OVERLAY = overlay\n            _CLOSE_REQUESTED.clear()\n        return overlay\n\n    closed = threading.Event()\n\n    def _open_overlay() -> None:\n        # NOTE: This runs on the Tk UI thread.\n        # If the root was not initialized, unblock the caller to avoid deadlock.\n        root = _UI_ROOT\n        if root is None:\n            LOGGER.error(\"Paragon overlay: UI root not ready — aborting open\")\n            closed.set()\n            return\n\n        try:\n            overlay = ParagonOverlay(root, builds, on_close=closed.set)\n        except Exception:\n            LOGGER.exception(\"Paragon overlay: failed to open\")\n            closed.set()\n            return\n\n        with _OVERLAY_LOCK:\n            global _CURRENT_OVERLAY\n            _CURRENT_OVERLAY = overlay\n            _CLOSE_REQUESTED.clear()\n\n    _call_on_ui_thread(_open_overlay)\n    # The caller owns a worker thread per overlay session and expects that thread\n    # to stay alive until the overlay closes, so block here on the close signal.\n    closed.wait()\n    return None\n\n\ndef request_close(overlay: ParagonOverlay | None = None) -> None:\n    \"\"\"Request that the current overlay closes, even from another thread.\"\"\"\n    with _OVERLAY_LOCK:\n        if not (t := overlay or _CURRENT_OVERLAY):\n            return\n        _CLOSE_REQUESTED.set()\n    with suppress(Exception):\n        _post_to_ui_thread(lambda: t.close() if _is_alive(t) else None)\n\n\nif __name__ == \"__main__\":\n    run_paragon_overlay()\n"
  },
  {
    "path": "src/scripts/__init__.py",
    "content": "def correct_name(name: str) -> str | None:\n    if name:\n        return (\n            name\n            .replace(\" (CRUCIBLE)\", \"\")  # S12 Crucible items are identical to regular uniques\n            .lower()\n            .replace(\"'\", \"\")\n            .replace(\" \", \"_\")\n            .replace(\"\\xa0\", \"_\")\n            .replace(\"\\u00a0\", \"_\")\n            .replace(\",\", \"\")\n            .replace(\"(\", \"\")\n            .replace(\")\", \"\")\n        )\n    return name\n"
  },
  {
    "path": "src/scripts/common.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport sys\nimport time\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING\n\nfrom src.item.data.seasonal_attribute import SeasonalAttribute\n\nif TYPE_CHECKING:\n    from tkinter import Canvas\n\nif sys.platform != \"darwin\":\n    import keyboard\n\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.item.data.item_type import ItemType, is_consumable, is_non_sigil_mapping, is_socketable\nfrom src.utils.custom_mouse import mouse\n\ntry:\n    from src.config.ui import ResManager\nexcept Exception:  # pragma: no cover\n    ResManager = None  # type: ignore[assignment]\n\n\nif TYPE_CHECKING:\n    from src.item.models import Item\n\nLOGGER = logging.getLogger(__name__)\n\nSETUP_INSTRUCTIONS_URL = \"https://github.com/d4lfteam/d4lf/blob/main/README.md#how-to-setup\"\n\n\n@dataclass(frozen=True, slots=True)\nclass FilterColors:\n    \"\"\"Color palette used by the loot filter / vision overlays.\"\"\"\n\n    matched: str\n    no_match: str\n    codex_upgrade: str\n    processing: str\n    unhandled: str\n\n\n# Default palette.\nFILTER_COLORS_DEFAULT = FilterColors(\n    matched=\"#23fc5d\",  # COLOR_GREEN-Matched a profile\n    no_match=\"#fc2323\",  # COLOR_RED-Matched no profiles at all\n    codex_upgrade=\"#fca503\",  # COLOR_ORANGE-Matched a codex upgrade\n    processing=\"#888888\",  # COLOR_GREY-Still processing or can't find the info we expect\n    unhandled=\"#00b3b3\",  # COLOR_BLUE-We recognize this as an item, but it is not one we handle\n)\n\n# Colorblind-friendly palette (Okabe-Ito inspired).\nFILTER_COLORS_COLORBLIND = FilterColors(\n    matched=\"#56B4E9\",  # COLOR_BLUE-Matched a profile\n    no_match=\"#D55E00\",  # COLOR_VERMILLION-Matched no profiles at all\n    codex_upgrade=\"#E69F00\",  # COLOR_ORANGE-Matched a codex upgrade\n    processing=\"#888888\",  # COLOR_GREY-Still processing or can't find the info we expect\n    unhandled=\"#CC79A7\",  # COLOR_PURPLE-We recognize this as an item, but it is not one we handle\n)\n\n\ndef get_filter_colors() -> FilterColors:\n    \"\"\"Return the active palette (default vs. colorblind mode).\"\"\"\n    try:\n        if IniConfigLoader().general.colorblind_mode:\n            return FILTER_COLORS_COLORBLIND\n    except Exception:\n        # Fail-safe: if config isn't available yet, use defaults.\n        LOGGER.debug(\"get_filter_colors(): config unavailable; using default palette\", exc_info=True)\n    return FILTER_COLORS_DEFAULT\n\n\nASPECT_UPGRADES_LABEL = \"AspectUpgrades\"\n\n\ndef mark_as_junk():\n    keyboard.send(\"space\")\n    time.sleep(0.13)\n\n\ndef mark_as_favorite():\n    LOGGER.info(\"Mark as favorite\")\n    keyboard.send(\"space\")\n    time.sleep(0.17)\n    keyboard.send(\"space\")\n    time.sleep(0.13)\n\n\ndef reset_canvas(root, canvas):\n    canvas.delete(\"all\")\n    canvas.config(height=0, width=0)\n    root.geometry(\"0x0+0+0\")\n    root.update_idletasks()\n    root.update()\n\n\ndef reset_item_status(occupied, inv):\n    for item_slot in occupied:\n        if item_slot.is_fav:\n            inv.hover_item_with_delay(item_slot)\n            keyboard.send(\"space\")\n        if item_slot.is_junk:\n            inv.hover_item_with_delay(item_slot)\n            keyboard.send(\"space\")\n            time.sleep(0.15)\n            keyboard.send(\"space\")\n        time.sleep(0.15)\n\n    if occupied:\n        mouse.move(*Cam().abs_window_to_monitor((0, 0)))\n\n\ndef drop_item_from_inventory() -> None:\n    \"\"\"Drop the currently-hovered inventory item (Ctrl + Left Click in-game).\"\"\"\n    if keyboard is None:\n        return\n    keyboard.press(\"ctrl\")\n    time.sleep(0.03)\n    mouse.click(\"left\")\n    time.sleep(0.03)\n    keyboard.release(\"ctrl\")\n    time.sleep(0.10)\n\n\ndef is_ignored_item(item_descr: Item):\n    if is_consumable(item_descr.item_type):\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Consumable\")\n        return True\n    if is_non_sigil_mapping(item_descr.item_type):\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Non-sigil Mapping\")\n        return True\n    if item_descr.item_type == ItemType.EscalationSigil and IniConfigLoader().general.ignore_escalation_sigils:\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Escalation Sigil and configured to be ignored\")\n        return True\n    if is_socketable(item_descr.item_type):\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Socketable\")\n        return True\n    if item_descr.item_type == ItemType.Material:\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Material\")\n        return True\n    if item_descr.item_type == ItemType.Cache:\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Cache\")\n        return True\n    if item_descr.item_type == ItemType.Cosmetic:\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Cosmetic only item\")\n        return True\n    if item_descr.item_type == ItemType.LairBossKey:\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Lair Boss Key\")\n        return True\n    if item_descr.seasonal_attribute == SeasonalAttribute.sanctified:\n        LOGGER.info(f\"{item_descr.original_name} -- Matched: Sanctified item, which is not supported\")\n        return True\n\n    return False\n\n\n# --- Shared overlay text helper (used by paragon_overlay & vision modes) ---\ndef _scaled_overlay_font_size(minimum_font_size: int, window_height: int | None) -> int:\n    \"\"\"Legacy scaling behavior from vision_mode_with_highlighting.\"\"\"\n    if window_height == 1440:\n        return minimum_font_size + 1\n    if window_height == 1600:\n        return minimum_font_size + 2\n    if window_height == 2160:\n        return minimum_font_size + 3\n    return minimum_font_size\n\n\ndef draw_text_with_background(\n    canvas: Canvas,\n    text: str,\n    color: str,\n    previous_text_y: int,\n    offset: int,\n    canvas_center_x: int,\n    *,\n    background_color: str = \"#111111\",\n    font_name: str = \"Courier New\",\n    window_height: int | None = None,\n) -> int | None:\n    \"\"\"Draw wrapped text centered at canvas_center_x with a background box.\n\n    Thread-safe relative to overlays that run outside the main thread because this\n    implementation avoids creating tkinter.font.Font() objects (which can trigger\n    'main thread is not in main loop' in some environments). Instead, it measures\n    the rendered text using a temporary canvas text item and canvas.bbox().\n    \"\"\"\n    if not text:\n        return None\n\n    minimum_font_size = IniConfigLoader().general.minimum_overlay_font_size\n\n    # If caller didn't provide window_height, attempt to fetch it lazily.\n    if window_height is None:\n        try:\n            if ResManager is not None:\n                window_height = ResManager().pos.window_dimensions[1]\n        except Exception:\n            window_height = None\n\n    max_width = int(canvas_center_x * 2)\n    font_size = _scaled_overlay_font_size(minimum_font_size, window_height)\n\n    def _measure_bbox(size: int):\n        tmp_id = canvas.create_text(\n            -10000, -10000, text=text, anchor=\"nw\", font=(font_name, size), fill=color, width=max_width\n        )\n        bbox = canvas.bbox(tmp_id)  # (x1, y1, x2, y2) or None\n        canvas.delete(tmp_id)\n        return bbox\n\n    bbox = _measure_bbox(font_size)\n    if not bbox:\n        return None\n\n    text_w = int(bbox[2] - bbox[0])\n    text_h = int(bbox[3] - bbox[1])\n\n    # Legacy-ish fallback: if it basically fills the whole width and we used the\n    # scaled font, try the minimum font size.\n    if font_size != minimum_font_size and text_w >= max_width - 2:\n        bbox2 = _measure_bbox(minimum_font_size)\n        if bbox2:\n            font_size = minimum_font_size\n            text_w = int(bbox2[2] - bbox2[0])\n            text_h = int(bbox2[3] - bbox2[1])\n\n    left = int(canvas_center_x - text_w // 2)\n    right = int(canvas_center_x + text_w // 2)\n    bottom = int(previous_text_y - offset)\n    top = int(bottom - text_h)\n\n    rect_id = canvas.create_rectangle(left, top, right, bottom, fill=background_color, outline=\"\")\n    text_id = canvas.create_text(\n        canvas_center_x, bottom, text=text, anchor=\"s\", font=(font_name, font_size), fill=color, width=max_width\n    )\n    # Ensure text is above background.\n    canvas.tag_raise(text_id, rect_id)\n\n    return top\n"
  },
  {
    "path": "src/scripts/handler.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport sys\nimport threading\nimport time\nfrom contextlib import suppress\nfrom typing import TYPE_CHECKING, Any\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n    from collections.abc import Set as AbstractSet\n\nif sys.platform != \"darwin\":\n    import keyboard\n\nimport src.scripts.loot_filter_tts\nimport src.scripts.vision_mode_fast\nimport src.scripts.vision_mode_with_highlighting\nimport src.tts\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import (\n    IS_HOTKEY_KEY,\n    LIVE_RELOAD_GROUP_KEY,\n    AdvancedOptionsModel,\n    GeneralModel,\n    ItemRefreshType,\n    VisionModeType,\n)\nfrom src.dataloader import Dataloader\nfrom src.loot_mover import move_items_to_inventory, move_items_to_stash\nfrom src.paragon_overlay import request_close, run_paragon_overlay\nfrom src.scripts.common import SETUP_INSTRUCTIONS_URL\nfrom src.ui.char_inventory import CharInventory\nfrom src.ui.stash import Stash\nfrom src.utils.custom_mouse import mouse\nfrom src.utils.process_handler import kill_thread, safe_exit\nfrom src.utils.window import screenshot\n\nLOGGER = logging.getLogger(__name__)\n\nLOCK = threading.Lock()\n\n\ndef _setting_key(section: str, field_name: str) -> str:\n    return f\"{section}.{field_name}\"\n\n\ndef _field_metadata(model_class: type[Any], field_name: str) -> dict[str, Any]:\n    return model_class.model_fields[field_name].json_schema_extra or {}\n\n\ndef _collect_reload_group_keys(section: str, model_class: type[Any], group_name: str) -> set[str]:\n    return {\n        _setting_key(section, field_name)\n        for field_name in model_class.model_fields\n        if _field_metadata(model_class, field_name).get(LIVE_RELOAD_GROUP_KEY) == group_name\n    }\n\n\ndef _collect_hotkey_setting_keys() -> set[str]:\n    hotkey_keys = {\n        _setting_key(\"advanced_options\", field_name)\n        for field_name in AdvancedOptionsModel.model_fields\n        if _field_metadata(AdvancedOptionsModel, field_name).get(IS_HOTKEY_KEY) == \"True\"\n    }\n    hotkey_keys.update(_collect_reload_group_keys(\"advanced_options\", AdvancedOptionsModel, \"hotkeys\"))\n    return hotkey_keys\n\n\ndef _has_any_changed(changed_keys: AbstractSet[str], relevant_keys: set[str]) -> bool:\n    return any(key in changed_keys for key in relevant_keys)\n\n\nHOTKEY_SETTING_KEYS = _collect_hotkey_setting_keys()\nLANGUAGE_SETTING_KEYS = _collect_reload_group_keys(\"general\", GeneralModel, \"language\")\nLOG_LEVEL_SETTING_KEYS = _collect_reload_group_keys(\"advanced_options\", AdvancedOptionsModel, \"log_level\")\nMANUAL_RESTART_SETTING_KEYS = _collect_reload_group_keys(\"general\", GeneralModel, \"restart_app\")\nVISION_MODE_TYPE_SETTING_KEY = _setting_key(\"general\", \"vision_mode_type\")\n\n\nclass ScriptHandler:\n    def __init__(self):\n        self.loot_interaction_thread = None\n        self.paragon_overlay_thread: threading.Thread | None = None\n        self.did_stop_scripts = False\n        self._vision_mode_was_running_before_overlay = False\n        self._hotkey_handles: list[Any] = []\n        self._runtime_config_lock = threading.RLock()\n        self._manual_restart_warning = False\n        self._config = IniConfigLoader()\n        self._language = self._config.general.language\n        self._log_level = self._config.advanced_options.log_lvl.value.upper()\n        self.vision_mode = self._create_vision_mode(self._config.general.vision_mode_type)\n\n        self.setup_key_binds()\n        self._config.register_change_listener(self._on_config_changed)\n        if self._config.general.run_vision_mode_on_startup:\n            self.run_vision_mode()\n\n    def _create_vision_mode(self, vision_mode_type: VisionModeType):\n        if vision_mode_type == VisionModeType.fast:\n            return src.scripts.vision_mode_fast.VisionModeFast()\n        return src.scripts.vision_mode_with_highlighting.VisionModeWithHighlighting()\n\n    def _graceful_exit(self):\n        safe_exit()\n\n    def _on_config_changed(self, changed_keys: AbstractSet[str]) -> None:\n        \"\"\"Apply relevant settings after a config change event.\"\"\"\n        with self._runtime_config_lock:\n            if _has_any_changed(changed_keys, LOG_LEVEL_SETTING_KEYS):\n                self._refresh_logging_level(self._config)\n            if _has_any_changed(changed_keys, HOTKEY_SETTING_KEYS):\n                self._refresh_hotkeys(self._config)\n            if _has_any_changed(changed_keys, LANGUAGE_SETTING_KEYS):\n                self._refresh_language_assets(self._config)\n            if VISION_MODE_TYPE_SETTING_KEY in changed_keys:\n                self._notify_manual_restart_required(\"vision mode changes\")\n            elif _has_any_changed(changed_keys, MANUAL_RESTART_SETTING_KEYS):\n                self._notify_manual_restart_required(\"settings changes\")\n\n    def _hotkey_signature(self, config: IniConfigLoader) -> tuple[str | bool, ...]:\n        advanced_options = config.advanced_options\n        return (\n            advanced_options.run_vision_mode,\n            advanced_options.exit_key,\n            advanced_options.toggle_paragon_overlay,\n            advanced_options.vision_mode_only,\n            advanced_options.run_filter,\n            advanced_options.run_filter_drop,\n            advanced_options.run_filter_force_refresh,\n            advanced_options.force_refresh_only,\n            advanced_options.move_to_inv,\n            advanced_options.move_to_chest,\n        )\n\n    def _refresh_hotkeys(self, config: IniConfigLoader) -> None:\n        if sys.platform == \"darwin\":\n            return\n\n        current_signature = self._hotkey_signature(config)\n        if getattr(self, \"_current_hotkey_signature\", None) == current_signature:\n            return\n\n        self._clear_key_binds()\n        self.setup_key_binds()\n        LOGGER.info(\"Reloaded hotkeys from updated settings\")\n\n    def _refresh_language_assets(self, config: IniConfigLoader) -> None:\n        if config.general.language == self._language:\n            return\n\n        Dataloader().load_data()\n        self._language = config.general.language\n        LOGGER.info(\"Reloaded language assets for %s\", self._language)\n\n    def _refresh_logging_level(self, config: IniConfigLoader) -> None:\n        current_log_level = config.advanced_options.log_lvl.value.upper()\n        if current_log_level == self._log_level:\n            return\n\n        root_logger = logging.getLogger()\n        for handler in root_logger.handlers:\n            handler.setLevel(current_log_level)\n        self._log_level = current_log_level\n        LOGGER.info(\"Updated log level to %s\", current_log_level)\n\n    def _notify_manual_restart_required(self, reason: str) -> None:\n        if self._manual_restart_warning:\n            return\n\n        self._manual_restart_warning = True\n        LOGGER.warning(\"Please restart d4lf manually to apply %s.\", reason)\n\n    def toggle_paragon_overlay(self):\n        \"\"\"Toggle the Paragon overlay thread (start if not running, request close if running).\"\"\"\n        try:\n            if self.paragon_overlay_thread is not None and self.paragon_overlay_thread.is_alive():\n                LOGGER.info(\"Closing Paragon overlay\")\n                with suppress(Exception):\n                    request_close()\n                self.paragon_overlay_thread.join(timeout=2)\n                # Vision mode is restored by the overlay thread cleanup.\n                return\n\n            config = self._config\n            overlay_dir = config.user_dir / \"profiles\"\n            overlay_dir.mkdir(parents=True, exist_ok=True)\n\n            yaml_files = list(overlay_dir.glob(\"*.yaml\")) + list(overlay_dir.glob(\"*.yml\"))\n            if not yaml_files:\n                LOGGER.warning(\n                    \"No profile YAML files found in %s. Import a build first (Importer), then open the overlay again.\",\n                    overlay_dir,\n                )\n\n            # Disable vision mode while the overlay is active; restore it when the overlay closes.\n            self._vision_mode_was_running_before_overlay = self.vision_mode.running()\n            if self._vision_mode_was_running_before_overlay:\n                self.vision_mode.stop()\n\n            LOGGER.info(\"Opening Paragon overlay (source: %s)\", overlay_dir)\n            self.paragon_overlay_thread = threading.Thread(\n                target=self._run_paragon_overlay, args=(str(overlay_dir),), daemon=True\n            )\n            self.paragon_overlay_thread.start()\n\n        except Exception:\n            LOGGER.exception(\"Failed to toggle Paragon overlay\")\n\n    def _run_paragon_overlay(self, preset_path: str) -> None:\n        try:\n            run_paragon_overlay(preset_path)\n        except Exception:\n            LOGGER.exception(\"Paragon overlay crashed\")\n        finally:\n            try:\n                if self._vision_mode_was_running_before_overlay and not self.vision_mode.running():\n                    self.vision_mode.start()\n            except Exception:\n                LOGGER.exception(\"Failed to restore vision mode after Paragon overlay\")\n            finally:\n                self.paragon_overlay_thread = None\n\n    def _clear_key_binds(self) -> None:\n        if sys.platform == \"darwin\":\n            return\n\n        while self._hotkey_handles:\n            handle = self._hotkey_handles.pop()\n            with suppress(KeyError, ValueError):\n                keyboard.remove_hotkey(handle)\n\n    def _register_hotkey(self, hotkey: str, callback: Callable[[], None]) -> None:\n        self._hotkey_handles.append(keyboard.add_hotkey(hotkey, callback))\n\n    def setup_key_binds(self):\n        if sys.platform == \"darwin\":\n            LOGGER.info(\"Global hotkeys are disabled on macOS\")\n            return\n\n        config = self._config\n        advanced_options = config.advanced_options\n        self._register_hotkey(advanced_options.run_vision_mode, lambda: self.run_vision_mode())\n        self._register_hotkey(advanced_options.exit_key, lambda: self._graceful_exit())\n        self._register_hotkey(advanced_options.toggle_paragon_overlay, lambda: self.toggle_paragon_overlay())\n        if not advanced_options.vision_mode_only:\n            self._register_hotkey(advanced_options.run_filter, lambda: self.filter_items())\n            self._register_hotkey(advanced_options.run_filter_drop, lambda: self.filter_items(no_match_action=\"drop\"))\n            self._register_hotkey(\n                advanced_options.run_filter_force_refresh, lambda: self.filter_items(ItemRefreshType.force_with_filter)\n            )\n            self._register_hotkey(\n                advanced_options.force_refresh_only, lambda: self.filter_items(ItemRefreshType.force_without_filter)\n            )\n            self._register_hotkey(advanced_options.move_to_inv, lambda: self.move_items_to_inventory())\n            self._register_hotkey(advanced_options.move_to_chest, lambda: self.move_items_to_stash())\n\n        self._current_hotkey_signature = self._hotkey_signature(config)\n\n    def filter_items(self, force_refresh=ItemRefreshType.no_refresh, no_match_action: str = \"junk\"):\n        if src.tts.CONNECTED:\n            self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, no_match_action))\n        else:\n            LOGGER.warning(\n                \"TTS connection has not been made yet. Have you followed all of the instructions in %s? \"\n                \"If so, it's possible your Windows user does not have the correct permissions to allow Diablo 4 \"\n                \"to connect to a third party screen reader.\",\n                SETUP_INSTRUCTIONS_URL,\n            )\n\n    def move_items_to_inventory(self):\n        self._start_or_stop_loot_interaction_thread(move_items_to_inventory)\n\n    def move_items_to_stash(self):\n        self._start_or_stop_loot_interaction_thread(move_items_to_stash)\n\n    def _start_or_stop_loot_interaction_thread(self, loot_interaction_method: Callable[..., None], method_args=()):\n        if LOCK.acquire(blocking=False):\n            try:\n                if self.loot_interaction_thread is not None:\n                    LOGGER.info(\"Stopping filter or move process\")\n                    kill_thread(self.loot_interaction_thread)\n                    self.loot_interaction_thread = None\n                    if self.did_stop_scripts and not self.vision_mode.running():\n                        self.vision_mode.start()\n                else:\n                    self.loot_interaction_thread = threading.Thread(\n                        target=self._wrapper_run_loot_interaction_method,\n                        args=(loot_interaction_method, method_args),\n                        daemon=True,\n                    )\n                    self.loot_interaction_thread.start()\n            finally:\n                LOCK.release()\n        else:\n            return\n\n    def _wrapper_run_loot_interaction_method(self, loot_interaction_method: Callable[..., None], method_args=()):\n        try:\n            # We will stop all scripts if they are currently running and restart them afterward if needed.\n            self.did_stop_scripts = False\n            if self.vision_mode.running():\n                self.vision_mode.stop()\n                self.did_stop_scripts = True\n\n            loot_interaction_method(*method_args)\n\n            if self.did_stop_scripts:\n                self.run_vision_mode()\n        finally:\n            self.loot_interaction_thread = None\n\n    def run_vision_mode(self):\n        if LOCK.acquire(blocking=False):\n            try:\n                if self.vision_mode.running():\n                    self.vision_mode.stop()\n                else:\n                    self.vision_mode.start()\n            finally:\n                LOCK.release()\n        else:\n            return\n\n\ndef run_loot_filter(force_refresh: ItemRefreshType = ItemRefreshType.no_refresh, no_match_action: str = \"junk\"):\n    LOGGER.info(\"Running loot filter\")\n    mouse.move(*Cam().abs_window_to_monitor((0, 0)))\n    check_items = src.scripts.loot_filter_tts.check_items\n\n    inv = CharInventory()\n    stash = Stash()\n\n    if stash.is_open():\n        for i in IniConfigLoader().general.check_chest_tabs:\n            stash.switch_to_tab(i)\n            time.sleep(0.3)\n            check_items(stash, force_refresh, stash_is_open=True, no_match_action=\"junk\")\n        mouse.move(*Cam().abs_window_to_monitor((0, 0)))\n        time.sleep(0.3)\n        check_items(inv, force_refresh, stash_is_open=True, no_match_action=\"junk\")\n    else:\n        if not inv.open():\n            screenshot(\"inventory_not_open\", img=Cam().grab())\n            LOGGER.error(\"Inventory did not open up\")\n            return\n        check_items(inv, force_refresh, no_match_action=no_match_action)\n    mouse.move(*Cam().abs_window_to_monitor((0, 0)))\n    LOGGER.info(\"Loot filter done\")\n"
  },
  {
    "path": "src/scripts/loot_filter_tts.py",
    "content": "import logging\nimport time\nfrom typing import TYPE_CHECKING\n\nimport src.item.descr.read_descr_tts\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import ItemRefreshType, UnfilteredUniquesType\nfrom src.item.data.affix import AffixType\nfrom src.item.data.item_type import ItemType, is_sigil\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.filter import Filter\nfrom src.scripts.common import (\n    drop_item_from_inventory,\n    is_ignored_item,\n    mark_as_favorite,\n    mark_as_junk,\n    reset_item_status,\n)\nfrom src.utils.custom_mouse import mouse\nfrom src.utils.window import screenshot\n\nif TYPE_CHECKING:\n    from src.ui.inventory_base import InventoryBase\n\nLOGGER = logging.getLogger(__name__)\n\n\ndef check_items(\n    inv: InventoryBase, force_refresh: ItemRefreshType, stash_is_open: bool = False, no_match_action: str = \"junk\"\n):\n    occupied, _ = inv.get_item_slots()\n\n    def _handle_no_match() -> None:\n        if no_match_action == \"drop\" and not stash_is_open:\n            drop_item_from_inventory()\n        else:\n            mark_as_junk()\n\n    if force_refresh in {ItemRefreshType.force_with_filter, ItemRefreshType.force_without_filter}:\n        reset_item_status(occupied, inv)\n        occupied, _ = inv.get_item_slots()\n\n    if force_refresh == ItemRefreshType.force_without_filter:\n        return\n\n    num_fav = sum(1 for slot in occupied if slot.is_fav)\n    num_junk = sum(1 for slot in occupied if slot.is_junk)\n    LOGGER.info(f\"Items: {len(occupied)} (favorite: {num_fav}, junk: {num_junk}) in {inv.menu_name}\")\n    # These are used to check if there's any signs that the user does not have Advanced Tooltip Comparison on\n    num_of_items_with_all_ga = 0\n    num_of_affixed_items_checked = 0\n    start_checking_items = time.time()\n    for item in occupied:\n        if item.is_junk or item.is_fav:\n            continue\n        inv.hover_item_with_delay(item)\n        time.sleep(0.1)\n        img = Cam().grab()\n        item_descr = None\n        retry_count = 0\n\n        while item_descr is None and retry_count != 2:\n            try:\n                item_descr = src.item.descr.read_descr_tts.read_descr()\n                LOGGER.debug(f\"Attempt {retry_count} to parse item based on TTS: {item_descr}\")\n                retry_count += 1\n            except Exception:\n                screenshot(\"tts_error\", img=img)\n                LOGGER.exception(f\"Error in TTS read_descr. {src.tts.LAST_ITEM=}\")\n\n        if item_descr is None:\n            continue\n\n        # Hardcoded filters\n        if is_ignored_item(item_descr):\n            if (\n                not stash_is_open\n                and item_descr.item_type == ItemType.TemperManual\n                and IniConfigLoader().general.auto_use_temper_manuals\n            ):\n                mouse.click(\"right\")\n            continue\n\n        num_of_affixed_items_checked += 1\n        if item_descr.affixes and all(affix.type == AffixType.greater for affix in item_descr.affixes):\n            num_of_items_with_all_ga += 1\n\n        # Check if we want to keep the item\n        res = Filter().should_keep(item_descr)\n        matched_any_affixes = len(res.matched) > 0 and len(res.matched[0].matched_affixes) > 0\n        matched_aspect = any(match.aspect_match for match in res.matched)\n\n        # Uniques have special handling. They might be a keep but should actually be ignored\n        if item_descr.rarity == ItemRarity.Unique and item_descr.item_type != ItemType.Tribute:\n            if not res.keep:\n                _handle_no_match()\n            elif res.keep:\n                if len(res.matched) == 1 and res.matched[0].profile.lower() == \"cosmetics\":\n                    LOGGER.info(\"Ignoring unique because it matches no filters and is a cosmetic upgrade.\")\n                elif any(match.aspect_match for match in res.matched) and IniConfigLoader().general.mark_as_favorite:\n                    # This means it was a legitimate match, not an ignore\n                    mark_as_favorite()\n                elif IniConfigLoader().general.handle_uniques == UnfilteredUniquesType.favorite:\n                    mark_as_favorite()\n        elif not res.keep:\n            if IniConfigLoader().general.do_not_junk_ancestral_legendaries and any(\n                affix.type == AffixType.greater for affix in item_descr.affixes\n            ):\n                LOGGER.info(\"Skipping marking as junk because it is an ancestral legendary.\")\n            else:\n                _handle_no_match()\n        elif (\n            res.keep\n            and (\n                matched_any_affixes\n                or matched_aspect\n                or item_descr.rarity == ItemRarity.Mythic\n                or is_sigil(item_descr.item_type)\n                or item_descr.item_type == ItemType.Tribute\n            )\n            and IniConfigLoader().general.mark_as_favorite\n        ):\n            mark_as_favorite()\n\n    LOGGER.debug(f\"Time to filter all items in stash/inventory tab: {time.time() - start_checking_items:.2f}s\")\n\n    # If more than 80% of the items had all greater affixes that means something is probably wrong\n    if num_of_affixed_items_checked > 2 and (num_of_items_with_all_ga / num_of_affixed_items_checked > 0.8):\n        LOGGER.warning(\n            f\"{num_of_items_with_all_ga} out of {num_of_affixed_items_checked} non-junk rarity items checked had all greater affixes. You are either exceptionally lucky or have not enabled Advanced Tooltip Information in Options > Gameplay\"\n        )\n"
  },
  {
    "path": "src/scripts/vision_mode_fast.py",
    "content": "import logging\nimport queue\nimport tkinter as tk\nfrom tkinter import font\nfrom tkinter.font import Font\n\nimport src.item.descr.read_descr_tts\nimport src.tts\nfrom src.cam import Cam\nfrom src.config.helper import singleton\nfrom src.config.loader import IniConfigLoader\nfrom src.config.ui import ResManager\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.filter import Filter, MatchedFilter\nfrom src.scripts.common import ASPECT_UPGRADES_LABEL, get_filter_colors, is_ignored_item\nfrom src.tts import Publisher\nfrom src.utils.custom_mouse import mouse\nfrom src.utils.window import screenshot\n\nLOGGER = logging.getLogger(__name__)\n\n\n@singleton\nclass VisionModeFast:\n    def __init__(self):\n        self.root = tk.Tk()\n        self.root.overrideredirect(True)\n        self.root.attributes(\"-topmost\", True)\n        self.root.attributes(\"-transparentcolor\", \"white\")\n        self.root.attributes(\"-alpha\", 1.0)\n        self.canvas = tk.Canvas(self.root, bg=\"white\", highlightthickness=0)\n        self.canvas.pack(fill=tk.BOTH, expand=True)\n        self.canvas.config(height=self.root.winfo_screenheight(), width=self.root.winfo_screenwidth())\n        self.textbox = tk.Text(self.root, bg=\"black\", fg=\"black\", wrap=tk.WORD, borderwidth=0, highlightthickness=0)\n        self.textbox.config(state=tk.DISABLED)\n        self.clear_timer_id = None\n        self.queue = queue.Queue()\n        self.draw_from_queue()\n        self.is_running = False\n\n    def adjust_textbox_size(self):\n        self.textbox.config(state=tk.NORMAL)\n        self.textbox.update_idletasks()\n        text_content = self.textbox.get(1.0, tk.END)\n        line_count = text_content.count(\"\\n\")\n\n        text_font = font.Font(font=self.textbox.tag_cget(\"colored\", \"font\"))\n        line_height = text_font.metrics(\"linespace\")\n        max_line_length = max(len(line) for line in text_content.splitlines())\n\n        width = max_line_length * text_font.measure(\"0\")\n        height = (line_count + 1) * line_height\n\n        mouse_pos = Cam().monitor_to_window(mouse.get_position())\n        self.textbox.config(x=mouse_pos[0], y=mouse_pos[1], width=width // 9, height=(height // line_height) - 2)\n\n        self.textbox.config(state=tk.DISABLED)\n\n    def clear_textbox(self):\n        if hasattr(self, \"textbox\"):\n            self.textbox.destroy()\n\n    def create_textbox(self):\n        self.clear_textbox()\n        minimum_font_size = IniConfigLoader().general.minimum_overlay_font_size\n        minimum_font = Font(family=\"Courier New\", size=minimum_font_size)\n        self.textbox = tk.Text(\n            self.root, bg=\"black\", wrap=tk.WORD, borderwidth=0, highlightthickness=0, font=minimum_font\n        )\n        if IniConfigLoader().advanced_options.fast_vision_mode_coordinates is None:\n            x = ResManager().resolution[0] / 2\n            y = ResManager().resolution[1] / 5\n        else:\n            x = IniConfigLoader().advanced_options.fast_vision_mode_coordinates[0]\n            y = IniConfigLoader().advanced_options.fast_vision_mode_coordinates[1]\n        self.textbox.place(x=x, y=y)\n        self.textbox.config(state=tk.DISABLED)\n\n    def draw_from_queue(self):\n        try:\n            task = self.queue.get_nowait()\n            if task[0] == \"text\":\n                self.insert_colored_text(task[1], task[2])\n            if task[0] == \"clear\":\n                self.clear_textbox()\n        except queue.Empty:\n            pass\n\n        self.canvas.after(10, self.draw_from_queue)\n\n    def insert_colored_text(self, text, color):\n        self.create_textbox()\n        self.textbox.config(state=tk.NORMAL)\n        self.textbox.insert(tk.END, text + \"\\n\", \"colored\")\n        self.textbox.tag_configure(\"colored\", foreground=color)\n        self.adjust_textbox_size()\n        self.refresh_clear_timer()\n        self.textbox.config(state=tk.DISABLED)\n\n    def refresh_clear_timer(self):\n        if self.clear_timer_id is not None:\n            self.root.after_cancel(self.clear_timer_id)\n\n        self.clear_timer_id = self.root.after(5000, self.clear_textbox)\n\n    def request_clear(self):\n        self.queue.put((\"clear\",))\n\n    def request_draw(self, text, color):\n        self.queue.put((\"text\", text, color))\n\n    def on_tts(self, _):\n        try:\n            item_descr = None\n            try:\n                item_descr = src.item.descr.read_descr_tts.read_descr()\n                LOGGER.debug(f\"Parsed item based on TTS: {item_descr}\")\n            except Exception:\n                img = Cam().grab()\n                screenshot(\"tts_error\", img=img)\n                LOGGER.exception(f\"Error in TTS read_descr. {src.tts.LAST_ITEM=}\")\n            if item_descr is None:\n                return None\n\n            ignored_item = is_ignored_item(item_descr)\n            if ignored_item:\n                self.request_clear()\n                return None\n\n            if item_descr is None:\n                LOGGER.info(\"Unknown Item\")\n                return self.request_draw(\"Unknown item\", \"#ce7e00\")\n\n            res = Filter().should_keep(item_descr)\n\n            if res.keep:\n                color = get_filter_colors().matched\n                if not res.matched:\n                    if item_descr.rarity == ItemRarity.Unique:\n                        text = [\"Unique\"]\n                    elif item_descr.rarity == ItemRarity.Mythic:\n                        text = [\"Mythic (Always Kept)\"]\n                else:\n                    if any(res_matched.profile.endswith(ASPECT_UPGRADES_LABEL) for res_matched in res.matched):\n                        color = get_filter_colors().codex_upgrade\n                    text = create_match_text(reversed(res.matched))\n                return self.request_draw(\"\\n\".join(text), color)\n            self.request_clear()\n        except Exception:\n            LOGGER.exception(\"Error in vision mode. Please create a bug report\")\n\n    def start(self):\n        LOGGER.info(\"Starting Vision Mode\")\n        Publisher().subscribe(self.on_tts)\n        self.is_running = True\n\n    def stop(self):\n        LOGGER.info(\"Stopping Vision Mode\")\n        self.request_clear()\n        Publisher().unsubscribe(self.on_tts)\n        self.is_running = False\n\n    def running(self):\n        return self.is_running\n\n\ndef create_match_text(matches: list[MatchedFilter]):\n    result = []\n    for match in matches:\n        match_list = [f\"  - {ma.name}\" for ma in match.matched_affixes]\n        if match.aspect_match:\n            match_list.append(\"  - Aspect\")\n        result.append(f\"{match.profile}\\n\" + \"\\n\".join(match_list))\n\n    return result\n"
  },
  {
    "path": "src/scripts/vision_mode_with_highlighting.py",
    "content": "import logging\nimport math\nimport queue\nimport threading\nimport time\nimport tkinter as tk\nfrom threading import Event, Thread\nfrom tkinter.font import Font\nfrom typing import TYPE_CHECKING\n\nimport numpy as np\n\nimport src.item.descr.read_descr_tts\nimport src.tts\nfrom src.cam import Cam\nfrom src.config.helper import singleton\nfrom src.config.loader import IniConfigLoader\nfrom src.config.ui import ResManager\nfrom src.item.data.item_type import is_sigil\nfrom src.item.data.seasonal_attribute import SeasonalAttribute\nfrom src.item.filter import Filter, FilterResult\nfrom src.item.find_descr import find_descr\nfrom src.scripts.common import ASPECT_UPGRADES_LABEL, get_filter_colors, is_ignored_item, reset_canvas\nfrom src.tts import Publisher\nfrom src.ui.char_inventory import CharInventory\nfrom src.ui.stash import Stash\nfrom src.ui.vendor import Vendor\nfrom src.utils.custom_mouse import mouse\nfrom src.utils.image_operations import compare_histograms\nfrom src.utils.window import screenshot\n\nif TYPE_CHECKING:\n    from src.item.models import Item\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass CancellationRequested(Exception):\n    \"\"\"Exception raised when a cancellation is requested.\"\"\"\n\n\n@singleton\nclass VisionModeWithHighlighting:\n    def __init__(self):\n        super().__init__()\n        self.root = tk.Tk()\n        self.root.overrideredirect(True)\n        self.root.attributes(\"-topmost\", True)\n        self.root.attributes(\"-alpha\", 1.0)\n        self.root.attributes(\"-transparentcolor\", \"white\")\n        self.canvas = tk.Canvas(self.root, bg=\"white\", highlightthickness=0)\n        self.canvas.pack(fill=tk.BOTH, expand=True)\n        self.clear_when_item_not_selected_thread = None\n        self.clear_when_item_not_selected_thread_cancel_event = None\n        self.evaluate_item_thread = None\n        self.evaluate_item_thread_cancel_event = None\n        self.current_item = None\n        self.is_cleared = True\n        self.queue = queue.Queue()\n        self.draw_from_queue()\n        self.is_running = False\n        self.root.geometry(\"0x0+0+0\")\n        self.thick = int(Cam().window_roi[\"height\"] * 0.0047)\n\n        inv = CharInventory()\n        stash = Stash()\n        vendor = Vendor()\n        img = Cam().grab()\n        self.max_slot_size = stash.get_max_slot_size()\n        occ_inv, empty_inv = inv.get_item_slots(img)\n        occ_stash, empty_stash = stash.get_item_slots(img)\n        occ_vendor, empty_vendor = vendor.get_item_slots(img)\n        possible_centers = []\n        possible_centers += [slot.center for slot in occ_inv]\n        possible_centers += [slot.center for slot in empty_inv]\n\n        # add possible centers of equipped items\n        for x in ResManager().pos.possible_centers:\n            possible_centers.append(x)\n\n        possible_vendor_centers = possible_centers.copy()\n        possible_vendor_centers += [slot.center for slot in occ_vendor]\n        possible_vendor_centers += [slot.center for slot in empty_vendor]\n\n        possible_centers += [slot.center for slot in occ_stash]\n        possible_centers += [slot.center for slot in empty_stash]\n\n        self.possible_centers = np.array(possible_centers)\n        self.possible_vendor_centers = np.array(possible_vendor_centers)\n\n        self.screen_off_x = Cam().window_roi[\"left\"]\n        self.screen_off_y = Cam().window_roi[\"top\"]\n\n    def draw_rect(self, canvas: tk.Canvas, bullet_width, obj, off, color):\n        offset_loc = np.array(obj.loc) + off\n        x1 = int(offset_loc[0] - bullet_width / 2)\n        y1 = int(offset_loc[1] - bullet_width / 2)\n        x2 = int(offset_loc[0] + bullet_width / 2)\n        y2 = int(offset_loc[1] + bullet_width / 2)\n        self.canvas.create_rectangle(x1, y1, x2, y2, fill=color)\n\n    def draw_text(self, canvas, text, color, previous_text_y, offset, canvas_center_x) -> int:\n        if not text:\n            return None\n\n        font_name = \"Courier New\"\n        minimum_font_size = IniConfigLoader().general.minimum_overlay_font_size\n\n        font_size = minimum_font_size\n        window_height = ResManager().pos.window_dimensions[1]\n        if window_height == 1440:\n            font_size = minimum_font_size + 1\n        elif window_height == 1600:\n            font_size = minimum_font_size + 2\n        elif window_height == 2160:\n            font_size = minimum_font_size + 3\n\n        font = Font(family=font_name, size=font_size)\n        width_per_character = font.measure(text) / len(text)\n        height_of_character = font.metrics(\"linespace\")\n        max_text_length_per_line = canvas_center_x * 2 // width_per_character\n        if max_text_length_per_line < len(text):  # Use a smaller font\n            font_size = minimum_font_size\n            font = Font(family=font_name, size=font_size)\n            width_per_character = font.measure(text) / len(text)\n            height_of_character = font.metrics(\"linespace\")\n            max_text_length_per_line = canvas_center_x * 2 // width_per_character\n\n        # Create a gray rectangle as the background\n        text_width = int(width_per_character * len(text))\n        text_width = min(text_width, canvas_center_x * 2)\n        number_of_lines = math.ceil(len(text) / max_text_length_per_line)\n        text_height = int(height_of_character * number_of_lines)\n\n        dark_gray_color = \"#111111\"\n        canvas.create_rectangle(\n            canvas_center_x - text_width // 2,  # x1\n            previous_text_y - offset - text_height,  # y1\n            canvas_center_x + text_width // 2,  # x2\n            previous_text_y - offset,  # y2\n            fill=dark_gray_color,\n            outline=\"\",\n        )\n        canvas.create_text(\n            canvas_center_x,\n            previous_text_y - offset,\n            text=text,\n            anchor=tk.S,\n            font=(\"Courier New\", font_size),\n            fill=color,\n            width=text_width,\n        )\n        return int(previous_text_y - offset - text_height)\n\n    def create_signal_rect(self, canvas, w, thick, color):\n        canvas.create_rectangle(0, 0, w, thick * 2, outline=\"\", fill=color)\n        steps = int((thick * 20) / 40)\n        for i in range(100):\n            stipple = \"\"\n            if i > 75:\n                stipple = \"gray75\"\n            if i > 80:\n                stipple = \"gray50\"\n            if i > 95:\n                stipple = \"gray25\"\n            if i > 90:\n                stipple = \"gray12\"\n            start_y = steps * i\n            end_y = steps * (i + 1)\n\n            canvas.create_rectangle(0, start_y, thick * 2, end_y, fill=color, outline=\"\", stipple=stipple)\n            canvas.create_rectangle(w - thick * 2, start_y, w, end_y, fill=color, outline=\"\", stipple=stipple)\n\n    def draw_from_queue(self):\n        try:\n            task = self.queue.get_nowait()\n            # LOGGER.debug(f\"Queue size: {self.queue.qsize()}, task: {task}\")\n            if task[0] == \"clear\":\n                reset_canvas(self.root, self.canvas)\n                self.is_cleared = True\n            else:\n                item_desc = task[1]\n                if item_desc == self.current_item:\n                    self.is_cleared = False\n                    if task[0] == \"empty\":\n                        self.draw_empty_outline(task[2], task[3], task[4])\n                    if task[0] == \"match\":\n                        self.draw_match_outline(task[2], task[3], task[4])\n                    if task[0] == \"codex_upgrade\":\n                        self.draw_codex_upgrade_outline(task[2], task[3])\n                    if task[0] == \"no_match\":\n                        self.draw_no_match_outline(task[2])\n        except queue.Empty:\n            pass\n\n        self.canvas.after(10, self.draw_from_queue)\n\n    def draw_empty_outline(self, item_roi, color, text: str | None):\n        reset_canvas(self.root, self.canvas)\n\n        # Make the canvas gray for \"found the item\"\n        x, y, w, h, off = self.get_coords_from_roi(item_roi)\n        self.canvas.config(height=h, width=w)\n        self.create_signal_rect(self.canvas, w, self.thick, color)\n\n        if text:\n            self.draw_text(self.canvas, text, color, h, 5, w // 2)\n\n        self.root.geometry(f\"{w}x{h}+{x + self.screen_off_x}+{y + self.screen_off_y}\")\n        self.root.update_idletasks()\n        self.root.update()\n\n    def draw_match_outline(self, item_roi, should_keep_res, item_descr):\n        x, y, w, h, off = self.get_coords_from_roi(item_roi)\n        self.create_signal_rect(self.canvas, w, self.thick, get_filter_colors().matched)\n\n        # show all info strings of the profiles\n        text_y = h\n        for match in reversed(should_keep_res.matched):\n            text_y = self.draw_text(self.canvas, match.profile, get_filter_colors().matched, text_y, 5, w // 2)\n        # Show matched bullets\n        if item_descr and len(should_keep_res.matched) > 0:\n            bullet_width = self.thick * 3\n            for affix in should_keep_res.matched[0].matched_affixes:\n                if affix.loc:\n                    self.draw_rect(self.canvas, bullet_width, affix, off, get_filter_colors().matched)\n\n            if item_descr.aspect and item_descr.aspect.loc and any(m.aspect_match for m in should_keep_res.matched):\n                self.draw_rect(self.canvas, bullet_width, item_descr.aspect, off, get_filter_colors().matched)\n\n        self.root.update_idletasks()\n        self.root.update()\n\n    def draw_no_match_outline(self, item_roi):\n        x, y, w, h, off = self.get_coords_from_roi(item_roi)\n        self.create_signal_rect(self.canvas, w, self.thick, get_filter_colors().no_match)\n        self.root.update_idletasks()\n        self.root.update()\n\n    def draw_codex_upgrade_outline(self, item_roi, should_keep_result: FilterResult):\n        x, y, w, h, off = self.get_coords_from_roi(item_roi)\n\n        self.create_signal_rect(self.canvas, w, self.thick, get_filter_colors().codex_upgrade)\n\n        # show string indicating that this item upgrades the codex\n        if len(should_keep_result.matched) == 1 and should_keep_result.matched[0].profile == ASPECT_UPGRADES_LABEL:\n            self.draw_text(self.canvas, \"Codex Upgrade\", get_filter_colors().codex_upgrade, h, 5, w // 2)\n        else:\n            # This matched an Aspects section in a profile, write the profiles\n            text_y = h\n            for match in reversed(should_keep_result.matched):\n                text_y = self.draw_text(\n                    self.canvas, match.profile, get_filter_colors().codex_upgrade, text_y, 5, w // 2\n                )\n\n        self.root.update_idletasks()\n        self.root.update()\n\n    def on_tts(self, _):\n        img = Cam().grab()\n        item_descr = None\n        try:\n            item_descr = src.item.descr.read_descr_tts.read_descr()\n            LOGGER.debug(f\"Parsed item based on TTS: {item_descr}\")\n        except Exception:\n            screenshot(\"tts_error\", img=img)\n            LOGGER.exception(f\"Error in TTS read_descr. {src.tts.LAST_ITEM=}\")\n        if item_descr is None:\n            self.request_clear()\n            return\n\n        self.current_item = item_descr\n\n        # Kick off a thread that will evaluate the item and queue up the appropriate drawings.\n        # If one already exists we'll kill it since a new item has come in\n        if self.evaluate_item_thread:\n            self.stop_thread_and_wait(self.evaluate_item_thread, self.evaluate_item_thread_cancel_event)\n\n        self.evaluate_item_thread_cancel_event = threading.Event()\n        self.evaluate_item_thread = threading.Thread(\n            target=self.evaluate_item_and_queue_draw, args=(item_descr,), daemon=True\n        )\n        self.evaluate_item_thread.start()\n\n    def evaluate_item_and_queue_draw(self, item_descr: Item):\n        if not self.is_cleared:\n            self.request_clear()\n        if self.clear_when_item_not_selected_thread:\n            self.stop_thread_and_wait(\n                self.clear_when_item_not_selected_thread, self.clear_when_item_not_selected_thread_cancel_event\n            )\n            self.clear_when_item_not_selected_thread = None\n\n        last_top_left_corner = None\n        last_center = None\n        # Each item must be detected twice and the image must match, this is to avoid\n        # getting in item while the fade-in animation and failing to read it properly\n        is_confirmed = False\n        retry_count = 0\n        try:\n            while retry_count < 5 and not is_confirmed:\n                self.check_for_thread_cancellation(self.evaluate_item_thread_cancel_event)\n                retry_count += 1\n                mouse_pos = Cam().monitor_to_window(mouse.get_position())\n                # get closest pos to a item center\n                centers_to_use = self.possible_vendor_centers if item_descr.is_in_shop else self.possible_centers\n                delta = centers_to_use - mouse_pos\n                distances = np.linalg.norm(delta, axis=1)\n                closest_index = np.argmin(distances)\n                item_center = centers_to_use[closest_index]\n\n                self.check_for_thread_cancellation(self.evaluate_item_thread_cancel_event)\n\n                # Before we get the cropped_descr we need to ensure there is no previous overlay on screen\n                while not self.is_cleared:\n                    time.sleep(0.10)\n                found, rarity, cropped_descr, item_roi = find_descr(Cam().grab(), item_center)\n\n                top_left_corner = None if not found else item_roi[:2]\n                if found:\n                    if not is_confirmed:\n                        found_check, _, cropped_descr_check, _ = find_descr(Cam().grab(), item_center)\n                        if found_check:\n                            score = compare_histograms(cropped_descr, cropped_descr_check)\n                            if score < 0.99:\n                                continue\n                            is_confirmed = True\n\n                    self.check_for_thread_cancellation(self.evaluate_item_thread_cancel_event)\n\n                    if (\n                        last_top_left_corner is None\n                        or last_top_left_corner[0] != top_left_corner[0]\n                        or last_top_left_corner[1] != top_left_corner[1]\n                        or (last_center is not None and last_center[1] != item_center[1])\n                    ):\n                        ignored_item = is_ignored_item(item_descr)\n                        # Make the canvas gray for \"found the item\" or blue for \"ignored this item\"\n                        if ignored_item:\n                            if item_descr.seasonal_attribute == SeasonalAttribute.sanctified:\n                                self.request_empty_outline(\n                                    item_descr, item_roi, get_filter_colors().unhandled, \"Sanctified (Not Supported)\"\n                                )\n                            else:\n                                self.request_empty_outline(item_descr, item_roi, get_filter_colors().unhandled)\n                        else:\n                            self.request_empty_outline(item_descr, item_roi, get_filter_colors().processing)\n\n                        # Since we've now drawn something we kick off a thread to remove the drawing\n                        # if the item is unselected. It is also automatically removed if a different\n                        # TTS item comes in.\n                        self.check_for_thread_cancellation(self.evaluate_item_thread_cancel_event)\n                        if not self.clear_when_item_not_selected_thread:\n                            self.clear_when_item_not_selected_thread_cancel_event = threading.Event()\n                            self.clear_when_item_not_selected_thread = threading.Thread(\n                                target=self.check_for_item_still_selected, args=(item_center,), daemon=True\n                            )\n                            self.clear_when_item_not_selected_thread.start()\n\n                        if ignored_item:\n                            return\n\n                        # Check if the item is a match based on our filters\n                        last_top_left_corner = top_left_corner\n                        last_center = item_center\n\n                        if item_descr == self.current_item:\n                            # We need to get the item_descr again but this time with affix locations\n                            if is_sigil(item_descr.item_type):\n                                # We won't highlight specific affixes for sigils. We'll see if people complain\n                                item_descr_with_loc = item_descr\n                            else:\n                                item_descr_with_loc = src.item.descr.read_descr_tts.read_descr_mixed(cropped_descr)\n                            res = Filter().should_keep(item_descr_with_loc)\n                            match = res.keep\n\n                            # Adapt colors based on config\n                            if match:\n                                if any(\n                                    res_matched.profile.endswith(ASPECT_UPGRADES_LABEL) for res_matched in res.matched\n                                ):\n                                    self.request_codex_upgrade_box(item_descr, item_roi, res)\n                                else:\n                                    self.request_match_box(item_descr, item_roi, res, item_descr_with_loc)\n                            elif not match:\n                                self.request_no_match_box(item_descr, item_roi)\n                else:\n                    self.request_clear()\n                    self.check_for_thread_cancellation(self.evaluate_item_thread_cancel_event)\n                    last_center = None\n                    last_top_left_corner = None\n                    is_confirmed = False\n                    time.sleep(0.15)\n        except CancellationRequested:\n            pass\n        except Exception:\n            LOGGER.exception(\"Error in vision mode. Please create a bug report\")\n        finally:\n            self.evaluate_item_thread = None\n\n    @staticmethod\n    def check_for_thread_cancellation(cancel_event: Event):\n        if cancel_event.is_set():\n            raise CancellationRequested\n\n    @staticmethod\n    def stop_thread_and_wait(thread: Thread, cancel_event: Event):\n        cancel_event.set()\n        thread.join()\n\n    def check_for_item_still_selected(self, item_center):\n        try:\n            while True:\n                self.check_for_thread_cancellation(self.clear_when_item_not_selected_thread_cancel_event)\n                found_check, _, _, _ = find_descr(Cam().grab(), item_center)\n                if not found_check:\n                    self.request_clear()\n                    self.clear_when_item_not_selected_thread = None\n                    break\n                time.sleep(0.15)\n        except CancellationRequested:\n            self.clear_when_item_not_selected_thread = None\n\n    def get_coords_from_roi(self, item_roi):\n        x, y, w, h = item_roi\n        off = int(w * 0.1)\n        x -= off\n        y -= off\n        w += off * 2\n        h += off * 5\n        return x, y, w, h, off\n\n    def request_clear(self):\n        self.queue.put((\"clear\",))\n\n    def request_empty_outline(self, item_descr, item_roi, color, text: str | None = None):\n        self.queue.put((\"empty\", item_descr, item_roi, color, text))\n\n    def request_match_box(self, item_descr, item_roi, should_keep_res, item_descr_with_affix):\n        self.queue.put((\"match\", item_descr, item_roi, should_keep_res, item_descr_with_affix))\n\n    def request_no_match_box(self, item_descr, item_roi):\n        self.queue.put((\"no_match\", item_descr, item_roi))\n\n    def request_codex_upgrade_box(self, item_descr, item_roi, res):\n        self.queue.put((\"codex_upgrade\", item_descr, item_roi, res))\n\n    def start(self):\n        LOGGER.info(\"Starting Vision Mode\")\n        Publisher().subscribe(self.on_tts)\n        self.is_running = True\n\n    def stop(self):\n        LOGGER.info(\"Stopping Vision Mode\")\n        self.request_clear()\n        if self.evaluate_item_thread:\n            self.stop_thread_and_wait(self.evaluate_item_thread, self.evaluate_item_thread_cancel_event)\n            self.evaluate_item_thread = None\n        if self.clear_when_item_not_selected_thread:\n            self.stop_thread_and_wait(\n                self.clear_when_item_not_selected_thread, self.clear_when_item_not_selected_thread_cancel_event\n            )\n            self.clear_when_item_not_selected_thread = None\n        Publisher().unsubscribe(self.on_tts)\n        self.is_running = False\n\n    def running(self):\n        return self.is_running\n"
  },
  {
    "path": "src/startup_messages.py",
    "content": "import logging\nfrom pathlib import Path\n\nfrom src import __version__\nfrom src.config.loader import IniConfigLoader\n\nBANNER = (\n    \"════════════════════════════════════════════════════════════════════════════════\\n\"\n    \"D4LF - Diablo 4 Loot Filter\\n\"\n    \"════════════════════════════════════════════════════════════════════════════════\"\n)\n\n\ndef emit_startup_messages():\n    \"\"\"Emit the simplified startup banner for the new UI.\n\n    No hotkey table. No extra formatting.\n    \"\"\"\n    logger = logging.getLogger(__name__)\n    logger.info(BANNER)\n\n\ndef emit_early_startup_logs():\n    \"\"\"Emit early startup logs exactly as before.\n\n    - version\n    - config path\n    - missing profiles warning\n    \"\"\"\n    logger = logging.getLogger(__name__)\n\n    # 1. Running version\n    logger.info(f\"Running version v{__version__}\")\n\n    # 2. Adapt your configs\n    logger.info(f\"Adapt your configs in: {IniConfigLoader().user_dir}\")\n\n    # 3. No profiles configured warning (if applicable)\n    profiles_dir = Path(IniConfigLoader().user_dir) / \"profiles\"\n    profile_files = list(profiles_dir.glob(\"*.ini\"))\n\n    if not profile_files:\n        logger.warning(\n            \"No profiles have been configured so no filtering will be done. \"\n            \"If this is a mistake, use the profiles section in Settings \"\n            \"to activate the profiles you want to use.\"\n        )\n"
  },
  {
    "path": "src/template_finder.py",
    "content": "import logging\nimport threading\nimport time\nfrom dataclasses import dataclass\n\nimport cv2\nimport numpy as np\n\nfrom src import TP\nfrom src.cam import Cam\nfrom src.config.data import COLORS, Template\nfrom src.config.ui import ResManager\nfrom src.utils.image_operations import alpha_to_mask, color_filter, crop\nfrom src.utils.misc import run_until_condition\nfrom src.utils.roi_operations import get_center\nfrom src.utils.window import screenshot\n\nLOGGER = logging.getLogger(__name__)\n\nTEMPLATES_LOCK = threading.Lock()\n\n\n@dataclass\nclass TemplateMatch:\n    center: tuple[int, int] = None\n    center_monitor: tuple[int, int] = None\n    name: str = None\n    region: list[int, int, int, int] = None\n    region_monitor: list[int, int, int, int] = None\n    score: float = -1.0\n\n    def __eq__(self, other):\n        if isinstance(other, TemplateMatch):\n            return self.center == other.center and self.score == other.score\n        return False\n\n    def __hash__(self):\n        return hash((self.center, self.score))\n\n\n@dataclass\nclass SearchResult:\n    matches: list[TemplateMatch] = None\n    success: bool = False\n\n    def __post_init__(self):\n        if self.matches is None:\n            self.matches = []\n\n\n@dataclass\nclass SearchArgs:\n    _search_args = None\n    ref: str | np.ndarray | list[str]\n    inp_img: np.ndarray | None = None\n    threshold: float = 0.68\n    roi: list[float] | str | None = None\n    use_grayscale: bool = False\n    color_match: list[float] | str | None = None\n    mode: str = \"first\"\n    timeout: int = 0\n    suppress_debug: bool = True\n    do_multi_process: bool = True\n\n    def __call__(self, cls):\n        cls._search_args = self\n        return cls\n\n    def as_dict(self):\n        return self.__dict__\n\n    def detect(self, img: np.ndarray = None) -> SearchResult:\n        if img is not None:\n            self.inp_img = img\n        else:\n            Cam().grab() if self.inp_img is None else self.inp_img\n        return search(**self.as_dict())\n\n    def is_visible(self, img: np.ndarray = None) -> bool:\n        return self.detect(img).success\n\n    def wait_until_visible(self, timeout: float = 30, suppress_debug: bool = False) -> SearchResult:\n        if (\n            not (result := run_until_condition(lambda: self.detect(), lambda match: match.success, timeout)[0]).success\n            and not suppress_debug\n        ):\n            LOGGER.debug(f\"{self.ref} not found after {timeout} seconds\")\n        return result\n\n    def wait_until_hidden(self, timeout: float = 3, suppress_debug: bool = False) -> bool:\n        if (\n            not (hidden := run_until_condition(lambda: self.detect().success, lambda res: not res, timeout)[1])\n            and not suppress_debug\n        ):\n            LOGGER.debug(f\"{self.ref} still found after {timeout} seconds\")\n        return hidden\n\n    @staticmethod\n    def wait_for_update(\n        img: np.ndarray, roi: list[int] | None = None, timeout: float = 3, suppress_debug: bool = False\n    ) -> bool:\n        roi = roi if roi is not None else [0, 0, img.shape[0] - 1, img.shape[1] - 1]\n        if (\n            not (\n                change := run_until_condition(\n                    lambda: crop(Cam().grab(), roi), lambda res: not np.array_equal(crop(img, roi), res), timeout\n                )[1]\n            )\n            and not suppress_debug\n        ):\n            LOGGER.debug(f\"ROI: '{roi}' unchanged after {timeout} seconds\")\n        return change\n\n\ndef _process_template_refs(ref: str | np.ndarray | list[str]) -> list[Template]:\n    templates = []\n    if not isinstance(ref, list):\n        ref = [ref]\n    for i in ref:\n        # if the reference is a string, then it's a reference to a named template asset\n        if isinstance(i, str):\n            try:\n                templates.append(ResManager().templates[i.lower()])\n            except KeyError:\n                LOGGER.warning(f\"Template not defined: {i}\")\n        # if the reference is an image, append new Template class object\n        elif isinstance(i, np.ndarray):\n            templates.append(\n                Template(img_bgr=i, img_gray=cv2.cvtColor(i, cv2.COLOR_BGR2GRAY), alpha_mask=alpha_to_mask(i))\n            )\n    return templates\n\n\ndef _get_cv_result(\n    template: Template,\n    inp_img: np.ndarray,\n    roi: list[float] | None = None,\n    color_match: list[float] | None = None,\n    use_grayscale: bool = False,\n    take_debug_screenshot: bool = False,\n) -> list[np.ndarray]:\n    # crop image to roi\n    if roi is None:\n        # if no roi is provided roi = full inp_img\n        roi = [0, 0, inp_img.shape[1], inp_img.shape[0]]\n    roi = np.clip(np.array(roi), 0, None)\n    rx, ry, rw, rh = roi\n    img = inp_img[ry : ry + rh, rx : rx + rw]\n    if img.shape[0] == 0 or img.shape[1] == 0:\n        return None, template.img_bgr, roi\n    if take_debug_screenshot:\n        screenshot(\"template_finder\", img=img)\n\n    # filter for desired color or make grayscale\n    if color_match:\n        _, template_img = color_filter(template.img_bgr, color_match)\n        _, img = color_filter(img, color_match)\n    elif use_grayscale:\n        template_img = template.img_gray\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    else:\n        template_img = template.img_bgr\n    if not (img.shape[0] > template_img.shape[0] and img.shape[1] > template_img.shape[1]):\n        # LOGGER.error(\n        #     f\"Image shape and template shape are incompatible: {template.name}. Image: {img.shape}, Template: {template_img.shape}, roi: {roi}\"\n        # )\n        res = None\n    else:\n        res = cv2.matchTemplate(img, template_img, cv2.TM_CCOEFF_NORMED, mask=template.alpha_mask)\n        np.nan_to_num(res, copy=False, nan=0.0, posinf=0.0, neginf=0.0)\n    return res, template_img, roi\n\n\ndef search(\n    ref: str | np.ndarray | list[str],\n    inp_img: np.ndarray | None = None,\n    threshold: float = 0.68,\n    roi: list[float] | str | None = None,\n    use_grayscale: bool = False,\n    color_match: list[float] | str | None = None,\n    mode: str = \"first\",\n    timeout: int = 0,\n    suppress_debug: bool = True,\n    do_multi_process: bool = True,\n    take_debug_screenshot: bool = False,\n) -> SearchResult:\n    \"\"\"Search for templates in an image.\n\n    :param ref: Either key of a already loaded template, list of such keys, or a image which is used as template\n    :param inp_img: Image in which the template will be searched\n    :param threshold: Threshold which determines if a template is found or not\n    :param roi: Region of Interest of the inp_img to restrict search area. Format [left, top, width, height] or string corresponding to a key in Config().ui_roi\n    :param use_grayscale: Use grayscale template matching for speed up\n    :param color_match: Pass a color to be used by misc.color_filter to filter both image of interest and template image (format Config().colors[\"color\"]) or string corresponding to a key in Config().colors\n    :param mode: search \"first\" match or \"all\" matches\n    :param timeout: wait for the specified number of seconds before stopping search\n    :param do_multi_process: flag if multi process should be used in case there are multiple refs\n    :return: SearchResult object containing success and matches\n    \"\"\"\n    templates = _process_template_refs(ref)\n    result = SearchResult()\n    matches = []\n    future_list = []\n    if isinstance(roi, str):\n        try:\n            roi = getattr(ResManager().roi, roi)\n        except KeyError as e:\n            LOGGER.error(f\"Invalid roi key: {roi}\")\n            LOGGER.error(e)\n    if isinstance(color_match, str):\n        try:\n            color_match = getattr(COLORS, color_match)\n        except KeyError as e:\n            LOGGER.error(f\"Invalid color_match key: {color_match}\")\n            LOGGER.error(e)\n\n    def _process_cv_result(template: Template, img: np.ndarray, take_debug_screenshot: bool = False) -> bool:\n        new_match = False\n        res, template_img, new_roi = _get_cv_result(\n            template, img, roi, color_match, use_grayscale, take_debug_screenshot\n        )\n\n        while True and not (matches and mode == \"first\") and res is not None:\n            _, max_val, _, max_pos = cv2.minMaxLoc(res)\n\n            if max_val >= threshold:\n                new_match = True\n                # Save rectangle corresponding to the matched region\n                rec_x = int(max_pos[0] + new_roi[0])\n                rec_y = int(max_pos[1] + new_roi[1])\n                rec_w = int(template_img.shape[1])\n                rec_h = int(template_img.shape[0])\n\n                template_match = TemplateMatch()\n                template_match.region = [rec_x, rec_y, rec_w, rec_h]\n                template_match.region_monitor = [*Cam().window_to_monitor((rec_x, rec_y)), rec_w, rec_h]\n                template_match.center = get_center(template_match.region)\n                template_match.center_monitor = Cam().window_to_monitor(template_match.center)\n                template_match.name = template.name\n                template_match.score = max_val\n\n                matches.append(template_match)\n                if mode == \"first\":\n                    break\n                # Remove the matched region from the result\n                cv2.rectangle(\n                    res,\n                    (max_pos[0] - template_img.shape[1] // 2, max_pos[1] - template_img.shape[0] // 2),\n                    (max_pos[0] + template_img.shape[1], max_pos[1] + template_img.shape[0]),\n                    (0, 0, 0),\n                    -1,\n                )\n                # result_norm = cv2.normalize(res, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U)\n                # cv2.imwrite(f\"res{i}.png\", result_norm)\n                # i += 1\n            else:\n                break\n        return new_match\n\n    start = time.time()\n    time_remains = True\n    while time_remains and not matches:\n        img = Cam().grab() if inp_img is None else inp_img\n        if do_multi_process:\n            for template in templates:\n                future = TP.submit(_process_cv_result, template, img, take_debug_screenshot)\n                future_list.append(future)\n\n                for i in future_list:\n                    _ = i.result()\n        else:\n            for template in templates:\n                res = _process_cv_result(template, img, take_debug_screenshot)\n                if mode == \"first\" and res:\n                    break\n\n        time_remains = time.time() - start < timeout\n\n    if matches:\n        result.success = True\n        result.matches = sorted(matches, key=lambda obj: obj.score, reverse=True)\n        if not suppress_debug and len(matches) > 1 and mode == \"all\":\n            details = \"\\n\".join(\n                f\"  {template_match.name} ({template_match.score * 100:.1f}% confidence)\" for template_match in matches\n            )\n            msg = f\"Found the following matches:\\n{details}\"\n            LOGGER.debug(msg)\n    elif not suppress_debug:\n        LOGGER.debug(f\"Could not find desired templates: {ref}\")\n\n    return result\n"
  },
  {
    "path": "src/tools/__init__.py",
    "content": ""
  },
  {
    "path": "src/tools/data/custom_affixes_enUS.json",
    "content": "{\n    \"corpse_damage\": \"corpse damage\",\n    \"crowd_control_duration_lucky_hit_up_to_a_chance_to_heal_life\": \"crowd control duration lucky hit up to a chance to heal life\",\n    \"fire_damage_ranks_of_the_inner_flames_passive\": \"fire damage ranks of the inner flames passive\",\n    \"lucky_hit_up_to_a_chance_to_gain_damage_for_seconds\": \"lucky hit up to a chance to gain damage for seconds\",\n    \"nature_magic_skill_cooldown_reduction\": \"nature magic skill cooldown reduction\",\n    \"rain_of_arrows_skill_cooldown_reduction\": \"rain of arrows skill cooldown reduction\",\n    \"rank_of_all_agility_skills\": \"rank of all agility skills\",\n    \"ranks_of_the_aggressive_resistance_passive\": \"ranks of the aggressive resistance passive\",\n    \"ranks_of_the_concussive_passive\": \"ranks of the concussive passive\",\n    \"ranks_of_the_heightened_senses_passive\": \"ranks of the heightened senses passive\",\n    \"ranks_of_the_hewed_flesh_passive\": \"ranks of the hewed flesh passive\",\n    \"resource_regeneration\": \"resource regeneration\",\n    \"to_deaths_reach\": \"to deaths reach\"\n}\n"
  },
  {
    "path": "src/tools/data/custom_sigils_enUS.json",
    "content": "{\n    \"dungeons\": {},\n    \"major\": {},\n    \"positive\": {\n        \"chaos_rifts\": \"chaos rifts have opened in this place.\"\n    }\n}\n"
  },
  {
    "path": "src/tools/gen_data.py",
    "content": "# generate data from d4data repo\nimport json\nimport re\nfrom pathlib import Path\n\nD4LF_BASE_DIR = Path(__file__).parent.parent.parent\n\nGEAR_TYPES = [\n    \"Amulet\",\n    \"Axe\",\n    \"Axe2H\",\n    \"Boots\",\n    \"Bow\",\n    \"ChestArmor\",\n    \"Crossbow2H\",\n    \"Dagger\",\n    \"Flail\",\n    \"Focus\",\n    \"Glaive\",\n    \"Gloves\",\n    \"Helm\",\n    \"Legs\",\n    \"Mace\",\n    \"Mace2H\",\n    \"OffHandTotem\",\n    \"Polearm\",\n    \"Quarterstaff\",\n    \"Ring\",\n    \"Scythe\",\n    \"Scythe2H\",\n    \"Shield\",\n    \"Staff\",\n    \"Sword\",\n    \"Sword2H\",\n    \"Wand\",\n]\n\n\ndef remove_content_in_braces(input_string) -> str:\n    pattern = r\"\\{.*?\\}\"\n    result = re.sub(pattern, \"\", input_string)\n    pattern = r\"\\[.*?\\]\"\n    result = re.sub(pattern, \"\", result)\n    result = re.sub(r\"#%.*?#%\", \"\", result)\n    result = re.sub(r\"\\|.*?:\", \"|:\", result)\n    result = result.replace(\"|\", \"\")\n    result = result.replace(\";\", \"\")\n    result = re.sub(r\"(\\d)[, ]+(\\d)\", r\"\\1\\2\", result)  # Remove , between numbers (large number seperator)\n    result = re.sub(r\"(\\+)?\\d+(\\.\\d+)?%?\", \"\", result)  # Remove numbers and trailing % or preceding +\n    result = re.sub(r\"[\\[\\]+\\-:%\\'\\#]\", \"\", result)  # Remove [ and ] and leftover +, -, %, :, ', #\n    result = \" \".join(result.split())  # Remove extra spaces\n    result.strip()\n    return result\n\n\ndef get_random_number_idx(s: str) -> list[int]:\n    filtered_string = re.findall(r\"\\{c_random\\}|\\{c_number\\}\", s)\n    res = []\n    for i, val in enumerate(filtered_string):\n        if val == \"{c_random}\":\n            res.append(i)\n    return res\n\n\ndef is_placeholder_or_test_name(name) -> bool:\n    if any(\n        x in name\n        for x in [\n            \"(ph)\",\n            \"[ph]\",\n            \"[wip]\",\n            \"(ptr)\",\n            \"(debug)\",\n            \"[_ph_]\",\n            \"[ph_\",\n            \"bucranis_\",\n            \"boost_\",\n            \"_test_\",\n            \"(not_used\",\n            \"(dns)\",\n            \"(crucible)\",\n            \"(redesign)\",\n        ]\n    ):\n        return True\n\n    return name.startswith(\"ph_\")\n\n\ndef check_ms(input_string) -> str:\n    start_index = input_string.find(\"[ms]\")\n    end_index = input_string.find(\"[fs]\")\n\n    # Check if both \"[ms]\" and \"[fs]\" are present\n    if start_index != -1 and end_index != -1:\n        # Extract the part between \"[ms]\" and \"[fs]\"\n        input_string = input_string[start_index + 4 : end_index]\n\n    prefixes = [\"[ms]\", \"[ns]\", \"[fs]\", \"[p]\"]\n    for prefix in prefixes:\n        if input_string.startswith(prefix):\n            input_string = input_string[len(prefix) :]\n            break\n\n    return input_string.replace(\"{d}\", \"\")\n\n\ndef main(d4data_dir: Path, companion_app_dir: Path):\n    lang_arr = [\n        \"enUS\"\n    ]  # \"deDE\", \"frFR\", \"esES\", \"esMX\", \"itIT\", \"jaJP\", \"koKR\", \"plPL\", \"ptBR\", \"ruRU\", \"trTR\", \"zhCN\", \"zhTW\"]\n\n    for lang in lang_arr:\n        file_names = [\n            f\"assets/lang/{lang}/affixes.json\",\n            f\"assets/lang/{lang}/aspects.json\",\n            f\"assets/lang/{lang}/uniques.json\",\n            f\"assets/lang/{lang}/sigils.json\",\n            f\"assets/lang/{lang}/tributes.json\",\n            f\"assets/lang/{lang}/item_types.json\",\n            f\"assets/lang/{lang}/tooltips.json\",\n        ]\n        for f in file_names:\n            if Path(f).exists():\n                Path(f).unlink()\n        Path(f\"assets/lang/{lang}\").mkdir(exist_ok=True, parents=True)\n\n    for language in lang_arr:\n        # Create Aspects\n        generate_aspects(d4data_dir, language)\n\n        # Create Uniques\n        generate_uniques(d4data_dir, language)\n\n        print(f\"Gen Sigils for {language}\")\n        sigil_dict = {\"dungeons\": {}, \"minor\": {}, \"major\": {}, \"positive\": {}}\n\n        # Add others automatically\n        pattern = f\"json/{language}_Text/meta/StringList/World_DGN_*.stl.json\"\n        json_files = sorted(d4data_dir.glob(pattern, case_sensitive=False))\n        for json_file in json_files:\n            with Path(json_file).open(encoding=\"utf-8\") as file:\n                data = json.load(file)\n                name_idx, _ = (0, 1) if data[\"arStrings\"][0][\"szLabel\"] == \"Name\" else (1, 0)\n                dungeon_name: str = (\n                    data[\"arStrings\"][name_idx][\"szText\"].lower().strip().replace(\"’\", \"\").replace(\"'\", \"\")\n                )\n                sigil_dict[\"dungeons\"][dungeon_name.replace(\" \", \"_\")] = dungeon_name\n\n        pattern = f\"json/{language}_Text/meta/StringList/DungeonAffix_*.stl.json\"\n        json_files = sorted(d4data_dir.glob(pattern, case_sensitive=False))\n        for json_file in json_files:\n            affix_type = json_file.stem.split(\"_\")[1].lower().strip()\n            if affix_type in sigil_dict:\n                with Path(json_file).open(encoding=\"utf-8\") as file:\n                    data = json.load(file)\n                    name = \"\"\n                    desc = \"\"\n                    for sigil_affix in data[\"arStrings\"]:\n                        if sigil_affix[\"szLabel\"] == \"AffixName\":\n                            name = sigil_affix[\"szText\"].lower().strip().replace(\"’\", \"\").replace(\"'\", \"\")\n                            name = name.replace(\"(\", \"\").replace(\")\", \"\")\n                            name = remove_content_in_braces(name)\n                        else:\n                            desc = sigil_affix[\"szText\"].lower().strip().replace(\"’\", \"\").replace(\"'\", \"\")\n                            desc = remove_content_in_braces(desc)\n                    sigil_dict[affix_type][name.replace(\" \", \"_\")] = f\"{name} {desc}\"\n\n        # Add any sigils we might be missing. Right now, that's none, but we leave the option for the future\n        with Path(D4LF_BASE_DIR / f\"src/tools/data/custom_sigils_{language}.json\").open(encoding=\"utf-8\") as file:\n            data = json.load(file)\n            for key, values in data.items():\n                if key in sigil_dict:\n                    for key2, value2 in values.items():\n                        if key2 in sigil_dict[key]:\n                            if sigil_dict[key][key2] == value2:\n                                print(f\"Sigil {key2} already exists in sigils.json. Can be deleted from custom json\")\n                            else:\n                                print(f\"Sigil {key2} already exists in sigils.json but with different value\")\n                                sigil_dict[key][key2] = value2\n                        else:\n                            sigil_dict[key][key2] = value2\n                else:\n                    sigil_dict[key] = values\n\n        with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/sigils.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n            json.dump(sigil_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n            json_file.write(\"\\n\")\n\n        print(f\"Gen Tributes for {language}\")\n        tribute_dict = {}\n\n        # Add others automatically\n        pattern = f\"json/{language}_Text/meta/StringList/Item_*_TributeKeySigil_*.stl.json\"\n        json_files = sorted(d4data_dir.glob(pattern, case_sensitive=False))\n        for json_file in json_files:\n            with Path(json_file).open(encoding=\"utf-8\") as file:\n                data = json.load(file)\n                name_idx, _ = (0, 1) if data[\"arStrings\"][0][\"szLabel\"] == \"Name\" else (1, 0)\n                tribute_name: str = (\n                    data[\"arStrings\"][name_idx][\"szText\"].lower().strip().replace(\"’\", \"\").replace(\"'\", \"\")\n                )\n                tribute_dict[tribute_name.replace(\" \", \"_\").replace(\"(\", \"\").replace(\")\", \"\")] = tribute_name\n\n        with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/tributes.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n            json.dump(tribute_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n            json_file.write(\"\\n\")\n\n        print(f\"Gen ItemTypes for {language}\")\n        whitelist_types = GEAR_TYPES.copy()\n        whitelist_types.extend([\"Elixir\", \"TemperManual\", \"Tome\"])\n        item_typ_dict = {\n            \"Material\": \"custom type material\",\n            \"Sigil\": \"custom type sigil\",\n            \"Incense\": \"custom type incense\",\n        }\n        pattern = f\"json/{language}_Text/meta/StringList/ItemType_*.stl.json\"\n        json_files = sorted(d4data_dir.glob(pattern, case_sensitive=False))\n        for json_file in json_files:\n            item_type = json_file.stem.split(\"_\")[1].split(\".\")[0].strip()\n            with Path(json_file).open(encoding=\"utf-8\") as file:\n                data = json.load(file)\n                name_idx = 0 if data[\"arStrings\"][0][\"szLabel\"] == \"Name\" else 1\n                name_str: str = check_ms(data[\"arStrings\"][name_idx][\"szText\"]).lower().strip()\n                if item_type in whitelist_types:\n                    item_typ_dict[item_type] = name_str\n        with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/item_types.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n            json.dump(item_typ_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n            json_file.write(\"\\n\")\n\n        print(f\"Gen Tooltips for {language}\")\n        tooltip_dict = {}\n        with Path(d4data_dir / f\"json/{language}_Text/meta/StringList/UIToolTips.stl.json\").open(\n            encoding=\"utf-8\"\n        ) as file:\n            data = json.load(file)\n            for arString in data[\"arStrings\"]:\n                if arString[\"szLabel\"] == \"ItemPower\":\n                    tooltip_dict[\"ItemPower\"] = remove_content_in_braces(check_ms(arString[\"szText\"].lower()))\n        with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/tooltips.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n            json.dump(tooltip_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n            json_file.write(\"\\n\")\n\n        # Create Affixes\n        print(f\"Gen Affixes for {language}\")\n        affix_dict = {}\n        with Path(companion_app_dir / f\"D4Companion/Data/Affixes.{language}.json\").open(encoding=\"utf-8\") as file:\n            data = json.load(file)\n            for affix in data:\n                desc: str = affix[\"Description\"]\n                desc = desc.lower().strip().replace(\"'\", \"\").replace(\"’\", \"\").replace(\".\", \"\")\n                desc = remove_content_in_braces(desc)\n                desc = desc.removeprefix(\"x \")\n                name = desc.replace(\",\", \"\").replace(\" \", \"_\")\n                if len(desc) > 2:\n                    affix_dict[name] = desc\n        # Some of the unique specific affixes are missing. Add them manually\n        with Path(D4LF_BASE_DIR / f\"src/tools/data/custom_affixes_{language}.json\").open(encoding=\"utf-8\") as file:\n            data = json.load(file)\n            for key, value in data.items():\n                if key in affix_dict:\n                    if affix_dict[key] == value:\n                        print(f\"Affix {key} already exists in affixes.json. Can be deleted from custom json\")\n                    else:\n                        print(f\"Affix {key} already exists in affixes.json but with different value\")\n                        affix_dict[key] = value\n                else:\n                    affix_dict[key] = value\n        with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/affixes.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n            json.dump(affix_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n            json_file.write(\"\\n\")\n\n        print(\"=============================\")\n\n\ndef generate_aspects(d4data_dir, language):\n    print(f\"Gen Aspects for {language}\")\n    aspects_list = []\n    aspect_pattern = \"json/base/meta/Aspect/*.json\"\n    aspect_files = sorted(d4data_dir.glob(aspect_pattern, case_sensitive=False))\n\n    for core_aspect_file in aspect_files:\n        if core_aspect_file.name.endswith(\"Axe Bad Data.asp.json\"):\n            continue\n        # Get the associated Aspect file, which will tell us where to find the aspect file\n        with Path(core_aspect_file).open(encoding=\"utf-8\") as aspect_file:\n            # Get affix name from the file\n            aspect_data = json.load(aspect_file)\n            affix_name = aspect_data[\"snoAffix\"][\"name\"]\n\n        core_affix_file_name = f\"Affix_{affix_name}.stl.json\"\n        core_affix_file = d4data_dir / f\"json/{language}_Text/meta/StringList/{core_affix_file_name}\"\n        if not core_affix_file.exists():\n            print(f\"WARNING: Could not find file named {core_affix_file} in d4data.\")\n\n        with Path(core_affix_file).open(encoding=\"utf-8\") as file:\n            data = json.load(file)\n            name_idx = 0 if data[\"arStrings\"][0][\"szLabel\"] == \"Name\" else 1\n            aspect_name = data[\"arStrings\"][name_idx][\"szText\"]\n            aspect_name_clean = aspect_name.strip().replace(\" \", \"_\").lower().replace(\"’\", \"\").replace(\"'\", \"\")\n            aspect_name_clean = check_ms(aspect_name_clean)\n            if is_placeholder_or_test_name(aspect_name_clean):\n                continue\n            aspects_list.append(aspect_name_clean)\n\n    with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/aspects.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n        aspects_list.sort()\n        json.dump(aspects_list, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n        json_file.write(\"\\n\")\n\n\ndef generate_uniques(d4data_dir, language):\n    items_to_ignore = [\"halo\", \"pact_amulet\", \"wilted_potential\"]\n\n    print(f\"Gen Uniques for {language}\")\n    unique_dict = {}\n    unique_pattern = \"json/base/meta/Item/*nique*.itm.json\"\n    unique_files = sorted(d4data_dir.glob(unique_pattern, case_sensitive=False))\n\n    for core_unique_file in unique_files:\n        if core_unique_file.name.startswith(\"S10_\"):\n            # Chaos uniques really throw off our inherent counts\n            continue\n        # Get inherent count and item type from this file. Beyond that, we need the file name to find the enUS strings file.\n        num_inherents = 0\n        with Path(core_unique_file).open(encoding=\"utf-8\") as unique_item_file:\n            unique_item_data = json.load(unique_item_file)\n            if \"arForcedAffixes\" not in unique_item_data or not unique_item_data[\"arForcedAffixes\"]:\n                continue\n            item_type = unique_item_data[\"snoItemType\"][\"name\"]\n            inherent_affixes = unique_item_data[\"arInherentAffixes\"]\n\n        if item_type not in GEAR_TYPES and item_type != \"FocusBookOffHand\":\n            continue\n\n        # Some items, like Mortacrux, will list one inherent and then break it into two in the affix file.\n        # We will use the affix file for the true inherent count.\n        for inherent_affix in inherent_affixes:\n            # Inexplicably this inherent is broken into two when it's just 1\n            if inherent_affix[\"name\"].startswith(\"UNIQUE_INHERENT_Evade_MovementSpeed_\"):\n                num_inherents += 1\n                continue\n            affix_file_path = inherent_affix[\"__targetFileName__\"]\n            affix_file = d4data_dir / f\"json/{affix_file_path}.json\"\n            with Path(affix_file).open(encoding=\"utf-8\") as unique_affix_file:\n                affix_data = json.load(unique_affix_file)\n                num_inherents += len(affix_data[\"ptItemAffixAttributes\"])\n\n        core_unique_file_id = core_unique_file.name.split(\".\")[0]\n        string_item_file_name = f\"Item_{core_unique_file_id}.stl.json\"\n        string_item_file = d4data_dir / f\"json/{language}_Text/meta/StringList/{string_item_file_name}\"\n\n        if not string_item_file.exists():\n            print(f\"WARNING: Could not find file named {string_item_file} in d4data.\")\n            continue\n\n        with Path(string_item_file).open(encoding=\"utf-8\") as file:\n            data = json.load(file)\n            name_item = [item for item in data[\"arStrings\"] if item[\"szLabel\"] == \"Name\"]\n            if not name_item:\n                continue\n            name = name_item[0][\"szText\"]\n            name_clean = (\n                name\n                .strip()\n                .replace(\" \", \"_\")\n                .replace(\"\\xa0\", \"_\")\n                .lower()\n                .replace(\"’\", \"\")\n                .replace(\"'\", \"\")\n                .replace(\",\", \"\")\n            )\n            name_clean = check_ms(name_clean)\n            if name_clean in items_to_ignore or is_placeholder_or_test_name(name_clean):\n                continue\n\n            unique_dict[name_clean] = {\"num_inherents\": num_inherents}\n\n    with Path(D4LF_BASE_DIR / f\"assets/lang/{language}/uniques.json\").open(\"w\", encoding=\"utf-8\") as json_file:\n        json.dump(unique_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True)\n        json_file.write(\"\\n\")\n\n\nif __name__ == \"__main__\":\n    import argparse\n\n    parser = argparse.ArgumentParser(description=\"Path Argument Parser\")\n    parser.add_argument(\n        \"d4data_dir\", type=str, help=\"Provide a path to d4data repo\"\n    )  # https://github.com/DiabloTools/d4data.git\n    parser.add_argument(\n        \"companion_app_dir\", type=str, help=\"Provide a path to companion_app_dir repo\"\n    )  # https://github.com/josdemmers/Diablo4Companion\n    args = parser.parse_args()\n\n    input_path = Path(args.d4data_dir)\n    input_path2 = Path(args.companion_app_dir)\n\n    if input_path.exists() and input_path.is_dir() and input_path2.exists() and input_path2.is_dir():\n        main(input_path, input_path2)\n    else:\n        print(f\"The provided path '{input_path}' or '{input_path2}' does not exist or is not a directory.\")\n"
  },
  {
    "path": "src/tts.py",
    "content": "import enum\nimport logging\nimport queue\nimport re\nimport sys\nimport threading\n\nimport pywintypes\nimport win32file\nimport win32pipe\n\nfrom src.config.helper import singleton\n\nCONNECTED = False\nLAST_ITEM = []\nTO_FILTER = [\"Champions who earn the favor of\"]\n_DATA_QUEUE = queue.Queue(maxsize=100)\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass ItemIdentifiers(enum.Enum):\n    COMPASS = \"Compass\"\n    ESCALATION_SIGIL = \"Escalation Sigil\"\n    NIGHTMARE_SIGIL = \"Nightmare Sigil\"\n    TRIBUTE = \"TRIBUTE OF\"\n    WHISPERING_KEY = \"WHISPERING KEY\"\n\n\n@singleton\nclass Publisher:\n    def __init__(self):\n        self._subscribers = set()\n        self._subscriber_lock = threading.Lock()\n\n    def find_item(self) -> None:\n        local_cache = []\n        while True:\n            data = fix_data(_DATA_QUEUE.get())\n            local_cache.append(data)\n            if not filter_data(data) and (\n                any(word in data.lower() for word in [\"mouse button\", \"action button\"])\n                and (start := find_item_start(local_cache)) is not None\n            ):\n                global LAST_ITEM\n                LAST_ITEM = local_cache[start:]\n                LOGGER.debug(f\"TTS Found: {LAST_ITEM}\")\n                local_cache = []\n                self.publish(LAST_ITEM)\n\n    def publish(self, data):\n        with self._subscriber_lock:\n            for subscriber in self._subscribers:\n                subscriber(data)\n\n    def subscribe(self, subscriber):\n        with self._subscriber_lock:\n            self._subscribers.add(subscriber)\n\n    def unsubscribe(self, subscriber):\n        with self._subscriber_lock:\n            self._subscribers.remove(subscriber)\n\n\ndef create_pipe():\n    try:\n        return win32pipe.CreateNamedPipe(\n            r\"\\\\.\\pipe\\d4lf\",\n            win32pipe.PIPE_ACCESS_DUPLEX,\n            win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT,\n            1,\n            65536,\n            65536,\n            0,\n            None,\n        )\n    except pywintypes.error as e:\n        if e.args[0] == 231:  # ERROR_PIPE_BUSY\n            LOGGER.error(\"\")\n            LOGGER.error(\"=\" * 80)\n            LOGGER.error(\"D4LF IS ALREADY RUNNING\")\n            LOGGER.error(\"=\" * 80)\n            LOGGER.error(\"\")\n            LOGGER.error(\"You already have D4LF running in another window.\")\n            LOGGER.error(\"Please close your windows and re-launch.\")\n            LOGGER.error(\"\")\n            LOGGER.error(\"=\" * 80)\n\n            sys.exit(1)\n        else:\n            raise  # Re-raise other errors\n\n\ndef read_pipe() -> None:\n    while True:\n        handle = create_pipe()\n        LOGGER.debug(\"Waiting for TTS client to connect\")\n\n        win32pipe.ConnectNamedPipe(handle, None)\n        LOGGER.info(\"TTS client connected\")\n        global CONNECTED\n        CONNECTED = True\n\n        while True:\n            try:\n                # Block until data is available (assumes PIPE_WAIT)\n                win32file.ReadFile(handle, 0, None)\n                # Query message size\n                _, _, message_size = win32pipe.PeekNamedPipe(handle, 0)\n                # Read message\n                _, data = win32file.ReadFile(handle, message_size, None)\n                data = data.decode().replace(\"\\x00\", \"\")\n                if not data:\n                    continue\n                if \"DISCONNECTED\" in data:\n                    break\n                _DATA_QUEUE.put(data)\n            except Exception:\n                LOGGER.exception(\"Error while reading data\")\n\n        win32file.CloseHandle(handle)\n        LOGGER.info(\"TTS client disconnected\")\n        CONNECTED = False\n\n\ndef find_item_start(data: list[str]) -> int | None:\n    ignored_words = [\"COMPASS AFFIXES\", \"DUNGEON AFFIXES\", \"AFFIXES\", \"SELECT ALL\"]\n\n    for index, item in reversed(list(enumerate(data))):\n        if any(ignored in item for ignored in ignored_words):\n            continue\n\n        if any(item.startswith(x) for x in [y.value for y in ItemIdentifiers]):\n            return index\n\n        cleaned_str = re.sub(r\"[^A-Za-z]\", \"\", item)\n        if len(cleaned_str) >= 3 and item.isupper():\n            return index\n\n    return None\n\n\ndef filter_data(data: str) -> bool:\n    return any(word in data for word in TO_FILTER)\n\n\ndef fix_data(data: str) -> str:\n    to_remove = [\"&apos;\", \"&quot;\", \"[FAVORITED ITEM]. \", \"ￂﾠ\", \"(Spiritborn Only)\", \"[MARKED AS JUNK]. \"]\n\n    for item in to_remove:\n        data = data.replace(item, \"\")\n\n    return data.strip()\n\n\ndef start_connection() -> None:\n    LOGGER.info(\"Starting TTS listener. Hover over an item or button to perform the TTS connection.\")\n    threading.Thread(target=Publisher().find_item, daemon=True).start()\n    threading.Thread(target=read_pipe, daemon=True).start()\n"
  },
  {
    "path": "src/ui/__init__.py",
    "content": ""
  },
  {
    "path": "src/ui/char_inventory.py",
    "content": "from src.config.loader import IniConfigLoader\nfrom src.config.ui import ResManager\nfrom src.template_finder import SearchArgs\nfrom src.ui.inventory_base import InventoryBase\n\n\nclass CharInventory(InventoryBase):\n    def __init__(self):\n        super().__init__()\n        self.menu_name = \"Char_Inventory\"\n        self.is_open_search_args: SearchArgs = SearchArgs(\n            ref=[\"sort_icon\", \"sort_icon_hover\"], threshold=0.8, roi=ResManager().roi.sort_icon, use_grayscale=False\n        )\n        self.open_hotkey = IniConfigLoader().char.inventory\n        self.delay = 1  # Needed as they added a \"fade-in\" for the items\n"
  },
  {
    "path": "src/ui/inventory_base.py",
    "content": "from dataclasses import dataclass\n\nimport cv2\nimport numpy as np\n\nfrom src.cam import Cam\nfrom src.config.ui import ResManager\nfrom src.template_finder import search\nfrom src.ui.menu import Menu\nfrom src.utils.custom_mouse import mouse\nfrom src.utils.image_operations import crop\nfrom src.utils.roi_operations import get_center, to_grid\n\n\n@dataclass\nclass ItemSlot:\n    bounding_box: list[int] = None\n    center: list[int] = None\n    is_fav: bool = False\n    is_junk: bool = False\n\n\nclass InventoryBase(Menu):\n    \"\"\"Base class for all menus with a grid inventory.\n\n    Provides methods for identifying occupied and empty slots, item operations, etc.\n    \"\"\"\n\n    def __init__(self, rows: int = 3, columns: int = 11, is_stash: bool = False):\n        super().__init__()\n        self.rows = rows\n        self.columns = columns\n        self.slots_roi = getattr(ResManager().roi, f\"slots_{self.rows}x{self.columns}\")\n        if is_stash:\n            self.junk_template = \"junk_stash\"\n        else:\n            self.junk_template = \"junk_inv\"\n\n    def get_max_slot_size(self):\n        y_size = self.slots_roi[3] // self.rows\n        x_size = self.slots_roi[2] // self.columns\n        return max(y_size, x_size)\n\n    def get_item_slots(self, img: np.ndarray | None = None) -> tuple[list[ItemSlot], list[ItemSlot]]:\n        \"\"\"Identifies occupied and empty slots in a grid of slots within a given rectangle of interest (ROI).\n\n        :param roi: The rectangle to consider, represented as (x_min, y_min, width, height).\n        :param rows: The number of rows in the grid.\n        :param columns: The number of columns in the grid.\n        :param img: An optional image (as a numpy array) to use for identifying empty slots.\n        :return: Four sets of coordinates.\n            - Centers of the occupied slots\n            - Centers of the empty slots\n        \"\"\"\n        if img is None:\n            img = Cam().grab()\n        grid = to_grid(self.slots_roi, self.rows, self.columns)\n        occupied_slots = []\n        empty_slots = []\n\n        for slot_roi in grid:\n            item_slot = ItemSlot(bounding_box=slot_roi, center=get_center(slot_roi))\n            slot_img = crop(img, slot_roi)\n\n            hsv_img = cv2.cvtColor(slot_img, cv2.COLOR_BGR2HSV)\n            mean_value_overall = np.mean(hsv_img[:, :, 2])\n            fav_flag_crop = crop(hsv_img, ResManager().roi.rel_fav_flag)\n            mean_value_fav = cv2.mean(fav_flag_crop)[2]\n\n            res_junk = search(self.junk_template, slot_img, threshold=0.65, use_grayscale=True)\n\n            if mean_value_fav > 212:\n                item_slot.is_fav = True\n                occupied_slots.append(item_slot)\n            elif res_junk.success and mean_value_overall < 75:\n                item_slot.is_junk = True\n                occupied_slots.append(item_slot)\n            elif mean_value_overall > 37:\n                occupied_slots.append(item_slot)\n            else:\n                empty_slots.append(item_slot)\n\n        return occupied_slots, empty_slots\n\n    def hover_item(self, item: ItemSlot):\n        mouse.move(*Cam().window_to_monitor(item.center), randomize=15)\n\n    # Needed for double checking a TTS\n    def hover_left_of_item(self, item: ItemSlot):\n        mouse.move(\n            *Cam().window_to_monitor([\n                item.bounding_box[0] - item.bounding_box[2] / 2,\n                item.bounding_box[1] + item.bounding_box[3] / 2,\n            ]),\n            randomize=15,\n        )\n\n    def hover_item_with_delay(self, item: ItemSlot, delay_factor: tuple[float, float] = (2, 3)):\n        mouse.move(*Cam().window_to_monitor(item.center), randomize=15, delay_factor=delay_factor)\n"
  },
  {
    "path": "src/ui/menu.py",
    "content": "import logging\nimport sys\nimport time\nfrom enum import Enum\nfrom typing import TYPE_CHECKING\n\nif sys.platform != \"darwin\":\n    import keyboard\nfrom src.utils.misc import run_until_condition\n\nif TYPE_CHECKING:\n    import numpy as np\n\n    from src.template_finder import SearchArgs, SearchResult\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass ToggleMethod(Enum):\n    BUTTON = 1\n    HOTKEY = 2\n\n\nclass Menu:\n    def __init__(self):\n        self.menu_name: str = \"\"\n        self.parent_menu: Menu | None = None\n        self.is_open_search_args: SearchArgs | None = None\n        self.open_hotkey: str = \"\"\n        self.delay = 0\n\n    def open(self) -> bool:\n        \"\"\"Opens the menu by clicking the open button.\n\n        :return: True if the menu is successfully opened, False otherwise.\n        \"\"\"\n        # Open parent menu if there is one to be opened\n        if self.parent_menu and not self.is_open() and not self.parent_menu.open():\n            LOGGER.error(f\"Could not open parent menu {self.parent_menu.menu_name}\")\n            return False\n        if not (is_open := self.is_open()):\n            LOGGER.debug(f\"Opening {self.menu_name} using hotkey {self.open_hotkey}\")\n            keyboard.send(self.open_hotkey)\n        else:\n            LOGGER.debug(f\"{self.menu_name} already open\")\n        return is_open or self.wait_until_open()\n\n    def _check_match(self, res: SearchResult) -> bool:\n        \"\"\"Checks if the given TemplateMatch is a match for the menu.\n\n        :param res: The TemplateMatch to check.\n        \"\"\"\n        if self.is_open_search_args.mode == \"best\":\n            return res.matches[0].name.lower() == self.is_open_search_args.ref[0].lower()\n        return True\n\n    def is_open(self, img: np.ndarray = None) -> bool:\n        \"\"\"Checks if the menu is open.\n\n        :return: True if the menu is open, False otherwise.\n        \"\"\"\n        res = self.is_open_search_args.detect(img)\n        if res.success:\n            return self._check_match(res)\n        return False\n\n    def wait_until_open(self, timeout: float = 10) -> bool:\n        \"\"\"Waits until the menu is open.\n\n        :param timeout: The number of seconds to wait before timing out.\n        :return: True if the menu is open, False otherwise.\n        \"\"\"\n        _, success = run_until_condition(self.is_open, lambda res: res, timeout)\n        if not success:\n            LOGGER.warning(f\"Could not find {self.menu_name} after {timeout} seconds\")\n        time.sleep(self.delay)\n        return success\n"
  },
  {
    "path": "src/ui/stash.py",
    "content": "import logging\nimport time\n\nfrom src.cam import Cam\nfrom src.config.loader import IniConfigLoader\nfrom src.config.ui import ResManager\nfrom src.template_finder import SearchArgs\nfrom src.ui.inventory_base import InventoryBase\nfrom src.utils.custom_mouse import mouse\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass Stash(InventoryBase):\n    def __init__(self):\n        super().__init__(5, 10, is_stash=True)\n        self.menu_name = \"Stash\"\n        self.is_open_search_args = SearchArgs(\n            ref=[\"stash_menu_icon\", \"stash_menu_icon_medium\"], threshold=0.8, roi=\"stash_menu_icon\", use_grayscale=True\n        )\n        self.curr_tab = 0\n\n    @staticmethod\n    def switch_to_tab(tab_idx) -> bool:\n        number_tabs = IniConfigLoader().general.max_stash_tabs\n        LOGGER.info(f\"Switch Stash Tab to: {tab_idx}\")\n        if tab_idx > (number_tabs - 1):\n            return False\n        x, y, w, h = ResManager().roi.tab_slots\n        section_length = w // number_tabs\n        centers = [(x + (i + 0.5) * section_length, y + h // 2) for i in range(number_tabs)]\n        mouse.move(*Cam().window_to_monitor(centers[tab_idx]), randomize=2)\n        mouse.click(\"left\")\n        time.sleep(0.2)\n        return True\n"
  },
  {
    "path": "src/ui/vendor.py",
    "content": "import logging\n\nfrom src.template_finder import SearchArgs\nfrom src.ui.inventory_base import InventoryBase\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass Vendor(InventoryBase):\n    def __init__(self):\n        super().__init__(8, 1, is_stash=False)\n        self.menu_name = \"Vendor\"\n        self.is_open_search_args = SearchArgs(\n            ref=[\"vendor_menu_icon\", \"vendor_menu_icon_1080p\"],\n            threshold=0.8,\n            roi=\"vendor_menu_icon\",\n            use_grayscale=True,\n        )\n        self.curr_tab = 0\n"
  },
  {
    "path": "src/utils/__init__.py",
    "content": ""
  },
  {
    "path": "src/utils/custom_mouse.py",
    "content": "# Mostly copied from: https://github.com/patrikoss/pyclick\nimport math\nimport random\nimport time\n\nimport mouse as _mouse\nimport numpy as np\nimport pytweening\n\n\ndef isNumeric(val):\n    return isinstance(val, float | int | np.int32 | np.int64 | np.float32 | np.float64)\n\n\ndef is_list_of_points(value):\n    def is_point(p):\n        return len(p) == 2 and isNumeric(p[0]) and isNumeric(p[1])\n\n    if not isinstance(value, list):\n        return False\n    try:\n        return all(map(is_point, value))\n    except KeyError, TypeError:\n        return False\n\n\nclass BezierCurve:\n    @staticmethod\n    def binomial(n, k):\n        \"\"\"Returns the binomial coefficient: n choose k.\"\"\"\n        return math.factorial(n) / float(math.factorial(k) * math.factorial(n - k))\n\n    @staticmethod\n    def bernsteinPolynomialPoint(x, i, n):\n        \"\"\"Calculate the i-th component of a bernstein polynomial of degree n.\"\"\"\n        return BezierCurve.binomial(n, i) * (x**i) * ((1 - x) ** (n - i))\n\n    @staticmethod\n    def bernsteinPolynomial(points):\n        \"\"\"Given list of control points, returns a function, which given a point [0,1] returns a point in the bezier curve described by these points.\"\"\"\n\n        def bern(t):\n            n = len(points) - 1\n            x = y = 0\n            for i, point in enumerate(points):\n                bern = BezierCurve.bernsteinPolynomialPoint(t, i, n)\n                x += point[0] * bern\n                y += point[1] * bern\n            return x, y\n\n        return bern\n\n    @staticmethod\n    def curvePoints(n, points):\n        \"\"\"Given list of control points, returns n points in the bezier curve, described by these points.\"\"\"\n        curvePoints = []\n        bernstein_polynomial = BezierCurve.bernsteinPolynomial(points)\n        for i in range(n):\n            t = i / (n - 1)\n            curvePoints += (bernstein_polynomial(t),)\n        return curvePoints\n\n\nclass HumanCurve:\n    \"\"\"Generates a human-like mouse curve starting at given source point, and finishing in a given destination point.\"\"\"\n\n    def __init__(self, fromPoint, toPoint, **kwargs):\n        self.fromPoint = fromPoint\n        self.toPoint = toPoint\n        self.points = self.generateCurve(**kwargs)\n\n    def generateCurve(self, **kwargs):\n        \"\"\"Generates a curve according to the parameters specified below.\n\n        You can override any of the below parameters. If no parameter is\n        passed, the default value is used.\n        \"\"\"\n        offsetBoundaryX = kwargs.get(\"offsetBoundaryX\", 100)\n        offsetBoundaryY = kwargs.get(\"offsetBoundaryY\", 100)\n        leftBoundary = kwargs.get(\"leftBoundary\", min(self.fromPoint[0], self.toPoint[0])) - offsetBoundaryX\n        rightBoundary = kwargs.get(\"rightBoundary\", max(self.fromPoint[0], self.toPoint[0])) + offsetBoundaryX\n        downBoundary = kwargs.get(\"downBoundary\", min(self.fromPoint[1], self.toPoint[1])) - offsetBoundaryY\n        upBoundary = kwargs.get(\"upBoundary\", max(self.fromPoint[1], self.toPoint[1])) + offsetBoundaryY\n        knotsCount = kwargs.get(\"knotsCount\", 2)\n        distortionMean = kwargs.get(\"distortionMean\", 1)\n        distortionStdev = kwargs.get(\"distortionStdev\", 1)\n        distortionFrequency = kwargs.get(\"distortionFrequency\", 0.4)\n        tween = kwargs.get(\"tweening\", pytweening.easeOutQuad)\n        targetPoints = kwargs.get(\"targetPoints\", 10)\n\n        internalKnots = self.generateInternalKnots(leftBoundary, rightBoundary, downBoundary, upBoundary, knotsCount)\n        points = self.generatePoints(internalKnots)\n        points = self.distortPoints(points, distortionMean, distortionStdev, distortionFrequency)\n        return self.tweenPoints(points, tween, targetPoints)\n\n    def generateInternalKnots(self, leftBoundary, rightBoundary, downBoundary, upBoundary, knotsCount):\n        \"\"\"Generates the internal knots used during generation of bezier curvePoints.\n\n        or any interpolation function. The points are taken at random from\n        a surface delimited by given boundaries.\n        Exactly knotsCount internal knots are randomly generated.\n        \"\"\"\n        if not (\n            isNumeric(leftBoundary) and isNumeric(rightBoundary) and isNumeric(downBoundary) and isNumeric(upBoundary)\n        ):\n            msg = \"Boundaries must be numeric\"\n            raise ValueError(msg)\n        if not isinstance(knotsCount, int) or knotsCount < 0:\n            msg = \"knotsCount must be non-negative integer\"\n            raise ValueError(msg)\n        if leftBoundary > rightBoundary:\n            msg = \"leftBoundary must be less than or equal to rightBoundary\"\n            raise ValueError(msg)\n        if downBoundary > upBoundary:\n            msg = \"downBoundary must be less than or equal to upBoundary\"\n            raise ValueError(msg)\n\n        knotsX = np.random.choice(range(leftBoundary, rightBoundary), size=knotsCount)\n        knotsY = np.random.choice(range(downBoundary, upBoundary), size=knotsCount)\n        return list(zip(knotsX, knotsY, strict=False))\n\n    def generatePoints(self, knots):\n        \"\"\"Generates bezier curve points on a curve, according to the internal knots passed as parameter.\"\"\"\n        if not is_list_of_points(knots):\n            msg = \"knots must be valid list of points\"\n            raise ValueError(msg)\n\n        midPtsCnt = max(abs(self.fromPoint[0] - self.toPoint[0]), abs(self.fromPoint[1] - self.toPoint[1]), 2)\n        knots = [self.fromPoint, *knots, self.toPoint]\n        return BezierCurve.curvePoints(midPtsCnt, knots)\n\n    def distortPoints(self, points, distortionMean, distortionStdev, distortionFrequency):\n        \"\"\"Distorts the curve described by (x,y) points, so that the curve is not ideally smooth.\n\n        Distortion happens by randomly, according to normal distribution,\n        adding an offset to some of the points.\n        \"\"\"\n        if not (isNumeric(distortionMean) and isNumeric(distortionStdev) and isNumeric(distortionFrequency)):\n            msg = \"Distortions must be numeric\"\n            raise ValueError(msg)\n        if not is_list_of_points(points):\n            msg = \"points must be valid list of points\"\n            raise ValueError(msg)\n        if not (0 <= distortionFrequency <= 1):\n            msg = \"distortionFrequency must be in range [0,1]\"\n            raise ValueError(msg)\n\n        distorted = []\n        for i in range(1, len(points) - 1):\n            x, y = points[i]\n            delta = np.random.normal(distortionMean, distortionStdev) if random.random() < distortionFrequency else 0\n            distorted += ((x, y + delta),)\n        return [points[0], *distorted, points[-1]]\n\n    def tweenPoints(self, points, tween, targetPoints):\n        \"\"\"Chooses a number of points(targetPoints) from the list(points) according to tweening function(tween).\n\n        This function in fact controls the velocity of mouse movement\n        \"\"\"\n        if not is_list_of_points(points):\n            msg = \"points must be valid list of points\"\n            raise ValueError(msg)\n        if not isinstance(targetPoints, int) or targetPoints < 2:\n            msg = \"targetPoints must be an integer greater or equal to 2\"\n            raise ValueError(msg)\n\n        # tween is a function that takes a float 0..1 and returns a float 0..1\n        res = []\n        for i in range(targetPoints):\n            index = int(tween(float(i) / (targetPoints - 1)) * (len(points) - 1))\n            res += (points[index],)\n        return res\n\n\nclass mouse:\n    def move(\n        x: int,\n        y: int,\n        absolute: bool = True,\n        randomize: int | tuple[int, int] = 5,\n        delay_factor: tuple[float, float] = (0.2, 0.3),\n    ):\n        from_point = _mouse.get_position()\n        dist = math.dist((x, y), from_point)\n        offsetBoundaryX = max(10, int(0.08 * dist))\n        offsetBoundaryY = max(10, int(0.08 * dist))\n        targetPoints = min(6, max(3, int(0.004 * dist)))\n        if not absolute:\n            x = from_point[0] + x\n            y = from_point[1] + y\n\n        if isinstance(randomize, int):\n            randomize = int(randomize)\n            if randomize > 0:\n                x = int(x) + random.randrange(-randomize, +randomize)\n                y = int(y) + random.randrange(-randomize, +randomize)\n        else:\n            randomize = (int(randomize[0]), int(randomize[1]))\n            if randomize[1] > 0 and randomize[0] > 0:\n                x = int(x) + random.randrange(-randomize[0], +randomize[0])\n                y = int(y) + random.randrange(-randomize[1], +randomize[1])\n\n        human_curve = HumanCurve(\n            from_point,\n            (x, y),\n            offsetBoundaryX=offsetBoundaryX,\n            offsetBoundaryY=offsetBoundaryY,\n            targetPoints=targetPoints,\n        )\n\n        duration = min(0.3, max(0.05, dist * 0.0004) * random.uniform(delay_factor[0], delay_factor[1]))\n        delta = duration / len(human_curve.points)\n\n        for point in human_curve.points:\n            _mouse.move(point[0], point[1], duration=delta)\n        time.sleep(0.05)\n\n    @staticmethod\n    def _is_clicking_safe():\n        return True\n\n    @staticmethod\n    def click(button):\n        if button != \"left\" or mouse._is_clicking_safe():\n            _mouse.click(button)\n\n    @staticmethod\n    def get_position():\n        return _mouse.get_position()\n"
  },
  {
    "path": "src/utils/image_operations.py",
    "content": "import logging\nfrom copy import deepcopy\nfrom enum import Enum\n\nimport cv2\nimport numpy as np\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass ThresholdTypes(Enum):\n    BINARY = 0\n    ADAPTIVE = 1\n    OTSU = 2\n\n\ndef threshold(\n    img: np.ndarray,\n    method: ThresholdTypes = ThresholdTypes.BINARY,\n    threshold: int = 127,\n    inverse: bool = False,\n    block_size: int = 1,\n    adaptive_thresh_c: int = 10,\n) -> np.ndarray:\n    \"\"\"Applies a thresholding method to an input image.\n\n    :param img: Input image to be thresholded. Must be a 3D array representing an RGB image.\n    :param method: Thresholding method to use. Options are 'BINARY', 'ADAPTIVE', and 'OTSU'. Default is 'BINARY'.\n    :param inverse: Whether to use inverse thresholding. Default is False.\n    :param threshold: Threshold value to use for 'BINARY' thresholding. Ignored for other methods. Default is 127.\n    :param block_size: Size of a pixel neighborhood that is used to calculate a threshold value for the 'ADAPTIVE' method.\n                       Ignored for other methods. Default is 1.\n    :param adaptive_thresh_c: Constant subtracted from the mean or weighted mean for the 'ADAPTIVE' method.\n                              Ignored for other methods. Default is 10.\n    :return: The thresholded image\n    \"\"\"\n    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img\n\n    thresh_type = cv2.THRESH_BINARY_INV if inverse else cv2.THRESH_BINARY\n\n    # binary threshold\n    if method == ThresholdTypes.BINARY:\n        _, thresholded_image = cv2.threshold(img_gray, threshold, 255, thresh_type)\n    # adaptive threshold (no inversion option here as it's inherently binary)\n    elif method == ThresholdTypes.ADAPTIVE:\n        thresholded_image = cv2.adaptiveThreshold(\n            img_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size, adaptive_thresh_c\n        )\n        if inverse:\n            thresholded_image = 255 - thresholded_image\n    # otsu threshold\n    elif method == ThresholdTypes.OTSU:\n        _, thresholded_image = cv2.threshold(img_gray, 0, 255, thresh_type + cv2.THRESH_OTSU)\n\n    return thresholded_image\n\n\ndef crop(img: np.ndarray, roi: tuple[int, int, int, int]) -> np.ndarray:\n    \"\"\"Cuts an image according to a region of interest.\n\n    :param img: Source image.\n    :param roi: Region of interest in the format (x, y, w, h).\n    :return: Cropped image.\n    \"\"\"\n    x, y, w, h = roi\n    height, width = img.shape[:2]\n\n    # Ensure the ROI is within the dimensions of the image\n    if x < 0 or y < 0 or x + w > width or y + h > height:\n        LOGGER.debug(f\"The region of interest {roi} is not within the dimensions of the image {img.shape[:2]}.\")\n        return img\n    return img[y : y + h, x : x + w]\n\n\ndef mask_by_roi(img: np.ndarray, roi: tuple[int, int, int, int], masking_type: str = \"regular\") -> np.ndarray | None:\n    \"\"\"Masks an image according to a region of interest.\n\n    :param img: Source image.\n    :param roi: Region of interest in the format (x, y, w, h).\n    :param masking_type: Type of masking, \"regular\" or \"inverse\".\n    :return: Masked image, or None if type is not recognized.\n    \"\"\"\n    x, y, w, h = roi\n    if masking_type == \"regular\":\n        masked = np.zeros(img.shape, dtype=np.uint8)\n        masked[y : y + h, x : x + w] = img[y : y + h, x : x + w]\n    elif masking_type == \"inverse\":\n        masked = img.copy()\n        cv2.rectangle(masked, (x, y), (x + w - 1, y + h - 1), (0, 0, 0), -1)\n    else:\n        LOGGER.error(f\"Unrecognized masking type '{masking_type}'.\")\n        return None\n    return masked\n\n\ndef alpha_to_mask(img: np.ndarray) -> np.ndarray | None:\n    \"\"\"Creates a mask from an image where alpha == 0.\n\n    :param img: Source image.\n    :return: Mask, or None if the image has no alpha channel or the minimum alpha value is not 0.\n    \"\"\"\n    if img.shape[2] == 4 and np.min(img[:, :, 3]) == 0:\n        _, mask = cv2.threshold(img[:, :, 3], 1, 255, cv2.THRESH_BINARY)\n        return mask\n    return None\n\n\ndef create_mask(size: tuple[int, int], roi: tuple[int, int, int, int]) -> np.ndarray:\n    \"\"\"Creates a mask with a specific size and region of interest.\n\n    :param size: Size of the mask.\n    :param roi: Region of interest in the format (x, y, w, h).\n    :return: Created mask.\n    \"\"\"\n    img = np.ones(size[:2], dtype=np.uint8) * 255\n    x, y, w, h = roi\n    img[y : y + h, x : x + w] = 0\n    return img\n\n\ndef color_filter(\n    img: np.ndarray, color_range: list[np.ndarray], calc_filtered_img: bool = True\n) -> tuple[np.ndarray, np.ndarray | None]:\n    color_ranges = []\n    # ex: [array([ -9, 201,  25]), array([ 9, 237,  61])]\n    if color_range[0][0] < 0:\n        lower_range = deepcopy(color_range)\n        lower_range[0][0] = 0\n        color_ranges.append(lower_range)\n        upper_range = deepcopy(color_range)\n        upper_range[0][0] = 180 + color_range[0][0]\n        upper_range[1][0] = 180\n        color_ranges.append(upper_range)\n    # ex: [array([ 170, 201,  25]), array([ 188, 237,  61])]\n    elif color_range[1][0] > 180:\n        upper_range = deepcopy(color_range)\n        upper_range[1][0] = 180\n        color_ranges.append(upper_range)\n        lower_range = deepcopy(color_range)\n        lower_range[0][0] = 0\n        lower_range[1][0] = color_range[1][0] - 180\n        color_ranges.append(lower_range)\n    else:\n        color_ranges.append(color_range)\n    color_masks = []\n    hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)\n    for color_range in color_ranges:\n        mask = cv2.inRange(hsv_img, color_range[0], color_range[1])\n        color_masks.append(mask)\n    color_mask = np.bitwise_or.reduce(color_masks) if len(color_masks) > 0 else color_masks[0]\n    if calc_filtered_img:\n        filtered_img = cv2.bitwise_and(img, img, mask=color_mask)\n        return color_mask, filtered_img\n    return color_mask, None\n\n\ndef overlay_image(image1: np.ndarray, image2: np.ndarray, x_offset: int, y_offset: int) -> np.ndarray:\n    \"\"\"Overlays two images at specified offsets, creating a combined image.\n\n    :param image1: The first image to be placed on the canvas.\n    :param image2: The second image to be overlaid on top of the first one.\n    :param x_offset: The horizontal offset of the second image with respect to the first one.\n    :param y_offset: The vertical offset of the second image with respect to the first one.\n    :return: Combined image with the second image overlaid on top of the first one at the specified offsets.\n    \"\"\"\n    # Determine the dimensions of the combined image\n    w_combined = max(image1.shape[1] - min(0, x_offset), image2.shape[1] + max(0, x_offset))\n    h_combined = max(image1.shape[0] - min(0, y_offset), image2.shape[0] + max(0, y_offset))\n\n    # Create a blank canvas for the combined image\n    combined_image = np.zeros((h_combined, w_combined, 3), dtype=np.uint8)\n\n    # Calculate the position to place the first image on the canvas\n    x1_offset = max(0, -x_offset)\n    y1_offset = max(0, -y_offset)\n\n    # Calculate the position to place the second image on the canvas\n    x2_offset = max(0, x_offset)\n    y2_offset = max(0, y_offset)\n\n    # Place the first image on the canvas\n    combined_image[y1_offset : y1_offset + image1.shape[0], x1_offset : x1_offset + image1.shape[1]] = image1\n\n    # Place the second image on the canvas (taking care of possible overlapping)\n    for i in range(image2.shape[0]):\n        for j in range(image2.shape[1]):\n            y = y2_offset + i\n            x = x2_offset + j\n            if y < combined_image.shape[0] and x < combined_image.shape[1]:\n                combined_image[y, x] = image2[i, j]\n\n    return combined_image\n\n\ndef get_typographic_lines(img: np.ndarray, should_invert: bool = False) -> tuple[int, int, int, int]:\n    \"\"\"Extracts typographic lines from an image containing a single line of text.\n\n    :param img: The input image. Expects an image with light text on a dark background.\n    :param should_invert: Whether to invert the image before processing. Set to True if you have dark text on a light background.\n    :return: A tuple containing the positions (in y-coordinates) of the topline, baseline, midline, and beardline, respectively.\n    \"\"\"\n    # Make img grayscale if it isn't already\n    if len(img.shape) == 3:\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    # invert the grayscale image if needed\n    if should_invert:\n        img = cv2.bitwise_not(img)\n    # Threshold the image\n    binary_img = threshold(img, method=ThresholdTypes.OTSU)\n    # Compute vertical histogram\n    histogram = cv2.reduce(binary_img, 1, cv2.REDUCE_SUM, dtype=cv2.CV_32S).flatten()\n    # Calculate deltas in histogram\n    deltas = np.diff(histogram)\n    # Find the first row with text as topline\n    topline = np.where(histogram > 0)[0][0]\n    # Find the last row with text as beardline (approached from the bottom)\n    beardline = np.where(histogram[::-1] > 0)[0][0]\n    beardline = len(histogram) - beardline - 1  # Adjust for reversed indexing\n    # Identify the two sharpest deltas for midline and baseline\n    sharpest_deltas_indices = np.argsort(np.abs(deltas))[-2:]\n    # Sort them to determine which is midline and which is baseline\n    midline, baseline = sorted(sharpest_deltas_indices)\n\n    return topline, baseline, midline, beardline\n\n\ndef compare_histograms(imageA, imageB):\n    histA = cv2.calcHist([imageA], [0], None, [256], [0, 256])\n    histB = cv2.calcHist([imageB], [0], None, [256], [0, 256])\n    histA = cv2.normalize(histA, histA).flatten()\n    histB = cv2.normalize(histB, histB).flatten()\n\n    # Compute correlation between histograms\n    return cv2.compareHist(histA, histB, cv2.HISTCMP_CORREL)\n"
  },
  {
    "path": "src/utils/misc.py",
    "content": "import ast\nimport math\nimport random\nimport re\nimport string\nimport time\nimport unicodedata\nfrom functools import wraps\nfrom typing import TYPE_CHECKING, TypeVar\n\nimport cv2\nimport numpy as np\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\nT = TypeVar(\"T\")\n\n\ndef is_in_roi(roi: list[float], pos: tuple[float, float]):\n    x, y, w, h = roi\n    is_in_x_range = x < pos[0] < x + w\n    is_in_y_range = y < pos[1] < y + h\n    return is_in_x_range and is_in_y_range\n\n\ndef hms(seconds: int):\n    seconds = int(seconds)\n    h = seconds // 3600\n    m = seconds % 3600 // 60\n    s = seconds % 3600 % 60\n    return f\"{h:02d}:{m:02d}:{s:02d}\"\n\n\ndef set_cv2_window(name, x, y, size):\n    cv2.namedWindow(name, cv2.WINDOW_NORMAL)\n    cv2.resizeWindow(name, size)\n    cv2.moveWindow(name, x, y)\n\n\ndef generate_random_name(length_min=8, length_max=14):\n    length = random.randint(length_min, length_max)\n    characters = string.ascii_letters\n    return \"\".join(random.choice(characters) for _ in range(length))\n\n\ndef random_number_gaussian(min_val, max_val):\n    mean_iterations = (min_val + max_val) / 2\n    range_span = (max_val - min_val) / 2\n    std_deviation = range_span / 4\n    num = random.normalvariate(mean_iterations, std_deviation)\n    return max(min_val, min(max_val, num))\n\n\ndef random_coordinate_around_center(x, y, radius_x, radius_y):\n    angle = random.uniform(0, 2 * math.pi)\n    dx = random_number_gaussian(0, radius_x)\n    dy = random_number_gaussian(0, radius_y)\n    offset_x = int(dx * math.cos(angle))\n    offset_y = int(dy * math.sin(angle))\n    random_x = x + offset_x\n    random_y = y + offset_y\n    return np.array([random_x, random_y])\n\n\ndef convert_args_to_numpy(func):\n    @wraps(func)\n    def wrapper(*args, **kwargs):\n        converted_args = []\n        for arg in args:\n            if isinstance(arg, list | tuple):\n                converted_args.append(np.array(arg))\n            else:\n                converted_args.append(arg)\n\n        converted_kwargs = {}\n        for key, value in kwargs.items():\n            if isinstance(value, list | tuple):\n                converted_kwargs[key] = np.array(value)\n            else:\n                converted_kwargs[key] = value\n\n        return func(*converted_args, **converted_kwargs)\n\n    return wrapper\n\n\ndef run_until_condition(\n    func: Callable[[], T], is_success: Callable[[T], bool], timeout: float = 3\n) -> tuple[T | None, bool]:\n    \"\"\"Runs the given function until the specified condition is met or the timeout is reached.\n\n    :param func: The function to be executed repeatedly.\n    :param is_success: A function that takes the result of `func` and returns True if the success condition is met.\n    :param timeout: The maximum time to run the function in seconds (default 3 seconds).\n    :return: A tuple containing the result of the last call to `func` and a boolean indicating if the success condition was met.\n    \"\"\"\n    start_time = time.time()\n    res = None\n    success = False\n\n    while (time.time() - start_time) < timeout:\n        res = func()\n        success = is_success(res)\n        if success:\n            break\n        time.sleep(0.05)\n\n    return res, success\n\n\ndef scale_vector_to_distance(vector, target_distance):\n    current_distance = np.linalg.norm(vector)\n    scaling_factor = 1.0 / current_distance\n    normalized_vector = vector * scaling_factor\n    return normalized_vector * target_distance\n\n\ndef slugify(value, allow_unicode=False, separator=\"_\"):\n    \"\"\"Convert to ASCII if 'allow_unicode' is False.\n\n    Convert spaces or repeated dashes to the desired separator. Remove characters that aren't alphanumerics,\n    underscores, or hyphens. Convert to lowercase. Also strip leading and\n    trailing whitespace, dashes, and underscores.\n    \"\"\"\n    value = str(value)\n    if allow_unicode:\n        value = unicodedata.normalize(\"NFKC\", value)\n    else:\n        value = unicodedata.normalize(\"NFKD\", value).encode(\"ascii\", \"ignore\").decode(\"ascii\")\n    value = re.sub(r\"[^\\w\\s-]\", \"\", value.lower())\n\n    # Use the separator instead of just \"-\"\n    value = re.sub(r\"[-\\s]+\", separator, value)\n\n    # Strip the separator, '-' and '_'\n    strip_chars = \"-_\" + separator\n    return value.strip(strip_chars)\n\n\ndef find_and_eval_math_in_string(s):\n    # e.g. 615+30 Item Power -> 645 Item Power\n    # Extract numbers with mathematical operators from the string\n    expression = re.findall(r\"(\\d+[\\+\\-\\*\\/]\\d+)\", s)\n    if expression:\n        result = ast.literal_eval(expression[0])\n        s = s.replace(expression[0], str(result))\n    return s\n\n\ndef remove_commas_from_numbers(s: str) -> str:\n    # Function to remove commas from matched numbers\n    def repl(match):\n        # Remove commas from matched string and return\n        return match.group(0).replace(\",\", \"\")\n\n    # Replace numbers with commas using the repl function\n    return re.sub(r\"\\d{1,3}(,\\d{3})*\", repl, s)\n"
  },
  {
    "path": "src/utils/process_handler.py",
    "content": "import ctypes\nimport logging\nimport os\nimport time\n\nimport psutil\n\nfrom src.utils.window import get_window_spec_id\n\nLOGGER = logging.getLogger(__name__)\n\n\ndef kill_thread(thread):\n    thread_id = thread.ident\n    res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, ctypes.py_object(SystemExit))\n    if res > 1:\n        ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)\n        LOGGER.error(\"Exception raise failure\")\n\n\ndef safe_exit(error_code=0):\n    \"\"\"Shutdown ALL D4LF instances.\"\"\"\n    # Find and terminate all D4LF processes\n    current_pid = os.getpid()\n    processes_to_kill = []\n\n    try:\n        for proc in psutil.process_iter([\"pid\", \"name\", \"cmdline\"]):\n            try:\n                if not proc.info[\"cmdline\"]:\n                    continue\n\n                cmdline_str = \" \".join(proc.info[\"cmdline\"])\n\n                # Look for python processes with d4lf or main.py\n                if (\n                    \"python\" in proc.info[\"name\"].lower()\n                    and (\"main.py\" in cmdline_str or \"d4lf\" in cmdline_str.lower())\n                    and proc.pid != current_pid\n                ):\n                    processes_to_kill.append(proc)\n            except (psutil.NoSuchProcess, psutil.AccessDenied) as e:\n                LOGGER.debug(f\"Error accessing process: {e}\")\n    except Exception as e:\n        LOGGER.debug(f\"Error iterating processes: {e}\")\n\n    # Kill all processes silently\n    for proc in processes_to_kill:\n        try:\n            proc.kill()\n            proc.wait(timeout=2)\n        except (psutil.NoSuchProcess, psutil.TimeoutExpired, Exception) as e:\n            LOGGER.debug(f\"Error killing process {proc.pid}: {e}\")\n\n    time.sleep(0.3)\n    os._exit(error_code)\n\n\ndef set_process_name(name, window_spec):\n    try:\n        hwnd = get_window_spec_id(window_spec)\n        kernel32 = ctypes.WinDLL(\"kernel32\")\n        kernel32.SetConsoleTitleW(hwnd, name)\n    except Exception:\n        LOGGER.exception(\"Failed to set process name\")\n"
  },
  {
    "path": "src/utils/roi_operations.py",
    "content": "import logging\nfrom enum import Enum\n\nfrom src.config.ui import ResManager\n\nLOGGER = logging.getLogger(__name__)\n\n\ndef compare_tuples(t1, t2, uncertainty):\n    return abs(t1[0] - t2[0]) <= uncertainty and abs(t1[1] - t2[1]) <= uncertainty\n\n\ndef create_roi_from_rel(point, rel_roi):\n    if isinstance(rel_roi, str):\n        rel_roi = getattr(ResManager().roi, rel_roi)\n    x, y = point\n    rel_x, rel_y, w, h = rel_roi\n    abs_x = x + rel_x\n    abs_y = y + rel_y\n    return abs_x, abs_y, w, h\n\n\ndef fit_roi_to_window_size(roi, size):\n    ww, wh = size\n    x, y, w, h = roi\n\n    success = True\n\n    # Check if the ROI is entirely out of bounds\n    if x >= ww or y >= wh:\n        return False, None\n\n    # Adjust the width and height to fit within the window\n    if x + w > ww:\n        w = ww - x\n    if y + h > wh:\n        h = wh - y\n\n    # Check if ROI is valid after adjustments\n    if w <= 0 or h <= 0:\n        return False, None\n\n    updated_roi = (x, y, w, h)\n    return success, updated_roi\n\n\ndef get_center(roi: tuple[int, int, int, int]) -> tuple[int, int]:\n    \"\"\"Finds the center of a region of interest.\n\n    :param roi: Region of interest in the format (x, y, w, h).\n    :return: Coordinates of the center.\n    \"\"\"\n    x, y, w, h = roi\n    return round(x + w / 2), round(y + h / 2)\n\n\ndef intersect(*rects: list[tuple[int, int, int, int]] | tuple[int, int, int, int]) -> tuple[int, int, int, int] | None:\n    \"\"\"Finds the intersection of multiple rectangles.\n\n    :param rects: The rectangles to intersect. Each rectangle is represented as a tuple of four integers (x_min, y_min, width, height).\n    :return: The intersection of all rectangles, represented as (x_min, y_min, width, height), or None if there is no intersection.\n    \"\"\"\n    if len(rects) == 1 and isinstance(rects[0], list):\n        rects = rects[0]\n\n    max_x_min = max(rect[0] for rect in rects)\n    max_y_min = max(rect[1] for rect in rects)\n    min_x_max = min(rect[0] + rect[2] for rect in rects)\n    min_y_max = min(rect[1] + rect[3] for rect in rects)\n\n    if max_x_min < min_x_max and max_y_min < min_y_max:\n        return max_x_min, max_y_min, min_x_max - max_x_min, min_y_max - max_y_min\n    # LOGGER.debug(f\"No intersection between {rects}.\")\n    return None\n\n\ndef bounding_box(\n    *args: list[tuple[int, int, int, int]] | tuple[int, int, int, int] | list[tuple[int, int]] | tuple[int, int],\n) -> tuple[int, int, int, int] | None:\n    \"\"\"Finds the bounding rectangle of a set of rectangles or coordinates.\n\n    :param args: The rectangles or coordinates to bound.\n        Each rectangle is represented as a tuple of four integers (x_min, y_min, width, height).\n        Each coordinate is represented as a tuple of two integers (x, y).\n    :return: The smallest rectangle that contains all the input rectangles or coordinates, represented as (x_min, y_min, width, height).\n    \"\"\"\n    if len(args) == 1 and isinstance(args[0], list):\n        args = args[0]\n\n    min_x, min_y, max_x, max_y = (float(\"inf\"), float(\"inf\"), float(\"-inf\"), float(\"-inf\"))\n\n    for arg in args:\n        if len(arg) == 2:  # if it's a coordinate\n            x, y = arg\n            min_x, max_x = min(min_x, x), max(max_x, x)\n            min_y, max_y = min(min_y, y), max(max_y, y)\n        elif len(arg) == 4:  # if it's a rectangle\n            x, y, w, h = arg\n            min_x, max_x = min(min_x, x), max(max_x, x + w)\n            min_y, max_y = min(min_y, y), max(max_y, y + h)\n        else:\n            LOGGER.error(\n                f\"Invalid argument: {arg}. Each argument should be either a coordinate (2 integers) or a rectangle (4 integers).\"\n            )\n            return None\n\n    return min_x, min_y, max_x - min_x, max_y - min_y\n\n\ndef to_grid(roi: tuple[int, int, int, int], rows: int, columns: int) -> set[tuple[int, int, int, int]]:\n    \"\"\"Splits a rectangle of interest (ROI) into a grid of smaller rectangles.\n\n    :param roi: The rectangle to split, represented as (x_min, y_min, width, height).\n    :param rows: The number of rows in the grid.\n    :param columns: The number of columns in the grid.\n    :return: A set of rectangles representing the grid. Each rectangle is represented as (x_min, y_min, width, height).\n    \"\"\"\n    x_min, y_min, width, height = roi\n    base_cell_width = width // columns\n    base_cell_height = height // rows\n\n    extra_width = width % columns\n    extra_height = height % rows\n\n    rectangles = []\n    for i in range(rows):\n        for j in range(columns):\n            cell_width = base_cell_width + (1 if j < extra_width else 0)\n            cell_height = base_cell_height + (1 if i < extra_height else 0)\n            cell_x_min = x_min + sum(base_cell_width + (1 if k < extra_width else 0) for k in range(j))\n            cell_y_min = y_min + sum(base_cell_height + (1 if k < extra_height else 0) for k in range(i))\n            rectangles.append((cell_x_min, cell_y_min, cell_width, cell_height))\n\n    rectangles.sort(key=lambda x: (x[1], x[0]))  # sort row major\n    return rectangles\n\n\nclass Condition(Enum):\n    WITHIN = \"within\"\n    ALIGN_Y = \"align_y\"\n    ALIGN_X = \"align_x\"\n\n\ndef is_in_roi(\n    coor: tuple[int, int], roi: tuple[int, int, int, int], condition: Condition | str = Condition.WITHIN\n) -> bool:\n    \"\"\"Checks the position of a given coordinate relative to a given rectangle of interest (ROI).\n\n    :param coor: The coordinate to check, represented as (x, y).\n    :param roi: The rectangle to check against, represented as (x_min, y_min, width, height).\n    :param condition: The condition to check for:\n                      - Condition.WITHIN: Check if coordinate is inside the ROI.\n                      - Condition.ALIGN_Y: Check if coordinate aligns with ROI in y-direction.\n                      - Condition.ALIGN_X: Check if coordinate aligns with ROI in x-direction.\n    :return: True if the coordinate meets the specified condition relative to the ROI, False otherwise.\n    \"\"\"\n    x, y = coor\n    x_min, y_min, width, height = roi\n    x_max = x_min + width\n    y_max = y_min + height\n\n    # Convert string condition to Enum value if necessary\n    if isinstance(condition, str):\n        condition = Condition(condition)\n\n    if condition == Condition.WITHIN:\n        return x_min <= x <= x_max and y_min <= y <= y_max\n    if condition == Condition.ALIGN_Y:\n        return x_min <= x <= x_max and not (y_min <= y <= y_max)\n    if condition == Condition.ALIGN_X:\n        return not (x_min <= x <= x_max) and y_min <= y <= y_max\n    msg = \"Invalid condition specified\"\n    raise ValueError(msg)\n"
  },
  {
    "path": "src/utils/window.py",
    "content": "import ctypes\nimport logging\nimport pathlib\nimport threading\nimport time\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING\n\nimport cv2\nimport psutil\nfrom win32gui import ClientToScreen, EnumWindows, GetClientRect, GetWindowText\nfrom win32process import GetWindowThreadProcessId\n\nfrom src.cam import Cam\nfrom src.logger import LOG_DIR\n\nif TYPE_CHECKING:\n    import numpy as np\n\nLOGGER = logging.getLogger(__name__)\n\nDETECTION_WINDOW_FLAG = True\nDETECT_WINDOW_THREAD = None\n\n\n@dataclass\nclass WindowSpec:\n    process_name: str\n\n    def match(self, hwnd: int, check_window_name: bool = True) -> bool:\n        window_name_ok = not check_window_name or \"diablo\" in _get_window_name_from_id(hwnd).lower()\n        return _get_process_from_window_name(hwnd).casefold() == self.process_name.casefold() and window_name_ok\n\n\ndef _list_active_window_ids() -> list[int]:\n    window_list = []\n    EnumWindows(lambda win, list_of_win: list_of_win.append(win), window_list)\n    return window_list\n\n\ndef get_window_spec_id(window_spec: WindowSpec) -> int | None:\n    for hwnd in _list_active_window_ids():\n        if window_spec.match(hwnd):\n            return hwnd\n    # If no process was found with \"diablo\" in the window name, search without that restriction\n    for hwnd in _list_active_window_ids():\n        if window_spec.match(hwnd, check_window_name=False):\n            return hwnd\n    return None\n\n\ndef _get_window_name_from_id(hwnd: int) -> str:\n    return GetWindowText(hwnd)\n\n\ndef _get_process_from_window_name(hwnd: int) -> str:\n    try:\n        pid = GetWindowThreadProcessId(hwnd)[1]\n        return psutil.Process(pid).name().lower()\n    except Exception:\n        return \"\"\n\n\ndef start_detecting_window(window_spec: WindowSpec):\n    global DETECTION_WINDOW_FLAG, DETECT_WINDOW_THREAD\n    if DETECT_WINDOW_THREAD is None:\n        LOGGER.info(f\"Using WinAPI to search for window: {window_spec.process_name}\")\n        DETECTION_WINDOW_FLAG = True\n        DETECT_WINDOW_THREAD = threading.Thread(target=detect_window, args=(window_spec,), daemon=True)\n        DETECT_WINDOW_THREAD.start()\n\n\ndef detect_window(window_spec: WindowSpec):\n    global DETECTION_WINDOW_FLAG\n    while DETECTION_WINDOW_FLAG:\n        find_and_set_window_position(window_spec)\n    LOGGER.debug(\"Detect window thread stopped\")\n\n\ndef find_and_set_window_position(window_spec: WindowSpec):\n    hwnd = get_window_spec_id(window_spec)\n    if hwnd is not None:\n        pos = GetClientRect(hwnd)\n        top_left = ClientToScreen(hwnd, (pos[0], pos[1]))\n        if pos[2] > 0 and pos[3] > 0:\n            Cam().update_window_pos(top_left[0], top_left[1], pos[2], pos[3])\n    time.sleep(1)\n\n\ndef stop_detecting_window():\n    global DETECTION_WINDOW_FLAG, DETECT_WINDOW_THREAD\n    DETECTION_WINDOW_FLAG = False\n    if DETECT_WINDOW_THREAD:\n        DETECT_WINDOW_THREAD.join()\n    DETECT_WINDOW_THREAD = None\n\n\ndef move_window_to_foreground(window_spec: WindowSpec):\n    hwnd = get_window_spec_id(window_spec)\n    if hwnd is not None:\n        ctypes.windll.user32.ShowWindow(hwnd, 5)\n        ctypes.windll.user32.SetForegroundWindow(hwnd)\n\n\ndef is_window_foreground(window_spec: WindowSpec) -> bool:\n    hwnd = get_window_spec_id(window_spec)\n    if hwnd is not None:\n        active_window_handle = ctypes.windll.user32.GetForegroundWindow()\n        return active_window_handle == hwnd\n    return False\n\n\ndef screenshot(\n    name: str | None = None,\n    path: str = str(LOG_DIR / \"screenshots\"),\n    img: np.ndarray = None,\n    overwrite: bool = True,\n    timestamp: bool = True,\n):\n    name = name if name is not None else \"screenshot\"\n    img = img if img is not None else Cam().grab()\n\n    pathlib.Path(path).mkdir(exist_ok=True, parents=True)\n    file_path = f\"{path}/{name}{'_' + datetime.now(tz=None).strftime('%Y%m%d_%H%M%S.%f') if timestamp else ''}.png\"  # noqa: DTZ005\n\n    if pathlib.Path(file_path).exists():\n        if overwrite:\n            LOGGER.warning(f\"{name} already exists, overwriting.\")\n            cv2.imwrite(file_path, img)\n        else:\n            LOGGER.warning(f\"{name} already exists, not overwriting because overwrite is set to False.\")\n    else:\n        cv2.imwrite(file_path, img)\n        LOGGER.debug(f\"Saved screenshot: {file_path}\")\n\n\nif __name__ == \"__main__\":\n    find_and_set_window_position(WindowSpec(\"Diablo IV.exe\"))\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/config/__init__.py",
    "content": ""
  },
  {
    "path": "tests/config/data/__init__.py",
    "content": ""
  },
  {
    "path": "tests/config/data/sigils.py",
    "content": "all_bad_cases = [\n    # 1 item\n    {\"Sigils\": {\"blacklist\": \"monster_cold_resist\"}},\n    {\"Sigils\": {\"blacklist\": [\"monster123_cold_resist\"]}},\n    {\"Sigils\": {\"blacklist\": [\"monster_cold_resist\", \"test123\"]}},\n    {\"Sigils\": {\"blacklist\": [\"monster_cold_resist\"], \"whitelist\": [\"monster_cold_resist\"]}},\n    {\"Sigils\": {\"whitelist\": [\"monster123_cold_resist\"]}},\n    {\"Sigils\": {\"whitelist\": [\"monster_cold_resist\", \"test123\"]}},\n]\n\nall_good_cases = [\n    # 1 item\n    {\"Sigils\": {\"blacklist\": [\"monster_cold_resist\"]}},\n    {\"Sigils\": {\"whitelist\": [\"monster_cold_resist\"]}},\n    # 2 items\n    {\"Sigils\": {\"blacklist\": [\"monster_cold_resist\"], \"whitelist\": [\"monster_fire_resist\"]}},\n]\n"
  },
  {
    "path": "tests/config/data/uniques.py",
    "content": "all_bad_cases = [\n    ({\"GlobalUniques\": [{\"minPower\": -20}]}, \"must be greater than zero\"),  # Has to be above 0\n    ({\"GlobalUniques\": [{\"minGreaterAffixCount\": 5}]}, \"must be in [0, 4]\"),  # Can't be greater than 4\n    ({\"GlobalUniques\": [{\"minPercentOfAspect\": 110}]}, \"must be less than or equal to 100\"),  # Can't be above 100\n]\n\nall_good_cases = {\n    \"name\": \"good\",\n    \"GlobalUniques\": [{\"minPower\": 300}, {\"minGreaterAffixCount\": 4}, {\"minPercentOfAspect\": 100}],\n}\n"
  },
  {
    "path": "tests/config/helper_test.py",
    "content": "import pytest\n\nfrom src.config.helper import singleton, str_to_int_list, validate_hotkey\n\n\nclass TestKeyMustExist:\n    def test_existing_key(self):\n        # Test for an existing key\n        assert validate_hotkey(\"a\")\n\n    def test_modifier_key_works(self):\n        assert validate_hotkey(\"shift+a\")\n\n    def test_non_existing_key(self):\n        # Test for a non-existing key\n        with pytest.raises(ValueError, match=\"Key 'non_existing_key' is not mapped to any known key.\"):\n            validate_hotkey(\"non_existing_key\")\n\n\nclass TestSingletonDecorator:\n    @singleton\n    class SingletonDummyClass:\n        def __init__(self, *args, **kwargs):\n            pass\n\n    def test_singleton_instance(self):\n        # Test whether multiple instances of singleton class return the same object\n        instance1 = self.SingletonDummyClass()\n        instance2 = self.SingletonDummyClass()\n        assert instance1 is instance2\n\n\nclass TestStrToIntList:\n    def test_empty_string(self):\n        # Test for an empty string\n        assert str_to_int_list(\"\") == []\n\n    def test_single_integer(self):\n        # Test for a single integer string\n        assert str_to_int_list(\"5\") == [5]\n\n    def test_multiple_integers(self):\n        # Test for a string containing multiple integers separated by commas\n        assert str_to_int_list(\"1,2,3,4,5\") == [1, 2, 3, 4, 5]\n\n    def test_invalid_input(self):\n        # Test for invalid input type\n        with pytest.raises(ValueError, match=\"invalid literal\"):\n            str_to_int_list(\"1,2,3,a,5\")\n\n    def test_negative_numbers(self):\n        # Test for negative numbers\n        assert str_to_int_list(\"-1,-2,-3,-4,-5\") == [-1, -2, -3, -4, -5]\n\n    def test_whitespace(self):\n        # Test for string containing whitespace\n        assert str_to_int_list(\" 1 ,  2 , 3 , 4 , 5 \") == [1, 2, 3, 4, 5]\n"
  },
  {
    "path": "tests/config/loader_test.py",
    "content": "from __future__ import annotations\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nimport pytest\n\nfrom src.config.loader import PARAMS_INI, IniConfigLoader\n\nif TYPE_CHECKING:\n    from pathlib import Path\n\n\n@pytest.fixture\ndef isolated_ini_loader(tmp_path: Path):\n    loader = IniConfigLoader()\n    original_user_dir = loader._user_dir\n    original_parser = loader._parser\n    original_general = loader._general\n    original_char = loader._char\n    original_advanced_options = loader._advanced_options\n    original_signature = loader._last_config_signature\n    original_revision = loader._config_revision\n    original_listeners = list(loader._change_listeners)\n    original_deferred_cleanup_logs = list(loader._deferred_cleanup_log_records)\n    original_defer_cleanup_logs = loader._defer_cleanup_log_records\n\n    loader._user_dir = tmp_path\n    loader._change_listeners = []\n    loader._deferred_cleanup_log_records = []\n    loader._defer_cleanup_log_records = True\n    loader.load(clear=True)\n\n    try:\n        yield loader\n    finally:\n        loader._user_dir = original_user_dir\n        loader._parser = original_parser\n        loader._general = original_general\n        loader._char = original_char\n        loader._advanced_options = original_advanced_options\n        loader._last_config_signature = original_signature\n        loader._config_revision = original_revision\n        loader._change_listeners = original_listeners\n        loader._deferred_cleanup_log_records = original_deferred_cleanup_logs\n        loader._defer_cleanup_log_records = original_defer_cleanup_logs\n\n\nclass TestIniConfigLoader:\n    def test_reload_if_changed_updates_models_and_revision(self, isolated_ini_loader: IniConfigLoader) -> None:\n        loader = isolated_ini_loader\n        revision_before_change = loader.config_revision\n        config_path = loader.user_dir / PARAMS_INI\n        config_path.write_text(\"[general]\\nrun_vision_mode_on_startup = false\\n\", encoding=\"utf-8\")\n\n        assert loader.reload_if_changed() is True\n        assert loader.general.run_vision_mode_on_startup is False\n        assert loader.config_revision > revision_before_change\n        assert loader.reload_if_changed() is False\n\n    def test_property_access_auto_reloads_changed_config(self, isolated_ini_loader: IniConfigLoader) -> None:\n        loader = isolated_ini_loader\n        config_path = loader.user_dir / PARAMS_INI\n        config_path.write_text(\"[general]\\nrun_vision_mode_on_startup = false\\n\", encoding=\"utf-8\")\n\n        assert loader.general.run_vision_mode_on_startup is False\n\n    def test_save_value_updates_model_without_reloading_from_file(self, isolated_ini_loader: IniConfigLoader) -> None:\n        loader = isolated_ini_loader\n\n        loader.save_value(\"general\", \"profiles\", \"alpha, beta\")\n\n        assert loader.general.profiles == [\"alpha\", \"beta\"]\n\n    def test_save_value_notifies_change_listeners(self, isolated_ini_loader: IniConfigLoader) -> None:\n        loader = isolated_ini_loader\n        notified_changes: list[frozenset[str]] = []\n\n        loader.register_change_listener(notified_changes.append)\n        loader.save_value(\"advanced_options\", \"log_lvl\", \"debug\")\n\n        assert notified_changes == [frozenset({\"advanced_options.log_lvl\"})]\n\n    def test_reload_if_changed_notifies_changed_keys(self, isolated_ini_loader: IniConfigLoader) -> None:\n        loader = isolated_ini_loader\n        notified_changes: list[frozenset[str]] = []\n        config_path = loader.user_dir / PARAMS_INI\n        loader.register_change_listener(notified_changes.append)\n\n        config_path.write_text(\"[general]\\nvision_mode_type = fast\\n\", encoding=\"utf-8\")\n        loader.reload_if_changed()\n\n        assert notified_changes == [frozenset({\"general.vision_mode_type\"})]\n\n    def test_reload_if_changed_removes_defunct_model_keys(\n        self, isolated_ini_loader: IniConfigLoader, caplog: pytest.LogCaptureFixture\n    ) -> None:\n        loader = isolated_ini_loader\n        config_path = loader.user_dir / PARAMS_INI\n        config_path.write_text(\n            \"[general]\\nrun_vision_mode_on_startup = false\\nremoved_setting = true\\n\\n\"\n            \"[paragon_overlay]\\ncell_size = 12\\n\",\n            encoding=\"utf-8\",\n        )\n\n        with caplog.at_level(logging.WARNING, logger=\"src.config.loader\"):\n            assert loader.reload_if_changed() is True\n\n        config_text = config_path.read_text(encoding=\"utf-8\")\n        assert loader.general.run_vision_mode_on_startup is False\n        assert \"removed_setting\" not in config_text\n        assert \"[paragon_overlay]\" in config_text\n        assert \"cell_size = 12\" in config_text\n        assert \"Deprecated key=removed_setting\" in caplog.text\n        cleanup_records = loader.consume_deferred_cleanup_log_records()\n        assert [record.getMessage() for record in cleanup_records] == [\n            \"Deprecated key=removed_setting found in [general]. Removing it from params.ini.\"\n        ]\n        assert loader.consume_deferred_cleanup_log_records() == []\n"
  },
  {
    "path": "tests/config/models_test.py",
    "content": "import re\nfrom typing import TYPE_CHECKING, Any\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom src.config.profile_models import ProfileModel\nfrom src.config.settings_models import GeneralModel\nfrom tests.config.data import sigils, uniques\n\nif TYPE_CHECKING:\n    from src.config.loader import IniConfigLoader\n\n\nclass TestSigil:\n    @pytest.fixture(autouse=True)\n    def _setup(self, mock_ini_loader: IniConfigLoader) -> None:\n        self.mock_ini_loader = mock_ini_loader\n\n    @staticmethod\n    @pytest.mark.parametrize(\"data\", sigils.all_bad_cases)\n    def test_all_bad_cases(data: dict[str, Any]) -> None:\n        data[\"name\"] = \"bad\"\n        with pytest.raises(ValidationError):\n            ProfileModel(**data)\n\n    @staticmethod\n    @pytest.mark.parametrize(\"data\", sigils.all_good_cases)\n    def test_all_good_cases(data: dict[str, Any]) -> None:\n        data[\"name\"] = \"good\"\n        assert ProfileModel(**data)\n\n\nclass TestUnique:\n    @pytest.fixture(autouse=True)\n    def _setup(self, mock_ini_loader: IniConfigLoader) -> None:\n        self.mock_ini_loader = mock_ini_loader\n\n    @staticmethod\n    @pytest.mark.parametrize((\"data\", \"expected_msg\"), uniques.all_bad_cases)\n    def test_all_bad_cases(data: dict[str, Any], expected_msg: str) -> None:\n        data[\"name\"] = \"bad\"\n        with pytest.raises(ValidationError, match=re.escape(expected_msg)):\n            ProfileModel(**data)\n\n    @staticmethod\n    def test_all_good_cases() -> None:\n        assert ProfileModel(**uniques.all_good_cases)\n\n\nclass TestGeneralProfiles:\n    @staticmethod\n    def test_profiles_empty_entries_are_removed() -> None:\n        assert GeneralModel(profiles=\"alpha, , beta,   ,\").profiles == [\"alpha\", \"beta\"]\n"
  },
  {
    "path": "tests/config/ui_test.py",
    "content": "import numpy as np\nimport pytest\nfrom natsort import natsorted\n\nfrom src.config.data import COLORS\nfrom src.config.ui import ResManager, _ResTransformer\n\n_PIXELS = [np.array([0, 0]), np.array([3840, 0]), np.array([0, 2160]), np.array([3840, 2160])]\n_TESTS = [\n    (\"1920x1080\", (np.array(coord) for coord in [(0, 0), (1920, 0), (0, 1080), (1920, 1080)])),\n    (\"1920x540\", (np.array(coord) for coord in [(150, 0), (1770, 0), (150, 540), (1770, 540)])),\n    (\"2560x1080\", (np.array(coord) for coord in [(0, 0), (2560, 0), (0, 1080), (2560, 1080)])),\n    (\"2560x1440\", (np.array(coord) for coord in [(0, 0), (2560, 0), (0, 1440), (2560, 1440)])),\n    (\"2560x1600\", (np.array(coord) for coord in [(0, 0), (2560, 0), (0, 1600), (2560, 1600)])),\n    (\"2560x720\", (np.array(coord) for coord in [(200, 0), (2360, 0), (200, 720), (2360, 720)])),\n    (\"3440x1440\", (np.array(coord) for coord in [(0, 0), (3440, 0), (0, 1440), (3440, 1440)])),\n    (\"3840x1080\", (np.array(coord) for coord in [(300, 0), (3540, 0), (300, 1080), (3540, 1080)])),\n    (\"3840x1600\", (np.array(coord) for coord in [(0, 0), (3840, 0), (0, 1600), (3840, 1600)])),\n    (\"3840x2160\", (np.array(coord) for coord in [(0, 0), (3840, 0), (0, 2160), (3840, 2160)])),\n    (\"5120x1440\", (np.array(coord) for coord in [(400, 0), (4720, 0), (400, 1440), (4720, 1440)])),\n]\n\n\n@pytest.mark.parametrize(\"res\", natsorted([x[0] for x in _TESTS]), ids=natsorted([x[0] for x in _TESTS]))\ndef test_set_resolution(res):\n    ResManager().set_resolution(res)\n    assert ResManager().pos\n\n\n@pytest.mark.parametrize(\"result\", _TESTS, ids=[x[0] for x in _TESTS])\ndef test_transformation(result):\n    for pixel in _PIXELS:\n        new_pixel = _ResTransformer(result[0])._transform_array(pixel)\n        expected = next(result[1])\n        assert new_pixel[0] == expected[0]\n        assert new_pixel[1] == expected[1]\n\n\ndef test_colors():\n    assert COLORS is not None\n\n\ndef test_templates():\n    assert len(ResManager().templates) == 61\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import typing\n\nimport pytest\n\nfrom src.config.loader import IniConfigLoader\nfrom src.config.settings_models import BrowserType\n\nif typing.TYPE_CHECKING:\n    from pytest_mock import MockerFixture\n\n\n@pytest.fixture\ndef mock_ini_loader(mocker: MockerFixture):\n    general_mock = mocker.patch.object(IniConfigLoader(), \"_general\")\n    general_mock.language = \"enUS\"\n    general_mock.browser = BrowserType.edge\n    general_mock.full_dump = False\n    return IniConfigLoader()\n"
  },
  {
    "path": "tests/gui/__init__.py",
    "content": ""
  },
  {
    "path": "tests/gui/importer/__init__.py",
    "content": ""
  },
  {
    "path": "tests/gui/importer/test_d4builds.py",
    "content": "import os\nimport typing\n\nimport lxml.html\nimport pytest\n\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.d4builds import _extract_build_metadata, _extract_d4builds_season_number, import_d4builds\nfrom src.gui.importer.importer_config import ImportConfig\n\nif typing.TYPE_CHECKING:\n    from pytest_mock import MockerFixture\nIN_GITHUB_ACTIONS = os.getenv(\"GITHUB_ACTIONS\") == \"true\"\n\nURLS = [\n    \"https://d4builds.gg/builds/01953e1c-6ba5-4f3a-8ebe-73273beda61b\",\n    \"https://d4builds.gg/builds/0704c20f-68a7-49ed-97da-fc51454a9906\",\n    \"https://d4builds.gg/builds/23ae9cbb-933e-4a88-999c-2241654cc8e2\",\n    \"https://d4builds.gg/builds/a3e80fe0-11a8-48b8-8255-f6540ebc1c1d\",\n    \"https://d4builds.gg/builds/b0330cfb-0f79-4d6d-a362-129492fad6a9\",\n    \"https://d4builds.gg/builds/ba06ccf8-4182-449a-bfb4-102f96b1041e\",\n    \"https://d4builds.gg/builds/dbad6569-2e78-4c43-a831-c563d0a1e1ad\",\n    \"https://d4builds.gg/builds/ef414fbd-81cd-49d1-9c8d-4938b278e2ee\",\n    \"https://d4builds.gg/builds/f8298a54-dc67-41ab-8232-ddfd32bd80fa\",\n]\n\n\ndef test_extract_build_metadata_from_planner_header() -> None:\n    data = lxml.html.fromstring(\"\"\"\n        <div class=\"builder__header\">\n            <div class=\"builder__header__title\">\n                <div class=\"builder__header__selection builder__header__selection--planner\">\n                    <h1 class=\"builder__header__name\">\n                        <span>Necromancer Build</span>\n                        <form class=\"builder__header__form\">\n                            <input class=\"builder__header__input\" value=\"Rob&#39;s Golem Minion Necro (S4) Pit 142+\">\n                        </form>\n                    </h1>\n                </div>\n            </div>\n            <div class=\"variant__navigation\">\n                <input class=\"builder__variant__input\" value=\"Standard Build\">\n            </div>\n        </div>\n        <div class=\"builder__gear\">\n            <div class=\"builder__dropdown__wrapper\">\n                <div class=\"dropdown\">\n                    <div class=\"dropdown__button\">Season 4</div>\n                </div>\n            </div>\n        </div>\n    \"\"\")\n\n    assert _extract_build_metadata(data) == (\n        \"Necromancer\",\n        \"Rob's Golem Minion Necro (S4) Pit 142+\",\n        \"4\",\n        \"Standard Build\",\n    )\n\n\ndef test_extract_build_metadata_prefers_description_for_guides() -> None:\n    data = lxml.html.fromstring(\"\"\"\n        <div class=\"builder\">\n          <div class=\"builder__header\">\n            <h1 class=\"builder__header__name\">Blessed Shield Paladin Build Guide - Diablo 4</h1>\n            <h2 class=\"builder__header__description\">Rob's Cpt. America (S12)</h2>\n            <div class=\"variant__navigation\">\n                <input class=\"builder__variant__input\" value=\"Pit Push (Glasscannon)\">\n            </div>\n          </div>\n          <div class=\"builder__gear\">\n            <div class=\"builder__dropdown__wrapper\">\n                <div class=\"dropdown\">\n                    <div class=\"dropdown__button\">Season 12</div>\n                </div>\n            </div>\n          </div>\n        </div>\n    \"\"\")\n\n    assert _extract_build_metadata(data) == (\"Paladin\", \"Rob's Cpt. America (S12)\", \"12\", \"Pit Push (Glasscannon)\")\n\n\ndef test_extract_d4builds_season_number_from_gear_dropdown() -> None:\n    data = lxml.html.fromstring(\"\"\"\n        <div class=\"builder\">\n            <div class=\"builder__gear\">\n                <div class=\"builder__dropdown__wrapper\">\n                    <div class=\"dropdown\">\n                        <div class=\"dropdown__button\">Season 12</div>\n                    </div>\n                </div>\n                <div class=\"builder__gear__items season_12\">\n                    <div>Gear</div>\n                </div>\n            </div>\n            <div>Active Runes</div>\n            <div>Season 10 appears later in the page and should be ignored.</div>\n        </div>\n    \"\"\")\n\n    assert _extract_d4builds_season_number(data) == \"12\"\n\n\n@pytest.mark.parametrize(\"url\", URLS)\n@pytest.mark.selenium\n@pytest.mark.skipif(not IN_GITHUB_ACTIONS, reason=\"Importer tests are skipped if not run from Github Actions\")\ndef test_import_d4builds(url: str, mock_ini_loader: MockerFixture, mocker: MockerFixture):\n    Dataloader()  # need to load data first or the mock will make it impossible\n    mocker.patch(\"builtins.open\", new=mocker.mock_open())\n    config = ImportConfig(\n        url=url,\n        import_aspect_upgrades=True,\n        add_to_profiles=False,\n        import_greater_affixes=True,\n        require_greater_affixes=True,\n        custom_file_name=None,\n    )\n    import_d4builds(config=config)\n"
  },
  {
    "path": "tests/gui/importer/test_diablo_trade.py",
    "content": "import os\nimport typing\n\nimport pytest\n\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.diablo_trade import import_diablo_trade\n\nif typing.TYPE_CHECKING:\n    from pytest_mock import MockerFixture\nIN_GITHUB_ACTIONS = os.getenv(\"GITHUB_ACTIONS\") == \"true\"\n\nURLS = [\"https://diablo.trade/listings/items?exactPrice=true&rarity=legendary&sold=true&sort=newest\"]\n\n\n@pytest.mark.parametrize(\"url\", URLS)\n@pytest.mark.selenium\n# @pytest.mark.skipif(not IN_GITHUB_ACTIONS, reason=\"Importer tests are skipped if not run from Github Actions\")\n@pytest.mark.skip(\"no longer works\")\ndef test_import_diablo_trade(url: str, mock_ini_loader: MockerFixture, mocker: MockerFixture):\n    Dataloader()  # need to load data first or the mock will make it impossible\n    mocker.patch(\"builtins.open\", new=mocker.mock_open())\n    import_diablo_trade(url=url, max_listings=30)\n"
  },
  {
    "path": "tests/gui/importer/test_gui_common.py",
    "content": "from src.config.profile_models import ProfileModel\nfrom src.gui.importer.gui_common import _to_yaml_str, build_default_profile_file_name\n\n\ndef test_build_default_profile_file_name_maxroll() -> None:\n    file_name = build_default_profile_file_name(\n        source_name=\"maxroll\", class_name=\"Spiritborn\", build_header=\"Touch of Death\", variant_name=\"Pit Push\"\n    )\n\n    assert file_name == \"maxroll_spiritborn_touch_of_death_pit_push\"\n\n\ndef test_build_default_profile_file_name_d4builds_strips_title_suffix() -> None:\n    file_name = build_default_profile_file_name(\n        source_name=\"d4builds\", class_name=\"Barbarian\", build_header=\"Bash Build - D4Builds\"\n    )\n\n    assert file_name == \"d4builds_barbarian_bash_build\"\n\n\ndef test_build_default_profile_file_name_d4builds_strips_spaced_title_suffix() -> None:\n    file_name = build_default_profile_file_name(\n        source_name=\"d4builds\", class_name=\"Barbarian\", build_header=\"Bash Build · D4 Builds\"\n    )\n\n    assert file_name == \"d4builds_barbarian_bash_build\"\n\n\ndef test_build_default_profile_file_name_keeps_unknown_class_and_empty_variant() -> None:\n    file_name = build_default_profile_file_name(\n        source_name=\"mobalytics\", class_name=\"Unknown\", build_header=\"Whirlwind Leveling Barb\", variant_name=\"\"\n    )\n\n    assert file_name == \"mobalytics_unknown_whirlwind_leveling_barb\"\n\n\ndef test_build_default_profile_file_name_adds_season_and_strips_matching_header_marker() -> None:\n    file_name = build_default_profile_file_name(\n        source_name=\"d4builds\",\n        class_name=\"Paladin\",\n        season_number=\"12\",\n        build_header=\"Rob's Cpt. America (S12)\",\n        variant_name=\"Pit Push (Glasscannon)\",\n    )\n\n    assert file_name == \"d4builds_paladin_s12_robs_cpt_america_pit_push_glasscannon\"\n\n\ndef test_build_default_profile_file_name_replaces_stale_season_marker_in_header() -> None:\n    file_name = build_default_profile_file_name(\n        source_name=\"maxroll\", class_name=\"Sorcerer\", season_number=\"12\", build_header=\"S11 Crackling Energy Sorc\"\n    )\n\n    assert file_name == \"maxroll_sorcerer_s12_crackling_energy_sorc\"\n\n\ndef test_to_yaml_str_sorts_aspect_upgrades_and_uses_block_style(mock_ini_loader) -> None:\n    profile = ProfileModel(name=\"test\", AspectUpgrades=[\"snowveiled\", \"accelerating\"])\n\n    yaml_str = _to_yaml_str(profile, exclude_defaults=True, exclude={\"name\", \"Sigils\"})\n\n    assert \"AspectUpgrades:\\n- accelerating\\n- snowveiled\\n\" in yaml_str\n    assert \"AspectUpgrades: [\" not in yaml_str\n"
  },
  {
    "path": "tests/gui/importer/test_maxroll.py",
    "content": "import os\nimport typing\n\nimport pytest\n\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.importer_config import ImportConfig\nfrom src.gui.importer.maxroll import _find_item_affixes, _find_item_type, _resolve_visible_profile_index, import_maxroll\nfrom src.item.data.item_type import ItemType\n\nif typing.TYPE_CHECKING:\n    from pytest_mock import MockerFixture\nIN_GITHUB_ACTIONS = os.getenv(\"GITHUB_ACTIONS\") == \"true\"\n\nURLS = [\n    \"https://maxroll.gg/d4/build-guides/auradin-guide\",\n    \"https://maxroll.gg/d4/build-guides/blessed-hammer-paladin-guide\",\n    \"https://maxroll.gg/d4/build-guides/double-swing-barbarian-guide\",\n    \"https://maxroll.gg/d4/build-guides/evade-spiritborn-build-guide\",\n    \"https://maxroll.gg/d4/build-guides/frozen-orb-sorcerer-guide\",\n    \"https://maxroll.gg/d4/build-guides/minion-necromancer-guide\",\n    \"https://maxroll.gg/d4/build-guides/quill-volley-spiritborn-guide\",\n    \"https://maxroll.gg/d4/build-guides/shield-of-retribution-paladin-guide\",\n    \"https://maxroll.gg/d4/build-guides/touch-of-death-spiritborn-guide\",\n]\n\n\n@pytest.mark.parametrize(\"url\", URLS)\n@pytest.mark.requests\n@pytest.mark.skipif(not IN_GITHUB_ACTIONS, reason=\"Importer tests are skipped if not run from Github Actions\")\ndef test_import_maxroll(url: str, mock_ini_loader: MockerFixture, mocker: MockerFixture):\n    Dataloader()  # need to load data first or the mock will make it impossible\n    mocker.patch(\"builtins.open\", new=mocker.mock_open())\n    config = ImportConfig(\n        url=url,\n        import_aspect_upgrades=True,\n        add_to_profiles=False,\n        import_greater_affixes=True,\n        require_greater_affixes=True,\n        custom_file_name=None,\n    )\n    import_maxroll(config=config)\n\n\ndef test_find_item_type_uses_fix_weapon_type_with_slot_context() -> None:\n    assert (\n        _find_item_type(mapping_data={\"item-1\": {\"type\": \"2H Sword\"}}, value=\"item-1\", class_name=\"Barbarian\")\n        == ItemType.Sword2H\n    )\n\n\ndef test_find_item_type_uses_fix_offhand_type_with_slot_and_class_context() -> None:\n    assert (\n        _find_item_type(mapping_data={\"item-1\": {\"type\": \"FocusBookOffHand\"}}, value=\"item-1\", class_name=\"Sorcerer\")\n        == ItemType.Focus\n    )\n\n\ndef test_find_item_type_uses_fix_offhand_type_when_item_type_implies_offhand() -> None:\n    assert (\n        _find_item_type(mapping_data={\"item-1\": {\"type\": \"1HFocus\"}}, value=\"item-1\", class_name=\"Sorcerer\")\n        == ItemType.Focus\n    )\n\n\ndef test_resolve_visible_profile_index_skips_hidden_profiles() -> None:\n    profiles = [\n        {\"name\": \"Any hidden variant name\", \"hidden\": True},\n        {\"name\": \"Visible variant A\"},\n        {\"name\": \"Visible variant B\"},\n        {\"name\": \"Visible variant C\"},\n    ]\n\n    assert _resolve_visible_profile_index(profiles=profiles, visible_profile_index=2) == 3\n\n\ndef test_find_item_affixes_resolves_skill_rank_category_from_affix_key() -> None:\n    mapping_data = {\n        \"affixes\": {\n            \"X2_SkillRankBonus_Sorc_Category_Shock\": {\n                \"id\": 1,\n                \"magicType\": 0,\n                \"attributes\": [{\"id\": 1155, \"param\": 332737186, \"formula\": \"GearAffix_SkillRankBonus_1to2\"}],\n            }\n        },\n        \"skills\": {},\n    }\n\n    affixes = _find_item_affixes(mapping_data=mapping_data, item_affixes=[{\"nid\": 1}], item_type=ItemType.Amulet)\n\n    assert [affix.name for affix in affixes] == [\"to_shock_skills\"]\n\n\ndef test_find_item_affixes_resolves_skill_rank_category_from_related_description() -> None:\n    mapping_data = {\n        \"affixes\": {\n            \"Unknown_SkillRankBonus\": {\n                \"id\": 1,\n                \"magicType\": 0,\n                \"attributes\": [{\"id\": 1155, \"param\": 1856650534, \"formula\": \"GearAffix_SkillRankBonus\"}],\n            },\n            \"Talisman_SealAffix_Set_Rogue_05_UltimateSkillRanks\": {\n                \"id\": 2,\n                \"magicType\": 1,\n                \"attributes\": [{\"id\": 1155, \"param\": 1856650534, \"formula\": \"GearAffix_SkillRankBonus\"}],\n                \"desc\": \"+{c_number}[Skill_Rank_Skill_Tag_Bonus(1856650534)||]{/c} {c_important}Ultimate{/c} Skills\",\n            },\n        },\n        \"skills\": {},\n    }\n\n    affixes = _find_item_affixes(mapping_data=mapping_data, item_affixes=[{\"nid\": 1}], item_type=ItemType.Amulet)\n\n    assert [affix.name for affix in affixes] == [\"to_ultimate_skills\"]\n"
  },
  {
    "path": "tests/gui/importer/test_mobalytics.py",
    "content": "import os\nimport typing\n\nimport pytest\n\nfrom src.dataloader import Dataloader\nfrom src.gui.importer.importer_config import ImportConfig\nfrom src.gui.importer.mobalytics import import_mobalytics\nfrom src.gui.importer.paragon_export import extract_mobalytics_paragon_steps\n\nif typing.TYPE_CHECKING:\n    from pytest_mock import MockerFixture\nIN_GITHUB_ACTIONS = os.getenv(\"GITHUB_ACTIONS\") == \"true\"\n\nURLS = [\n    # No frills and no uniques\n    \"https://mobalytics.gg/diablo-4/builds/barbarian-whirlwind-leveling-barb\",\n    # Is a variant of the one above\n    \"https://mobalytics.gg/diablo-4/builds/barbarian-whirlwind-leveling-barb?ws-ngf5-1=activeVariantId%2C7a9c6d51-18e9-4090-a804-7b73ff00879d\",\n    # A standard build with uniques\n    \"https://mobalytics.gg/diablo-4/builds/necromancer-skeletal-warrior-minions\",\n    # This one has no variants at all, just to make sure that works too\n    \"https://mobalytics.gg/diablo-4/profile/screamheart/builds/15x-thrash-out-of-date\",\n    # This one has an item type for the weapon\n    \"https://mobalytics.gg/diablo-4/builds/druid-zaior-pulverize-druid\",\n    # This has two rogue offhand weapons\n    \"https://mobalytics.gg/diablo-4/builds/rogue-efficientrogue-dance-of-knives?ws-ngf5-1=activeVariantId%2Ca2977139-f3e2-4b13-aa64-82ba69972528\",\n]\n\n\ndef test_extract_mobalytics_paragon_steps_normalizes_warlock_starting_board():\n    steps = extract_mobalytics_paragon_steps({\n        \"boards\": [{\"board\": {\"slug\": \"warlock-starter-board\"}, \"glyph\": {\"slug\": \"warlock-hellforge\"}, \"rotation\": 0}],\n        \"nodes\": [{\"slug\": \"warlock-starting-board-x11-y14\"}],\n    })\n\n    board = steps[0][0]\n    node_index = (14 - 1) * 21 + (11 - 1)\n\n    assert board[\"Name\"] == \"warlock-starting-board\"\n    assert board[\"Nodes\"].count(True) == 1\n    assert board[\"Nodes\"][node_index] is True\n\n\n@pytest.mark.parametrize(\"url\", URLS)\n@pytest.mark.requests\n@pytest.mark.skipif(not IN_GITHUB_ACTIONS, reason=\"Importer tests are skipped if not run from Github Actions\")\ndef test_import_mobalytics(url: str, mock_ini_loader: MockerFixture, mocker: MockerFixture):\n    Dataloader()  # need to load data first or the mock will make it impossible\n    mocker.patch(\"builtins.open\", new=mocker.mock_open())\n    config = ImportConfig(\n        url=url,\n        import_aspect_upgrades=True,\n        add_to_profiles=False,\n        import_greater_affixes=True,\n        require_greater_affixes=True,\n        custom_file_name=None,\n    )\n    import_mobalytics(config=config)\n"
  },
  {
    "path": "tests/item/__init__.py",
    "content": ""
  },
  {
    "path": "tests/item/descr/__init__.py",
    "content": ""
  },
  {
    "path": "tests/item/filter/__init__.py",
    "content": ""
  },
  {
    "path": "tests/item/filter/data/__init__.py",
    "content": ""
  },
  {
    "path": "tests/item/filter/data/affixes.py",
    "content": "from src.item.data.affix import Affix, AffixType\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.models import Item\n\n\nclass TestItem(Item):\n    def __init__(self, rarity: ItemRarity = ItemRarity.Legendary, power=910, **kwargs):\n        super().__init__(rarity=rarity, power=power, **kwargs)\n\n\naffixes = [\n    (\"wrong type\", [], TestItem(item_type=ItemType.Amulet)),\n    (\"power too low\", [], TestItem(item_type=ItemType.Helm, power=724)),\n    (\n        \"res boots 4 res\",\n        [],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"cold_resistance\", value=5),\n                Affix(name=\"fire_resistance\", value=5),\n                Affix(name=\"poison_resistance\", value=5),\n                Affix(name=\"shadow_resistance\", value=5),\n            ],\n        ),\n    ),\n    (\n        \"res boots 3 res\",\n        [],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"cold_resistance\", value=5),\n                Affix(name=\"fire_resistance\", value=5),\n                Affix(name=\"shadow_resistance\", value=5),\n            ],\n        ),\n    ),\n    (\n        \"res boots 3 res+ms\",\n        [\"test.ResBoots\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"cold_resistance\", value=5, min_value=5, max_value=4),\n                Affix(name=\"movement_speed\", value=5),\n                Affix(name=\"fire_resistance\", value=5, min_value=5, max_value=4),\n                Affix(name=\"shadow_resistance\", value=5, min_value=5, max_value=4),\n            ],\n        ),\n    ),\n    (\n        \"res boots 2 res\",\n        [],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[Affix(name=\"cold_resistance\", value=5), Affix(name=\"shadow_resistance\", value=5)],\n        ),\n    ),\n    (\n        \"res boots 2 res+ms\",\n        [\"test.ResBoots\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"cold_resistance\", value=5, min_value=5, max_value=4),\n                Affix(name=\"movement_speed\", value=5),\n                Affix(name=\"shadow_resistance\", value=5, min_value=5, max_value=4),\n            ],\n        ),\n    ),\n    (\n        \"helm life\",\n        [],\n        TestItem(\n            item_type=ItemType.Helm,\n            affixes=[\n                Affix(name=\"maximum_life\", value=5),\n                Affix(name=\"movement_speed\", value=5),\n                Affix(name=\"fire_resistance\", value=5),\n                Affix(name=\"shadow_resistance\", value=5),\n            ],\n        ),\n    ),\n    (\n        \"boots inherent\",\n        [\"test.GreatBoots\", \"test.ResBoots\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=5),\n                Affix(name=\"cold_resistance\", value=5, min_value=5, max_value=4),\n                Affix(name=\"lightning_resistance\", value=5, min_value=5, max_value=4),\n            ],\n            inherent=[Affix(name=\"maximum_evade_charges\", value=5)],\n        ),\n    ),\n    (\n        \"boots no inherent\",\n        [\"test.ResBoots\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=5),\n                Affix(name=\"cold_resistance\", value=5, min_value=5, max_value=4),\n                Affix(name=\"lightning_resistance\", value=5, min_value=5, max_value=4),\n            ],\n            inherent=[Affix(name=\"maximum_fury\", value=5)],\n        ),\n    ),\n    (\n        \"boots exact values\",\n        [\"test.ResBoots\", \"test.ResBootsExact\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=4),\n                Affix(name=\"cold_resistance\", value=4, min_value=5, max_value=4),\n                Affix(name=\"lightning_resistance\", value=4, min_value=5, max_value=4),\n            ],\n            inherent=[Affix(name=\"maximum_fury\", value=5)],\n        ),\n    ),\n    (\n        \"percent affix pass\",\n        [\"test.PercentBoots\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=9.0, min_value=5.0, max_value=10.0),\n                Affix(name=\"dodge_chance\", value=3.0),\n            ],\n        ),\n    ),\n    (\n        \"percent affix fail\",\n        [],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=8.0, min_value=5.0, max_value=10.0),\n                Affix(name=\"dodge_chance\", value=3.0),\n            ],\n        ),\n    ),\n    (\n        \"greater affix\",\n        [\"test.CountBootsMatch\", \"test.GreaterAffixes\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=4, type=AffixType.greater),\n                Affix(name=\"intelligence\", value=4),\n                Affix(name=\"maximum_life\", value=4),\n                Affix(name=\"shadow_resistance\", value=4),\n            ],\n            inherent=[Affix(name=\"maximum_fury\", value=5)],\n        ),\n    ),\n    (\n        \"greater affix 2\",\n        [\"test.CountBoots\", \"test.CountBootsMatch\", \"test.GreaterAffixes\"],\n        TestItem(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=4, type=AffixType.greater),\n                Affix(name=\"intelligence\", value=4, type=AffixType.greater),\n                Affix(name=\"maximum_life\", value=4),\n                Affix(name=\"shadow_resistance\", value=4),\n            ],\n            inherent=[Affix(name=\"maximum_fury\", value=5)],\n        ),\n    ),\n]\n"
  },
  {
    "path": "tests/item/filter/data/aspects.py",
    "content": "from src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.models import Item\n\n\nclass TestItem(Item):\n    def __init__(self, rarity=ItemRarity.Legendary, power=910, is_codex_upgrade=True, **kwargs):\n        super().__init__(rarity=rarity, power=power, codex_upgrade=is_codex_upgrade, item_type=ItemType.Helm, **kwargs)\n\n\naspects = [\n    (\"codex upgrade no profile\", [\"AspectUpgrades\"], TestItem(aspect=Aspect(name=\"no_profile_aspect\"))),\n    (\"no codex no profile\", [], TestItem(aspect=Aspect(name=\"no_profile_aspect\"), is_codex_upgrade=False)),\n    (\"codex upgrade match profile\", [\"aspect_profile.AspectUpgrades\"], TestItem(aspect=Aspect(name=\"accelerating\"))),\n]\n"
  },
  {
    "path": "tests/item/filter/data/filters.py",
    "content": "from src.config.profile_models import (\n    AffixFilterCountModel,\n    AffixFilterModel,\n    GlobalUniqueModel,\n    ItemFilterModel,\n    ProfileModel,\n    SigilConditionModel,\n    SigilFilterModel,\n    TributeFilterModel,\n)\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\n\n# noinspection PyTypeChecker\naffix = ProfileModel(\n    name=\"test\",\n    Affixes=[\n        {\n            \"Helm\": ItemFilterModel(\n                itemType=[ItemType.Helm],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"intelligence\", value=5),\n                            AffixFilterModel(name=\"cooldown_reduction\", value=5),\n                            AffixFilterModel(name=\"maximum_life\", value=640),\n                            AffixFilterModel(name=\"total_armor\", value=9),\n                        ]\n                    )\n                ],\n            )\n        },\n        {\n            \"ResBoots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(count=[AffixFilterModel(name=\"movement_speed\")]),\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"shadow_resistance\"),\n                            AffixFilterModel(name=\"cold_resistance\"),\n                            AffixFilterModel(name=\"lightning_resistance\"),\n                            AffixFilterModel(name=\"poison_resistance\"),\n                            AffixFilterModel(name=\"fire_resistance\"),\n                        ],\n                        minCount=2,\n                    ),\n                ],\n            )\n        },\n        {\n            \"ResBootsExact\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(count=[AffixFilterModel(name=\"movement_speed\")]),\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"shadow_resistance\", value=4),\n                            AffixFilterModel(name=\"cold_resistance\", value=4),\n                            AffixFilterModel(name=\"lightning_resistance\", value=4),\n                            AffixFilterModel(name=\"poison_resistance\", value=4),\n                            AffixFilterModel(name=\"fire_resistance\", value=4),\n                        ],\n                        minCount=2,\n                    ),\n                ],\n            )\n        },\n        {\n            \"GreatBoots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"movement_speed\"),\n                            AffixFilterModel(name=\"cold_resistance\"),\n                            AffixFilterModel(name=\"lightning_resistance\"),\n                        ]\n                    )\n                ],\n                inherentPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"maximum_evade_charges\"),\n                            AffixFilterModel(name=\"attacks_reduce_evades_cooldown_by_seconds\"),\n                        ],\n                        minCount=1,\n                    )\n                ],\n            )\n        },\n        {\n            \"Armor\": ItemFilterModel(\n                itemType=[ItemType.ChestArmor, ItemType.Legs],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"maximum_life\", value=700),\n                            AffixFilterModel(name=\"dexterity\", value=5),\n                            AffixFilterModel(name=\"intelligence\", value=5),\n                            AffixFilterModel(name=\"dodge_chance\", value=5),\n                        ]\n                    )\n                ],\n            )\n        },\n        {\n            \"Boots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"movement_speed\", value=10),\n                            AffixFilterModel(name=\"maximum_life\", value=700),\n                            AffixFilterModel(name=\"cold_resistance\", value=6.5),\n                            AffixFilterModel(name=\"fire_resistance\", value=5),\n                            AffixFilterModel(name=\"poison_resistance\", value=5),\n                            AffixFilterModel(name=\"shadow_resistance\", value=5),\n                        ],\n                        minCount=4,\n                    )\n                ],\n            )\n        },\n        {\n            \"PercentBoots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"movement_speed\", minPercentOfAffix=80),\n                            AffixFilterModel(name=\"dodge_chance\"),\n                        ]\n                    )\n                ],\n            )\n        },\n        {\"GreaterAffixes\": ItemFilterModel(minGreaterAffixCount=1)},\n        {\n            \"CountBoots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"intelligence\", want_greater=True),\n                            AffixFilterModel(name=\"movement_speed\", want_greater=True),\n                            AffixFilterModel(name=\"lightning_resistance\"),\n                            AffixFilterModel(name=\"maximum_life\"),\n                            AffixFilterModel(name=\"poison_resistance\"),\n                            AffixFilterModel(name=\"shadow_resistance\"),\n                        ],\n                        minCount=3,\n                    )\n                ],\n                minGreaterAffixCount=2,\n            )\n        },\n        {\n            \"CountBootsMatch\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"intelligence\", want_greater=True),\n                            AffixFilterModel(name=\"movement_speed\", want_greater=True),\n                            AffixFilterModel(name=\"lightning_resistance\"),\n                            AffixFilterModel(name=\"maximum_life\"),\n                        ],\n                        minCount=3,\n                    )\n                ],\n                minGreaterAffixCount=1,  # Should match - only needs 1 greater, has 2\n            )\n        },\n        {\n            \"CountBootsNoMatch\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"intelligence\", want_greater=True),\n                            AffixFilterModel(name=\"movement_speed\", want_greater=True),\n                            AffixFilterModel(name=\"lightning_resistance\"),\n                            AffixFilterModel(name=\"maximum_life\"),\n                        ],\n                        minCount=3,\n                    )\n                ],\n                minGreaterAffixCount=3,  # Should NOT match - needs 3 greater, only has 2\n            )\n        },\n    ],\n)\n\nalways_keep_mythics = ProfileModel(name=\"keep_mythics\", GlobalUniques=[GlobalUniqueModel(minPower=900)])\n\naspects_filters = ProfileModel(name=\"aspect_profile\", AspectUpgrades=[\"accelerating\", \"aggressive\"])\n\nglobal_unique = ProfileModel(\n    name=\"test\",\n    GlobalUniques=[\n        GlobalUniqueModel(minPower=900),\n        GlobalUniqueModel(minGreaterAffixCount=2),\n        GlobalUniqueModel(minPercentOfAspect=80, profileAlias=\"good_stuff\"),\n    ],\n)\n\nsigil = ProfileModel(\n    name=\"test\",\n    Sigils=SigilFilterModel(\n        blacklist=[SigilConditionModel(name=\"reduce_cooldowns_on_kill\"), SigilConditionModel(name=\"underroot\")],\n        whitelist=[\n            SigilConditionModel(name=\"jalals_vigil\"),\n            SigilConditionModel(name=\"iron_hold\", condition=[\"shadow_damage\"]),\n        ],\n    ),\n)\n\nsigil_blacklist_only = ProfileModel(\n    name=\"blacklist_only\", Sigils=SigilFilterModel(blacklist=[SigilConditionModel(name=\"iron_hold\")])\n)\n\nsigil_whitelist_only = ProfileModel(\n    name=\"whitelist_only\", Sigils=SigilFilterModel(whitelist=[SigilConditionModel(name=\"iron_hold\")])\n)\n\nsigil_priority = ProfileModel(\n    name=\"priority\",\n    Sigils=SigilFilterModel(\n        blacklist=[SigilConditionModel(name=\"reduce_cooldowns_on_kill\")],\n        whitelist=[SigilConditionModel(name=\"iron_hold\", condition=[\"shadow_damage\"])],\n    ),\n)\n\n# noinspection PyTypeChecker\nunique_affixes = ProfileModel(\n    name=\"test\",\n    Affixes=[\n        {\n            \"Helm\": ItemFilterModel(\n                itemType=[ItemType.Helm],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"intelligence\", value=5),\n                            AffixFilterModel(name=\"cooldown_reduction\", value=5),\n                            AffixFilterModel(name=\"maximum_life\", value=640),\n                            AffixFilterModel(name=\"total_armor\", value=9),\n                        ],\n                        minCount=1,\n                    )\n                ],\n                # Due to quirks of pydantic this has to be passed in as a map and not the object\n                uniqueAspect={\"name\": \"crown_of_lucion\", \"value\": 12},\n            )\n        },\n        {\n            \"PercentBoots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                minPower=725,\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"movement_speed\", minPercentOfAffix=80),\n                            AffixFilterModel(name=\"dodge_chance\"),\n                        ]\n                    )\n                ],\n                uniqueAspect={\"name\": \"penitent_greaves\", \"minPercentOfAspect\": 50},\n            )\n        },\n        {\n            \"CountBoots\": ItemFilterModel(\n                itemType=[ItemType.Boots],\n                affixPool=[\n                    AffixFilterCountModel(\n                        count=[\n                            AffixFilterModel(name=\"intelligence\", want_greater=True),\n                            AffixFilterModel(name=\"movement_speed\", want_greater=True),\n                            AffixFilterModel(name=\"lightning_resistance\"),\n                            AffixFilterModel(name=\"maximum_life\"),\n                            AffixFilterModel(name=\"poison_resistance\"),\n                            AffixFilterModel(name=\"shadow_resistance\"),\n                        ],\n                        minCount=3,\n                    )\n                ],\n                uniqueAspect={\"name\": \"flickerstep\"},\n                minGreaterAffixCount=2,\n            )\n        },\n        {\"UniqueAspectOnly\": ItemFilterModel(uniqueAspect={\"name\": \"battle_trance\"})},\n        {\n            \"SmallerUniqueAspectValue\": ItemFilterModel(\n                itemType=[ItemType.Shield], uniqueAspect={\"name\": \"crown_of_lucion\", \"value\": 12}\n            )\n        },\n        {\"UniqueAspectWithGA\": ItemFilterModel(uniqueAspect={\"name\": \"flickerstep\"}, minGreaterAffixCount=2)},\n    ],\n)\n\ntributes = ProfileModel(\n    name=\"tributes\",\n    Tributes=[\n        TributeFilterModel(name=\"tribute_of_andariel\"),\n        TributeFilterModel(name=\"harmony\"),\n        TributeFilterModel(rarities=[ItemRarity.Legendary, ItemRarity.Unique]),\n    ],\n)\n"
  },
  {
    "path": "tests/item/filter/data/items.py",
    "content": "from src.item.data.affix import Affix\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.models import Item\n\nitems = [\n    (\n        Item(\n            affixes=[\n                Affix(name=\"potion_capacity\", value=3),\n                Affix(name=\"thorns\", value=873),\n                Affix(name=\"damage_reduction_from_close_enemies\", value=11),\n                Affix(name=\"imbuement_skill_cooldown_reduction\", value=5.8),\n            ],\n            inherent=[Affix(name=\"injured_potion_resource\", value=20)],\n            item_type=ItemType.Legs,\n            power=844,\n            rarity=ItemRarity.Rare,\n        ),\n        False,\n    )\n]\n"
  },
  {
    "path": "tests/item/filter/data/sigils.py",
    "content": "from src.item.data.affix import Affix\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.models import Item\n\n\nclass TestSigil(Item):\n    def __init__(self, rarity=ItemRarity.Common, item_type=ItemType.Sigil, **kwargs):\n        super().__init__(rarity=rarity, item_type=item_type, **kwargs)\n\n\nsigils = [\n    (\n        \"affix in blacklist\",\n        [],\n        TestSigil(affixes=[Affix(name=\"death_pulse\"), Affix(name=\"reduce_cooldowns_on_kill\", value=0.25)]),\n    ),\n    (\"inherent in blacklist\", [], TestSigil(inherent=[Affix(name=\"underroot\")])),\n    (\"affix not in whitelist\", [], TestSigil(inherent=[Affix(name=\"lubans_rest\")])),\n    (\"condition not met\", [], TestSigil(inherent=[Affix(name=\"iron_hold\")])),\n    (\n        \"ok_1\",\n        [\"test\"],\n        TestSigil(\n            affixes=[\n                Affix(name=\"extra_shrines\"),\n                Affix(name=\"empowered_elites_shock_lance\"),\n                Affix(name=\"monster_burning_damage\", value=30.0),\n                Affix(name=\"monster_regen\", value=1.5),\n                Affix(name=\"slowing_projectiles\", value=50.0),\n            ],\n            inherent=[Affix(name=\"jalals_vigil\")],\n        ),\n    ),\n    (\n        \"ok_2\",\n        [\"test\"],\n        TestSigil(\n            affixes=[\n                Affix(name=\"increased_healing\", value=15.0),\n                Affix(name=\"volcanic\"),\n                Affix(name=\"monster_cold_damage\", value=20.0),\n                Affix(name=\"monster_poison_resist\", value=60),\n                Affix(name=\"armor_breakers\", value=7.0),\n            ],\n            inherent=[Affix(name=\"jalals_vigil\")],\n        ),\n    ),\n    (\n        \"ok_3\",\n        [\"test\"],\n        TestSigil(\n            affixes=[\n                Affix(name=\"quick_killer\", value=2.0),\n                Affix(name=\"nightmare_portal\"),\n                Affix(name=\"monster_attack_speed\", value=25.0),\n                Affix(name=\"monster_burning_resist\", value=60.0),\n                Affix(name=\"backstabbers\"),\n            ],\n            inherent=[Affix(name=\"jalals_vigil\")],\n        ),\n    ),\n    (\n        \"ok_4\",\n        [\"test\"],\n        TestSigil(\n            affixes=[\n                Affix(name=\"shadow_damage\", value=2.0),\n                Affix(name=\"nightmare_portal\"),\n                Affix(name=\"monster_attack_speed\", value=25.0),\n                Affix(name=\"monster_burning_resist\", value=60.0),\n                Affix(name=\"backstabbers\"),\n            ],\n            inherent=[Affix(name=\"iron_hold\")],\n        ),\n    ),\n]\n\nsigil_jalal = TestSigil(inherent=[Affix(name=\"jalals_vigil\")])\n\nsigil_priority = TestSigil(\n    affixes=[Affix(name=\"shadow_damage\", value=2.0), Affix(name=\"reduce_cooldowns_on_kill\")],\n    inherent=[Affix(name=\"iron_hold\")],\n)\n"
  },
  {
    "path": "tests/item/filter/data/tributes.py",
    "content": "from src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.models import Item\n\n\nclass TestTribute(Item):\n    def __init__(self, rarity=ItemRarity.Common, item_type=ItemType.Tribute, **kwargs):\n        super().__init__(rarity=rarity, item_type=item_type, **kwargs)\n\n\ntributes = [\n    (\"ok_1\", [\"tributes\"], TestTribute(name=\"tribute_of_andariel\", rarity=ItemRarity.Magic)),\n    (\"ok_2\", [\"tributes\"], TestTribute(name=\"tribute_of_harmony\", rarity=ItemRarity.Magic)),\n    (\"rarity_matches\", [\"tributes\"], TestTribute(name=\"tribute_of_ascendance_resolute\", rarity=ItemRarity.Unique)),\n    (\"not_in_list\", [], TestTribute(name=\"tribute_of_fake\")),\n]\n"
  },
  {
    "path": "tests/item/filter/data/uniques.py",
    "content": "from src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.models import Item\n\n\nclass TestUnique(Item):\n    def __init__(\n        self, rarity: ItemRarity = ItemRarity.Unique, item_type: ItemType = ItemType.Shield, power=910, **kwargs\n    ):\n        super().__init__(rarity=rarity, item_type=item_type, power=power, **kwargs)\n\n\naspect_only_mythic_tests = [\n    (\"matches filter\", True, [\"aspect_only.tibaults_will\"], TestUnique(aspect=Aspect(name=\"tibaults_will\"), power=925)),\n    (\"does not match filter\", False, [], TestUnique(aspect=Aspect(name=\"tibaults_will\"), power=800)),\n    (\"matches with alias\", True, [\"alias_test.black_river\"], TestUnique(aspect=Aspect(name=\"black_river\"), power=925)),\n    (\"no aspect applies\", True, [], TestUnique(aspect=Aspect(\"crown_of_lucion\"))),\n]\n\nsimple_mythics = [\n    (\"matches filter\", True, TestUnique(aspect=Aspect(name=\"black_river\"), power=925, rarity=ItemRarity.Mythic)),\n    (\n        \"does not match but should keep\",\n        True,\n        TestUnique(aspect=Aspect(name=\"black_river\"), power=800, rarity=ItemRarity.Mythic),\n    ),\n]\n\nglobal_uniques = [\n    (\n        \"item power too low\",\n        [],\n        TestUnique(power=800, aspect=Aspect(name=\"penitent_greaves\", value=10, min_value=9, max_value=100)),\n    ),\n    (\n        \"has greater affixes\",\n        [\"test.lidless_wall\"],\n        TestUnique(\n            aspect=Aspect(name=\"lidless_wall\", value=22, min_value=20, max_value=300),\n            affixes=[\n                Affix(name=\"attack_speed\", value=9.6, type=AffixType.greater),\n                Affix(name=\"lucky_hit_up_to_a_chance_to_restore_primary_resource\", value=13.5, type=AffixType.greater),\n                Affix(name=\"maximum_life\", value=1111),\n                Affix(name=\"maximum_essence\", value=13),\n            ],\n            power=800,\n        ),\n    ),\n    (\n        \"percent of affix is good\",\n        [\"good_stuff.black_river\"],\n        TestUnique(aspect=Aspect(name=\"black_river\", value=128, min_value=1, max_value=130), power=800),\n    ),\n]\n\nuniques_with_affixes = [\n    (\"matches nothing\", [], TestUnique(item_type=ItemType.Amulet, aspect=Aspect(name=\"dolmen_stone\"))),\n    (\n        \"rare does not match unique aspect filter\",\n        [],\n        TestUnique(rarity=ItemRarity.Rare, item_type=ItemType.Helm, affixes=[Affix(name=\"maximum_life\", value=641)]),\n    ),\n    (\n        \"matches aspect value\",\n        [\"test.Helm\"],\n        TestUnique(\n            item_type=ItemType.Helm,\n            aspect=Aspect(name=\"crown_of_lucion\", value=13),\n            affixes=[Affix(name=\"maximum_life\", value=641)],\n        ),\n    ),\n    (\n        \"does not match aspect value\",\n        [],\n        TestUnique(\n            item_type=ItemType.Helm,\n            aspect=Aspect(name=\"crown_of_lucion\", value=10),\n            affixes=[Affix(name=\"maximum_life\", value=5)],\n        ),\n    ),\n    (\n        \"percent affix/aspect pass\",\n        [\"test.PercentBoots\"],\n        TestUnique(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=9.0, min_value=5.0, max_value=10.0),\n                Affix(name=\"dodge_chance\", value=3.0),\n            ],\n            aspect=Aspect(name=\"penitent_greaves\", value=10, min_value=1, max_value=11),\n        ),\n    ),\n    (\n        \"percent affix pass but aspect fail\",\n        [],\n        TestUnique(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=9.0, min_value=5.0, max_value=10.0),\n                Affix(name=\"dodge_chance\", value=3.0),\n            ],\n            aspect=Aspect(name=\"penitent_greaves\", value=2, min_value=1, max_value=11),\n        ),\n    ),\n    (\n        \"greater affix\",\n        [\"test.CountBoots\", \"test.UniqueAspectWithGA\"],\n        TestUnique(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=4, type=AffixType.greater),\n                Affix(name=\"intelligence\", value=4, type=AffixType.greater),\n                Affix(name=\"maximum_life\", value=4),\n                Affix(name=\"shadow_resistance\", value=4),\n            ],\n            aspect=Aspect(name=\"flickerstep\"),\n        ),\n    ),\n    (\n        \"greater affix but aspect is wrong\",\n        [],\n        TestUnique(\n            item_type=ItemType.Boots,\n            affixes=[\n                Affix(name=\"movement_speed\", value=4, type=AffixType.greater),\n                Affix(name=\"intelligence\", value=4, type=AffixType.greater),\n                Affix(name=\"maximum_life\", value=4),\n                Affix(name=\"shadow_resistance\", value=4),\n            ],\n            aspect=Aspect(name=\"blood_wake\"),\n        ),\n    ),\n    (\"aspect only\", [\"test.UniqueAspectOnly\"], TestUnique(aspect=Aspect(name=\"battle_trance\"))),\n    (\n        \"smaller aspect value\",\n        [\"test.SmallerUniqueAspectValue\"],\n        TestUnique(aspect=Aspect(name=\"crown_of_lucion\", value=10, min_value=15, max_value=10)),\n    ),\n]\n"
  },
  {
    "path": "tests/item/filter/filter_test.py",
    "content": "from __future__ import annotations\n\nimport typing\n\nimport pytest\nfrom natsort import natsorted\n\nfrom src.config.loader import IniConfigLoader\nfrom src.config.profile_models import SigilPriority\nfrom src.config.settings_models import AspectFilterType\nfrom src.item.filter import Filter, FilterResult\nfrom tests.item.filter.data import filters\nfrom tests.item.filter.data.affixes import affixes\nfrom tests.item.filter.data.aspects import aspects\nfrom tests.item.filter.data.sigils import sigil_jalal, sigil_priority, sigils\nfrom tests.item.filter.data.tributes import tributes\nfrom tests.item.filter.data.uniques import global_uniques, simple_mythics, uniques_with_affixes\n\nif typing.TYPE_CHECKING:\n    from pytest_mock import MockerFixture\n\n    from src.item.models import Item\n\n\ndef _create_mocked_filter(mocker: MockerFixture) -> Filter:\n    filter_obj = Filter()\n    # Filter is singleton so we need to reset the filters to be safe\n    filter_obj.affix_filters = {}\n    filter_obj.aspect_upgrade_filters = {}\n    filter_obj.paragon_filters = {}\n    filter_obj.global_unique_filters = {}\n    filter_obj.sigil_filters = {}\n    filter_obj.tribute_filters = {}\n    filter_obj.files_loaded = True\n    mocker.patch.object(filter_obj, \"_did_files_change\", return_value=False)\n    return filter_obj\n\n\n@pytest.mark.parametrize(\n    (\"_name\", \"result\", \"item\"), natsorted(affixes), ids=[name for name, _, _ in natsorted(affixes)]\n)\ndef test_affixes(_name: str, result: list[str], item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.affix_filters = {filters.affix.name: filters.affix.Affixes}\n    assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result)\n\n\n@pytest.mark.parametrize(\n    (\"_name\", \"result\", \"item\"), natsorted(aspects), ids=[name for name, _, _ in natsorted(aspects)]\n)\ndef test_aspects(_name: str, result: list[str], item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    general_mock = mocker.patch.object(IniConfigLoader(), \"_general\")\n    general_mock.keep_aspects = AspectFilterType.upgrade\n    mocker.patch.object(test_filter, \"_check_affixes\", return_value=FilterResult(keep=False, matched=[]))\n    test_filter.aspect_upgrade_filters = {filters.aspects_filters.name: filters.aspects_filters.AspectUpgrades}\n    assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result)\n\n\n@pytest.mark.parametrize(\n    (\"_name\", \"result\", \"item\"), natsorted(global_uniques), ids=[name for name, _, _ in natsorted(global_uniques)]\n)\ndef test_global_uniques(_name: str, result: list[str], item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.global_unique_filters = {filters.global_unique.name: filters.global_unique.GlobalUniques}\n    assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result)\n\n\n@pytest.mark.parametrize((\"_name\", \"result\", \"item\"), natsorted(sigils), ids=[name for name, _, _ in natsorted(sigils)])\ndef test_sigils(_name: str, result: list[str], item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.sigil_filters = {filters.sigil.name: filters.sigil.Sigils}\n    assert natsorted([match.profile.split(\".\")[0] for match in test_filter.should_keep(item).matched]) == natsorted(\n        result\n    )\n\n\ndef test_sigil_empty_lists(mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.sigil_filters = {filters.sigil_whitelist_only.name: filters.sigil_whitelist_only.Sigils}\n    assert test_filter.should_keep(sigil_jalal).matched == []\n    assert test_filter.should_keep(sigil_priority).matched[0].profile == filters.sigil_whitelist_only.name\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.sigil_filters = {filters.sigil_blacklist_only.name: filters.sigil_blacklist_only.Sigils}\n    assert test_filter.should_keep(sigil_jalal).matched[0].profile == filters.sigil_blacklist_only.name\n    assert test_filter.should_keep(sigil_priority).matched == []\n\n\ndef test_sigil_priority(mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.sigil_filters = {filters.sigil_priority.name: filters.sigil_priority.Sigils}\n    assert test_filter.should_keep(sigil_priority).matched == []\n    test_filter.sigil_filters[next(iter(test_filter.sigil_filters))].priority = SigilPriority.whitelist\n    assert test_filter.should_keep(sigil_priority).matched[0].profile == filters.sigil_priority.name\n\n\n@pytest.mark.parametrize(\n    (\"_name\", \"result\", \"item\"), natsorted(tributes), ids=[name for name, _, _ in natsorted(tributes)]\n)\ndef test_tributes(_name: str, result: list[str], item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.tribute_filters = {filters.tributes.name: filters.tributes.Tributes}\n    assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result)\n\n\n@pytest.mark.parametrize(\n    (\"_name\", \"result\", \"item\"),\n    natsorted(uniques_with_affixes),\n    ids=[name for name, _, _ in natsorted(uniques_with_affixes)],\n)\ndef test_uniques_with_affixes(_name: str, result: list[str], item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.affix_filters = {filters.unique_affixes.name: filters.unique_affixes.Affixes}\n    assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result)\n\n\n@pytest.mark.parametrize(\n    (\"_name\", \"result\", \"item\"), natsorted(simple_mythics), ids=[name for name, _, _ in natsorted(simple_mythics)]\n)\ndef test_mythic_always_kept(_name: str, result: bool, item: Item, mocker: MockerFixture):\n    test_filter = _create_mocked_filter(mocker)\n    test_filter.global_unique_filters = {filters.always_keep_mythics.name: filters.always_keep_mythics.GlobalUniques}\n    assert test_filter.should_keep(item).keep == result\n"
  },
  {
    "path": "tests/item/read_descr_season6_tts_test.py",
    "content": "import pytest\n\nimport src.tts\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.descr.read_descr_tts import read_descr\nfrom src.item.models import Item\n\nitems = [\n    (\n        [\n            \"BLOOD ARTISANS CUIRASS\",\n            \"Unique Chest Armor\",\n            \"750 Item Power\",\n            \"210 Armor\",\n            \"+305 Maximum Life [305 - 340]\",\n            \"+125.0% Damage for 4 Seconds After Picking Up a Blood Orb [98.0 - 125.0]%[4]\",\n            \"Blood Orbs Restore +9 Essence [8 - 12]\",\n            \"+3 to Bone Spirit [2 - 3]\",\n            \"When you pick up 5 [10 - 3] Blood Orbs, a free Bone Spirit is spawned, dealing bonus damage based on your current Life percent.\",\n            \"Empty Socket\",\n            \"The infamous Necromancer Gaza-Thuls mastery over blood magic was indisputable. Many suspect that upon his death, his skin was used to fashion this eldritch armor.. - Barretts Book of Implements\",\n            \"Requires Level 60Necromancer. Only. Unique Equipped\",\n            \"Sell Value: 184,341 Gold\",\n            \"Durability: 100/100\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=340.0,\n                    min_value=305.0,\n                    name=\"maximum_life\",\n                    text=\"+305 Maximum Life [305 - 340]\",\n                    type=AffixType.normal,\n                    value=305.0,\n                ),\n                Affix(\n                    max_value=125.0,\n                    min_value=98.0,\n                    name=\"damage_for_seconds_after_picking_up_a_blood_orb\",\n                    text=\"+125.0% Damage for 4 Seconds After Picking Up a Blood Orb [98.0 - 125.0]%[4]\",\n                    type=AffixType.normal,\n                    value=125.0,\n                ),\n                Affix(\n                    max_value=12.0,\n                    min_value=8.0,\n                    name=\"blood_orbs_restore_essence\",\n                    text=\"Blood Orbs Restore +9 Essence [8 - 12]\",\n                    type=AffixType.normal,\n                    value=9.0,\n                ),\n                Affix(\n                    max_value=3.0,\n                    min_value=2.0,\n                    name=\"to_bone_spirit\",\n                    text=\"+3 to Bone Spirit [2 - 3]\",\n                    type=AffixType.normal,\n                    value=3.0,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"blood_artisans_cuirass\",\n                text=\"When you pick up 5 [10 - 3] Blood Orbs, a free Bone Spirit is spawned, dealing bonus damage based on your current Life percent.\",\n                value=5.0,\n            ),\n            codex_upgrade=False,\n            inherent=[],\n            item_type=ItemType.ChestArmor,\n            name=\"blood_artisans_cuirass\",\n            power=750,\n            rarity=ItemRarity.Unique,\n        ),\n    ),\n    # Ensuring mythics are read correctly\n    (\n        [\n            \"HARLEQUIN CREST\",\n            \"Ancestral Mythic Unique Helm\",\n            \"800 Item Power\",\n            \"Masterwork: 12 / 12\",\n            \"128 Armor\",\n            \"+1,760 Maximum Life [1,760]\",\n            \"+26 Maximum Resource [26]\",\n            \"+682 Armor\",\n            \"29.0% Cooldown Reduction [29.0]%\",\n            \"Gain 20% Damage Reduction. In addition, gain +4 Ranks to all Skills.\",\n            \"+60 Intelligence\",\n            \"+40 Intelligence\",\n            \"This headdress was once worn by an assassin disguised as a court mage. Her treachery was unveiled, but not before she used its magic to curse the kings entire lineage.. - The Fall of House Aston\",\n            \"Requires Level 35. Account Bound. Unique Equipped\",\n            \"Sell Value: 164,263 Gold\",\n            \"Durability: 100/100\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=1760.0,\n                    min_value=1760.0,\n                    name=\"maximum_life\",\n                    text=\"+1,760 Maximum Life [1,760]\",\n                    type=AffixType.normal,\n                    value=1760.0,\n                ),\n                Affix(\n                    max_value=26.0,\n                    min_value=26.0,\n                    name=\"maximum_resource\",\n                    text=\"+26 Maximum Resource [26]\",\n                    type=AffixType.normal,\n                    value=26.0,\n                ),\n                Affix(\n                    max_value=None, min_value=None, name=\"armor\", text=\"+682 Armor\", type=AffixType.greater, value=682.0\n                ),\n                Affix(\n                    max_value=29.0,\n                    min_value=29.0,\n                    name=\"cooldown_reduction\",\n                    text=\"29.0% Cooldown Reduction [29.0]%\",\n                    type=AffixType.normal,\n                    value=29.0,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"harlequin_crest\",\n                text=\"Gain 20% Damage Reduction. In addition, gain +4 Ranks to all Skills.\",\n                value=20.0,\n            ),\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            item_type=ItemType.Helm,\n            name=\"harlequin_crest\",\n            power=800,\n            rarity=ItemRarity.Mythic,\n        ),\n    ),\n]\n\n\n@pytest.mark.parametrize((\"input_item\", \"expected_item\"), items)\ndef test_items(input_item: list[str], expected_item: Item):\n    src.tts.LAST_ITEM = input_item\n    item = read_descr()\n    assert item == expected_item\n"
  },
  {
    "path": "tests/item/read_descr_season8_tts_test.py",
    "content": "import pytest\n\nimport src.tts\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.descr.read_descr_tts import read_descr\nfrom src.item.models import Item\n\nitems = [\n    (\n        [\n            \"FISTS OF FATE\",\n            \"Unique Gloves\",\n            \"750 Item Power\",\n            \"60 Armor  (-4)\",\n            \"+2.6% Attack Speed [0.1 - 8.7]% (+2.6%)\",\n            \"+8.1% Critical Strike Chance [0.1 - 8.7]% (+8.1%)\",\n            \"+51.0% Lucky Hit Chance [1.0 - 51.8]% (+51.0%)\",\n            \"Lucky Hit: Up to a +30.0% Chance to Make Enemies Vulnerable for 3 Seconds [1.0 - 51.8]%[3] (+30.0%)\",\n            \"Your attacks randomly deal 1% to 205% [200 - 300]% of their normal damage.\",\n            \"Properties lost when equipped:\",\n            \"+4 to Primordial Binding\",\n            \"+2 to Familiar\",\n            \"+53.5% Familiar Explosion Size\",\n            \"+27.5% Chance for Familiars to Hit Twice\",\n            \"Unique Power\",\n            \"Will you let fear cheat you, or will you risk everything to find understanding? After all, death is simply the coin with which we purchase life.. - Zurke\",\n            \"Requires Level 60. Account Bound. Unique Equipped\",\n            \"Sell Value: 90,289 Gold\",\n            \"Durability: 100/100\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=8.7,\n                    min_value=0.1,\n                    name=\"attack_speed\",\n                    text=\"+2.6% Attack Speed [0.1 - 8.7]% (+2.6%)\",\n                    type=AffixType.normal,\n                    value=2.6,\n                ),\n                Affix(\n                    max_value=8.7,\n                    min_value=0.1,\n                    name=\"critical_strike_chance\",\n                    text=\"+8.1% Critical Strike Chance [0.1 - 8.7]% (+8.1%)\",\n                    type=AffixType.normal,\n                    value=8.1,\n                ),\n                Affix(\n                    max_value=51.8,\n                    min_value=1.0,\n                    name=\"lucky_hit_chance\",\n                    text=\"+51.0% Lucky Hit Chance [1.0 - 51.8]% (+51.0%)\",\n                    type=AffixType.normal,\n                    value=51.0,\n                ),\n                Affix(\n                    max_value=51.8,\n                    min_value=1.0,\n                    name=\"lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds\",\n                    text=\"Lucky Hit: Up to a +30.0% Chance to Make Enemies Vulnerable for 3 Seconds [1.0 - 51.8]%[3] (+30.0%)\",\n                    type=AffixType.normal,\n                    value=30.0,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"fists_of_fate\",\n                min_value=200.0,\n                max_value=300.0,\n                text=\"Your attacks randomly deal 1% to 205% [200 - 300]% of their normal damage.\",\n                value=205.0,\n            ),\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            item_type=ItemType.Gloves,\n            name=\"fists_of_fate\",\n            power=750,\n            rarity=ItemRarity.Unique,\n        ),\n    )\n]\n\n\n@pytest.mark.parametrize((\"input_item\", \"expected_item\"), items)\ndef test_items(input_item: list[str], expected_item: Item):\n    src.tts.LAST_ITEM = input_item\n    item = read_descr()\n    assert item == expected_item\n"
  },
  {
    "path": "tests/item/read_descr_season_11_tts_test.py",
    "content": "import pytest\n\nimport src.tts\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.data.seasonal_attribute import SeasonalAttribute\nfrom src.item.descr.read_descr_tts import read_descr\nfrom src.item.models import Item\n\nitems = [\n    (\n        # Sanctified items intentionally do not have their affixes read, which we are confirming with these next two tests\n        [\n            \"ARCHON GAUNTLETS OF INFESTATION\",\n            \"Ancestral Legendary Gloves\",\n            \"800 Item Power\",\n            \"[PH] 20 (+20) Quality\",\n            \"Sanctified\",\n            \"905 Armor\",\n            \"+118 Dexterity +[107 - 121]\",\n            \"+342 Life On Kill\",\n            \"+97.5% Overpower Damage\",\n            \"+451 Fire Resistance [441 - 490]\",\n            \"+54 All Stats +[51 - 65]\",\n            \"Lucky Hit: Centipede Skills have up to a 35% chance to spawn a Pestilent Swarm from the target which deals 436 [228 - 488] Poison damage per hit.. Pestilent Swarms now also deal 100% of their Base damage as Poisoning damage over 6 seconds.\",\n            \"+14.2% Critical Strike Damage [12.0 - 15.0]%\",\n            \"Requires Level 60. Account Bound. Vessel of Hatred Item\",\n            \"Unmodifiable\",\n            \"Sell Value: 19,254 Gold\",\n            \"Durability: 100/100\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Gloves,\n            name=\"archon_gauntlets_of_infestation\",\n            power=800,\n            rarity=ItemRarity.Legendary,\n            seasonal_attribute=SeasonalAttribute.sanctified,\n        ),\n    ),\n    (\n        [\n            \"ASCENDANT QUARTERSTAFF OF UNYIELDING HITS\",\n            \"Ancestral Legendary Quarterstaff\",\n            \"800 Item Power\",\n            \"Sanctified\",\n            \"596 Damage Per Second\",\n            \"[434 - 650] Damage per Hit\",\n            \"1.10 Attacks per Second (Fast)\",\n            \"45% Block Chance [45]%\",\n            \"+231 Dexterity +[214 - 242]\",\n            \"+1,370 Maximum Life\",\n            \"+292 Life On Hit [292 - 318]\",\n            \"+114.0% Overpower Damage [110.0 - 130.0]%\",\n            \"Ignore Durability Loss\",\n            \"Casting a Gorilla Skill increases your Weapon Damage by 52% [20 - 60]% of your Armor for 3 seconds. Maximum 1,500 bonus Weapon Damage.\",\n            \"Empty Socket\",\n            \"Requires Level 60. Account Bound. Only. Vessel of Hatred Item\",\n            \"Unmodifiable\",\n            \"Sell Value: 52,949 Gold\",\n            \"Durability: Indestructible\",\n            \"Mousewheel scroll down\",\n            \"Scroll Down\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Quarterstaff,\n            name=\"ascendant_quarterstaff_of_unyielding_hits\",\n            power=800,\n            rarity=ItemRarity.Legendary,\n            seasonal_attribute=SeasonalAttribute.sanctified,\n        ),\n    ),\n    # Item from another class\n    (\n        [\n            \"BONEWEAVE GAUNTLETS OF CELESTIAL STRIFE\",\n            \"Legendary Gloves\",\n            \"750 Item Power\",\n            \"562 Armor (+15.4% Toughness)\",\n            \"+90 Strength +[89 - 99] (Barbarian  Only)\",\n            \"+7.0% Critical Strike Chance [6.0 - 7.0]% (+7.0%)\",\n            \"+39.0% Vulnerable Damage [35.0 - 45.0]% (+39.0%)\",\n            \"+340 Lightning Resistance [321 - 350] (+340)\",\n            \"While in Arbiter, kills grant 1.5%[x] [1.5 - 2.3]% increased Vulnerable damage, up to a maximum of 75%[x] [75 - 113]%. ( Only)\",\n            \"Properties lost when equipped:\",\n            \"1.9% Resource Cost Reduction\",\n            \"2.0% Cooldown Reduction\",\n            \"+2 Life On Hit\",\n            \"Requires Level 60. Lord of Hatred Item\",\n            \"Unlocks new Aspect in the Codex of Power on salvage\",\n            \"Sell Value: 17,198 Gold\",\n            \"Durability: 100/100. Tempers: 3/3\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=99.0,\n                    min_value=89.0,\n                    name=\"strength\",\n                    text=\"+90 Strength +[89 - 99] (Barbarian  Only)\",\n                    type=AffixType.normal,\n                    value=90.0,\n                ),\n                Affix(\n                    max_value=7.0,\n                    min_value=6.0,\n                    name=\"critical_strike_chance\",\n                    text=\"+7.0% Critical Strike Chance [6.0 - 7.0]% (+7.0%)\",\n                    type=AffixType.normal,\n                    value=7.0,\n                ),\n                Affix(\n                    max_value=45.0,\n                    min_value=35.0,\n                    name=\"vulnerable_damage\",\n                    text=\"+39.0% Vulnerable Damage [35.0 - 45.0]% (+39.0%)\",\n                    type=AffixType.normal,\n                    value=39.0,\n                ),\n                Affix(\n                    max_value=350.0,\n                    min_value=321.0,\n                    name=\"lightning_resistance\",\n                    text=\"+340 Lightning Resistance [321 - 350] (+340)\",\n                    type=AffixType.normal,\n                    value=340.0,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"of_celestial_strife\",\n                text=\"While in Arbiter, kills grant 1.5%[x] [1.5 - 2.3]% increased Vulnerable damage, up to a maximum of 75%[x] [75 - 113]%. ( Only)\",\n            ),\n            codex_upgrade=True,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Gloves,\n            name=\"boneweave_gauntlets_of_celestial_strife\",\n            power=750,\n            rarity=ItemRarity.Legendary,\n        ),\n    ),\n    # 3 Affix rare that has been imprinted and turned into a legendary\n    (\n        [\n            \"SHEPHERDS DEATHLINGS EYELET\",\n            \"Legendary Amulet\",\n            \"495 Item Power\",\n            \"91 All Resist\",\n            \"+14 Maximum Life [12 - 15]\",\n            \"+4.3% Critical Strike Chance [4.0 - 4.5]%\",\n            \"3.0% Cooldown Reduction [3.0 - 3.4]%\",\n            \"Imprinted: Companion Skills deal an additional 14.3%[x] [7.5 - 19.5]% damage per Companion you have.\",\n            \"Empty Socket\",\n            \"Requires Level 48. Account Bound\",\n            \"Sell Value: 15,584 Gold\",\n            \"Tempers: 3/3\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=15.0,\n                    min_value=12.0,\n                    name=\"maximum_life\",\n                    text=\"+14 Maximum Life [12 - 15]\",\n                    type=AffixType.normal,\n                    value=14.0,\n                ),\n                Affix(\n                    max_value=4.5,\n                    min_value=4.0,\n                    name=\"critical_strike_chance\",\n                    text=\"+4.3% Critical Strike Chance [4.0 - 4.5]%\",\n                    type=AffixType.normal,\n                    value=4.3,\n                ),\n                Affix(\n                    max_value=3.4,\n                    min_value=3.0,\n                    name=\"cooldown_reduction\",\n                    text=\"3.0% Cooldown Reduction [3.0 - 3.4]%\",\n                    type=AffixType.normal,\n                    value=3.0,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"shepherds\",\n                text=\"Imprinted: Companion Skills deal an additional 14.3%[x] [7.5 - 19.5]% damage per Companion you have.\",\n            ),\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Amulet,\n            name=\"shepherds_deathlings_eyelet\",\n            power=495,\n            rarity=ItemRarity.Legendary,\n        ),\n    ),\n    # Masterworked item so it has a quality indicator\n    (\n        [\n            \"LIMBIC BOUNDARY OF THE RABID BEAST\",\n            \"Ancestral Legendary Ring\",\n            \"800 Item Power\",\n            \"25 ( +25) Quality\",\n            \"204 All Resist\",\n            \"+212 Willpower\",\n            \"+555 Maximum Life [424 - 457]\",\n            \"+7.5% Critical Strike Chance [5.2 - 6.0]%\",\n            \"Lucky Hit: Up to a +54.1% Chance to Make Enemies Vulnerable for 2 Seconds [43.3 - 47.2]%[2]\",\n            \"Imprinted: Deal 58%[x] [40 - 80]% more Poison damage. While Shapeshifted, your direct damage is converted into Poison damage.\",\n            \"+125 Resistance to All Elements\",\n            \"Requires Level 60. Account Bound\",\n            \"Sell Value: 33,288 Gold\",\n            \"Tempers: 4/4\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=None,\n                    min_value=None,\n                    name=\"willpower\",\n                    text=\"+212 Willpower\",\n                    type=AffixType.greater,\n                    value=212.0,\n                ),\n                Affix(\n                    max_value=457.0,\n                    min_value=424.0,\n                    name=\"maximum_life\",\n                    text=\"+555 Maximum Life [424 - 457]\",\n                    type=AffixType.normal,\n                    value=555.0,\n                ),\n                Affix(\n                    max_value=6.0,\n                    min_value=5.2,\n                    name=\"critical_strike_chance\",\n                    text=\"+7.5% Critical Strike Chance [5.2 - 6.0]%\",\n                    type=AffixType.normal,\n                    value=7.5,\n                ),\n                Affix(\n                    max_value=47.2,\n                    min_value=43.3,\n                    name=\"lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds\",\n                    text=\"Lucky Hit: Up to a +54.1% Chance to Make Enemies Vulnerable for 2 Seconds [43.3 - 47.2]%[2]\",\n                    type=AffixType.normal,\n                    value=54.1,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"of_the_rabid_beast\",\n                min_value=None,\n                max_value=None,\n                text=\"Imprinted: Deal 58%[x] [40 - 80]% more Poison damage. While Shapeshifted, your direct damage is converted into Poison damage.\",\n                value=None,\n            ),\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Ring,\n            name=\"limbic_boundary_of_the_rabid_beast\",\n            power=800,\n            rarity=ItemRarity.Legendary,\n        ),\n    ),\n    # Escalation sigil that is also a whisper\n    (\n        [\n            \"HAUNTED REFUGE ESCALATION SIGIL\",\n            \"Legendary Escalation Sigil\",\n            \"Brave a series of dungeons, each one deadlier than the last, in search of Astaroths Lair.\",\n            \"Haunted Refuge in Hawezar\",\n            \"Grants Grim Favor for 17m\",\n            \"AFFIXES\",\n            \"Hidden Armory\",\n            \"Exceptional items are kept here, granting elite monsters a powerful loot affix.\",\n            \"Deathly Shadows\",\n            \"Killing a monster has a chance to unleash a volatile pulse after a short delay, dealing heavy area damage.\",\n            \"Account Bound\",\n            \"Sell Value: 1 Gold\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(max_value=None, min_value=None, name=\"hidden_armory\", text=\"\", type=AffixType.normal, value=None),\n                Affix(\n                    max_value=None, min_value=None, name=\"deathly_shadows\", text=\"\", type=AffixType.normal, value=None\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.EscalationSigil,\n            name=\"haunted_refuge\",\n            power=None,\n            rarity=ItemRarity.Legendary,\n            original_name=\"HAUNTED REFUGE ESCALATION SIGIL\",\n        ),\n    ),\n    # Regular sigil\n    (\n        [\n            \"Nightmare Sigil\",\n            \"Transform this dungeon into. aNightmare Dungeon\",\n            \"Mercys Reach in Fractured Peaks\",\n            \"DUNGEON AFFIXES\",\n            \"Hidden Armory\",\n            \"Exceptional items are kept here, granting elite monsters a powerful loot affix.\",\n            \"Deathly Shadows\",\n            \"Killing a monster has a chance to unleash a volatile pulse after a short delay, dealing heavy area damage.\",\n            \"Account Bound\",\n            \"Sell Value: 1 Gold\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(max_value=None, min_value=None, name=\"hidden_armory\", text=\"\", type=AffixType.normal, value=None),\n                Affix(\n                    max_value=None, min_value=None, name=\"deathly_shadows\", text=\"\", type=AffixType.normal, value=None\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Sigil,\n            name=\"mercys_reach\",\n            power=None,\n            rarity=ItemRarity.Common,\n        ),\n    ),\n    # Whether or not all resist is considered an inherent changes\n    (\n        [\n            \"MOTHERS EMBRACE\",\n            \"Unique Ring\",\n            \"750 Item Power\",\n            \"104 All Resist (-9.4% Toughness)\",\n            \"+41 All Stats +[37 - 55]\",\n            \"+12.5% Attack Speed [8.0 - 12.5]%\",\n            \"+6.8% Critical Strike Chance [6.0 - 7.0]%\",\n            \"+6.3% Lucky Hit Chance [6.0 - 7.0]%\",\n            \"If a Core Skill hits 4 or more enemies, 44% [30 - 60]% of the Resource cost is refunded.\",\n            \"Every tome, every scroll, every book in this temple produces the same answer. The only being willing to stand against the Eternal Conflict, against the Prime Evils, was Lilith.. - Elias\",\n            \"Requires Level 60. Unique Equipped\",\n            \"Sell Value: 94,589 Gold\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=55.0,\n                    min_value=37.0,\n                    name=\"all_stats\",\n                    text=\"+41 All Stats +[37 - 55]\",\n                    type=AffixType.normal,\n                    value=41.0,\n                ),\n                Affix(\n                    max_value=12.5,\n                    min_value=8.0,\n                    name=\"attack_speed\",\n                    text=\"+12.5% Attack Speed [8.0 - 12.5]%\",\n                    type=AffixType.normal,\n                    value=12.5,\n                ),\n                Affix(\n                    max_value=7.0,\n                    min_value=6.0,\n                    name=\"critical_strike_chance\",\n                    text=\"+6.8% Critical Strike Chance [6.0 - 7.0]%\",\n                    type=AffixType.normal,\n                    value=6.8,\n                ),\n                Affix(\n                    max_value=7.0,\n                    min_value=6.0,\n                    name=\"lucky_hit_chance\",\n                    text=\"+6.3% Lucky Hit Chance [6.0 - 7.0]%\",\n                    type=AffixType.normal,\n                    value=6.3,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"mothers_embrace\",\n                min_value=30.0,\n                max_value=60.0,\n                text=\"If a Core Skill hits 4 or more enemies, 44% [30 - 60]% of the Resource cost is refunded.\",\n                value=44.0,\n            ),\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Ring,\n            name=\"mothers_embrace\",\n            original_name=\"MOTHERS EMBRACE\",\n            power=750,\n            rarity=ItemRarity.Unique,\n        ),\n    ),\n]\n\n\n@pytest.mark.parametrize((\"input_item\", \"expected_item\"), items)\ndef test_items(input_item: list[str], expected_item: Item):\n    src.tts.LAST_ITEM = input_item\n    item = read_descr()\n    assert item == expected_item\n"
  },
  {
    "path": "tests/item/read_descr_season_12_tts_test.py",
    "content": "import pytest\n\nimport src.tts\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.data.seasonal_attribute import SeasonalAttribute\nfrom src.item.descr.read_descr_tts import read_descr\nfrom src.item.models import Item\n\nitems = [\n    (\n        # The next 2 tests are bloodied items\n        [\n            \"INIMICAL SEAL OF PILGRIMS PROGRESS\",\n            \"Bloodied Legendary Amulet\",\n            \"383 Item Power\",\n            \"70 All Resist (-2.2% Toughness)\",\n            \"+19 Strength +[18 - 20]\",\n            \"+8 Maximum Life [8 - 10]\",\n            \"+1 Faith On Kill +[1]\",\n            \"+10.1% Movement Speed [6.6 - 11.6]%\",\n            \"Hunger: 10% increased chance for Rampage Items during Kill Streaks [10]%\",\n            \"Your Disciple Skills with Cooldowns generate up to 30 Faith based on how far your travel with them.\",\n            \"Requires Level 34. Lord of Hatred Item\",\n            \"Unlocks new Aspect in the Codex of Power on salvage\",\n            \"Sell Value: 9,284 Gold\",\n            \"Tempers: 3/3\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=20.0,\n                    min_value=18.0,\n                    name=\"strength\",\n                    text=\"+19 Strength +[18 - 20]\",\n                    type=AffixType.normal,\n                    value=19.0,\n                ),\n                Affix(\n                    max_value=10.0,\n                    min_value=8.0,\n                    name=\"maximum_life\",\n                    text=\"+8 Maximum Life [8 - 10]\",\n                    type=AffixType.normal,\n                    value=8.0,\n                ),\n                Affix(\n                    max_value=1.0,\n                    min_value=1.0,\n                    name=\"faith_on_kill\",\n                    text=\"+1 Faith On Kill +[1]\",\n                    type=AffixType.normal,\n                    value=1.0,\n                ),\n                Affix(\n                    max_value=11.6,\n                    min_value=6.6,\n                    name=\"movement_speed\",\n                    text=\"+10.1% Movement Speed [6.6 - 11.6]%\",\n                    type=AffixType.normal,\n                    value=10.1,\n                ),\n                Affix(\n                    max_value=10.0,\n                    min_value=10.0,\n                    name=\"hunger_increased_chance_for_rampage_items_during_kill_streaks\",\n                    text=\"Hunger: 10% increased chance for Rampage Items during Kill Streaks [10]%\",\n                    type=AffixType.normal,\n                    value=10.0,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"of_pilgrims_progress\",\n                text=\"Your Disciple Skills with Cooldowns generate up to 30 Faith based on how far your travel with them.\",\n            ),\n            codex_upgrade=True,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Amulet,\n            name=\"inimical_seal_of_pilgrims_progress\",\n            original_name=\"INIMICAL SEAL OF PILGRIMS PROGRESS\",\n            power=383,\n            rarity=ItemRarity.Legendary,\n            seasonal_attribute=SeasonalAttribute.bloodied,\n        ),\n    ),\n    (\n        [\n            \"LURKING SNARE\",\n            \"Bloodied Rare Gloves\",\n            \"393 Item Power\",\n            \"295 Armor (+1.6% Toughness)\",\n            \"+24 Strength +[24 - 26]\",\n            \"+9 Maximum Life [8 - 10]\",\n            \"2.5% Resource Cost Reduction [2.2 - 2.5]%\",\n            \"Rampage: +8% Critical Strike Chance per Kill Streak Tier [8]%\",\n            \"Requires Level 38\",\n            \"Sell Value: 2,775 Gold\",\n            \"Durability: 100/100. Tempers: 1/1\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=26.0,\n                    min_value=24.0,\n                    name=\"strength\",\n                    text=\"+24 Strength +[24 - 26]\",\n                    type=AffixType.normal,\n                    value=24.0,\n                ),\n                Affix(\n                    max_value=10.0,\n                    min_value=8.0,\n                    name=\"maximum_life\",\n                    text=\"+9 Maximum Life [8 - 10]\",\n                    type=AffixType.normal,\n                    value=9.0,\n                ),\n                Affix(\n                    max_value=2.5,\n                    min_value=2.2,\n                    name=\"resource_cost_reduction\",\n                    text=\"2.5% Resource Cost Reduction [2.2 - 2.5]%\",\n                    type=AffixType.normal,\n                    value=2.5,\n                ),\n                Affix(\n                    max_value=8.0,\n                    min_value=8.0,\n                    name=\"rampage_critical_strike_chance_per_kill_streak_tier\",\n                    text=\"Rampage: +8% Critical Strike Chance per Kill Streak Tier [8]%\",\n                    type=AffixType.normal,\n                    value=8.0,\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Gloves,\n            name=\"lurking_snare\",\n            original_name=\"LURKING SNARE\",\n            power=393,\n            rarity=ItemRarity.Rare,\n            seasonal_attribute=SeasonalAttribute.bloodied,\n        ),\n    ),\n    # Bloodied nightmare sigil\n    (\n        [\n            \"ULDURS CAVE NIGHTMARE SIGIL\",\n            \"Bloodied Nightmare Sigil\",\n            \"Transform this place into a  Bloodied Nightmare Dungeon with greater challenge and greater reward.\",\n            \"Uldurs Cave in Kehjistan\",\n            \"DUNGEON AFFIXES\",\n            \"Obols Reserve\",\n            \"Many Obols chests have been stashed here.\",\n            \"Profane Aegis\",\n            \"Monsters gain 50% of their Maximum Life as a Barrier.\",\n            \"Bloodstained\",\n            \"Enemies are much stronger here.\",\n            \"Relentless Butcher\",\n            \"The Butcher is relentlessly stalking you...\",\n            \"Account Bound\",\n            \"Seasonal Item\",\n            \"Sell Value: 1 Gold\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(max_value=None, min_value=None, name=\"obols_reserve\", text=\"\", type=AffixType.normal, value=None),\n                Affix(max_value=None, min_value=None, name=\"profane_aegis\", text=\"\", type=AffixType.normal, value=None),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Sigil,\n            name=\"uldurs_cave\",\n            original_name=\"ULDURS CAVE NIGHTMARE SIGIL\",\n            power=None,\n            rarity=ItemRarity.Common,\n            seasonal_attribute=SeasonalAttribute.bloodied,\n        ),\n    ),\n]\n\n\n@pytest.mark.parametrize((\"input_item\", \"expected_item\"), items)\ndef test_items(input_item: list[str], expected_item: Item):\n    src.tts.LAST_ITEM = input_item\n    item = read_descr()\n    assert item == expected_item\n"
  },
  {
    "path": "tests/item/read_descr_season_13_tts_test.py",
    "content": "import pytest\n\nimport src.tts\nfrom src.item.data.affix import Affix, AffixType\nfrom src.item.data.aspect import Aspect\nfrom src.item.data.item_type import ItemType\nfrom src.item.data.rarity import ItemRarity\nfrom src.item.descr.read_descr_tts import read_descr\nfrom src.item.models import Item\n\nitems = [\n    (\n        # In season 13 weapons have nothing we'd consider an inherent\n        [\n            \"VICTORY SPINE\",\n            \"Rare Sword\",\n            \"850 Item Power\",\n            \"1,406 Damage Per Second (-26)\",\n            \"[1,023 - 1,535] Damage per Hit\",\n            \"1.10 Attacks per Second (Fast)\",\n            \"+1,802 Maximum Life [1,526 - 1,830]\",\n            \"x7% All Damage Multiplier [6 - 10]%\",\n            \"x24% Critical Strike Damage Multiplier [13 - 25]%\",\n            \"Lucky Hit: Up to a 15% Chance to Restore +4 Primary Resource [3 - 4]\",\n            \"Requires Level 70. Account Bound\",\n            \"Sell Value: 13,381 Gold\",\n            \"Durability: 100/100. Tempers: 1/1\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=1830.0,\n                    min_value=1526.0,\n                    name=\"maximum_life\",\n                    text=\"+1,802 Maximum Life [1,526 - 1,830]\",\n                    type=AffixType.normal,\n                    value=1802.0,\n                ),\n                Affix(\n                    max_value=10.0,\n                    min_value=6.0,\n                    name=\"all_damage_multiplier\",\n                    text=\"x7% All Damage Multiplier [6 - 10]%\",\n                    type=AffixType.normal,\n                    value=7.0,\n                ),\n                Affix(\n                    max_value=25.0,\n                    min_value=13.0,\n                    name=\"critical_strike_damage_multiplier\",\n                    text=\"x24% Critical Strike Damage Multiplier [13 - 25]%\",\n                    type=AffixType.normal,\n                    value=24.0,\n                ),\n                Affix(\n                    max_value=4.0,\n                    min_value=3.0,\n                    name=\"lucky_hit_up_to_a_chance_to_restore_primary_resource\",\n                    text=\"Lucky Hit: Up to a 15% Chance to Restore +4 Primary Resource [3 - 4]\",\n                    type=AffixType.normal,\n                    value=4.0,\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Sword,\n            name=\"victory_spine\",\n            original_name=\"VICTORY SPINE\",\n            power=850,\n            rarity=ItemRarity.Rare,\n            seasonal_attribute=None,\n        ),\n    ),\n    # Boots also lost their inherents and Evade grants whatever for 1.5 seconds was being read as a GA\n    (\n        [\n            \"MARCH INCEPTION\",\n            \"Rare Boots\",\n            \"850 Item Power\",\n            \"638 Armor (-8.8% Toughness)\",\n            \"+933 Armor [780 - 980]\",\n            \"+22% Movement Speed [20 - 24]%\",\n            \"+439 Shadow Resistance [416 - 523]\",\n            \"Evade Grants +114% Movement Speed for 1.5 Seconds [100 - 125]%[1.5]\",\n            \"Requires Level 70\",\n            \"Sell Value: 10,705 Gold\",\n            \"Durability: 100/100. Tempers: 1/1\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=980.0,\n                    min_value=780.0,\n                    name=\"armor\",\n                    text=\"+933 Armor [780 - 980]\",\n                    type=AffixType.normal,\n                    value=933.0,\n                ),\n                Affix(\n                    max_value=24.0,\n                    min_value=20.0,\n                    name=\"movement_speed\",\n                    text=\"+22% Movement Speed [20 - 24]%\",\n                    type=AffixType.normal,\n                    value=22.0,\n                ),\n                Affix(\n                    max_value=523.0,\n                    min_value=416.0,\n                    name=\"shadow_resistance\",\n                    text=\"+439 Shadow Resistance [416 - 523]\",\n                    type=AffixType.normal,\n                    value=439.0,\n                ),\n                Affix(\n                    max_value=125.0,\n                    min_value=100.0,\n                    name=\"evade_grants_movement_speed_for_seconds\",\n                    text=\"Evade Grants +114% Movement Speed for 1.5 Seconds [100 - 125]%[1.5]\",\n                    type=AffixType.normal,\n                    value=114.0,\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Boots,\n            name=\"march_inception\",\n            original_name=\"MARCH INCEPTION\",\n            power=850,\n            rarity=ItemRarity.Rare,\n            seasonal_attribute=None,\n        ),\n    ),\n    # This is just to ensure 3 affix rares still work\n    (\n        [\n            \"RIP NEXUS\",\n            \"Rare Sword\",\n            \"850 Item Power\",\n            \"1,406 Damage Per Second (-26)\",\n            \"[1,023 - 1,535] Damage per Hit\",\n            \"1.10 Attacks per Second (Fast)\",\n            \"+126 Dexterity +[125 - 149]\",\n            \"+1,759 Maximum Life [1,526 - 1,830]\",\n            \"x8% All Damage Multiplier [6 - 10]%\",\n            \"Requires Level 70. Account Bound. Lord of Hatred Item\",\n            \"Sell Value: 13,381 Gold\",\n            \"Durability: 100/100. Tempers: 1/1\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=149.0,\n                    min_value=125.0,\n                    name=\"dexterity\",\n                    text=\"+126 Dexterity +[125 - 149]\",\n                    type=AffixType.normal,\n                    value=126.0,\n                ),\n                Affix(\n                    max_value=1830.0,\n                    min_value=1526.0,\n                    name=\"maximum_life\",\n                    text=\"+1,759 Maximum Life [1,526 - 1,830]\",\n                    type=AffixType.normal,\n                    value=1759.0,\n                ),\n                Affix(\n                    max_value=10.0,\n                    min_value=6.0,\n                    name=\"all_damage_multiplier\",\n                    text=\"x8% All Damage Multiplier [6 - 10]%\",\n                    type=AffixType.normal,\n                    value=8.0,\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Sword,\n            name=\"rip_nexus\",\n            original_name=\"RIP NEXUS\",\n            power=850,\n            rarity=ItemRarity.Rare,\n            seasonal_attribute=None,\n        ),\n    ),\n    # Shields also lost an inherent\n    (\n        [\n            \"CONCEITED DREAD SHIELD\",\n            \"Legendary Shield\",\n            \"767 Item Power\",\n            \"863 Armor (-12.5% Toughness)\",\n            \"20.0% Block Chance [20.0]%\",\n            \"+100% Main Hand Weapon Damage [100]%\",\n            \"+93 Strength +[85 - 102] (-11)\",\n            \"+514 Thorns [386 - 579] (+514)\",\n            \"+7.0% Healing Received [7.0 - 11.0]% (+7.0%)\",\n            \"8.8% Damage Reduction [7.0 - 11.0]% (-1.8%)\",\n            \"Deal 50%[x] [40 - 55]% increased damage while you have a Barrier active.\",\n            \"Empty Socket\",\n            \"Properties lost when equipped:\",\n            \"+6.0% Critical Strike Chance\",\n            \"Rampage: +6.0% Cooldown Reduction per Kill Streak Tier\",\n            \"+100 All Stats\",\n            \"+668 Maximum Life\",\n            \"Legendary Power\",\n            \"Requires Level 60\",\n            \"Sell Value: 26,829 Gold\",\n            \"Durability: 100/100. Tempers: 3/3\",\n            \"Mousewheel scroll down\",\n            \"Scroll Down\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=102.0,\n                    min_value=85.0,\n                    name=\"strength\",\n                    text=\"+93 Strength +[85 - 102] (-11)\",\n                    type=AffixType.normal,\n                    value=93.0,\n                ),\n                Affix(\n                    max_value=579.0,\n                    min_value=386.0,\n                    name=\"thorns\",\n                    text=\"+514 Thorns [386 - 579] (+514)\",\n                    type=AffixType.normal,\n                    value=514.0,\n                ),\n                Affix(\n                    max_value=11.0,\n                    min_value=7.0,\n                    name=\"healing_received\",\n                    text=\"+7.0% Healing Received [7.0 - 11.0]% (+7.0%)\",\n                    type=AffixType.normal,\n                    value=7.0,\n                ),\n                Affix(\n                    max_value=11.0,\n                    min_value=7.0,\n                    name=\"damage_reduction\",\n                    text=\"8.8% Damage Reduction [7.0 - 11.0]% (-1.8%)\",\n                    type=AffixType.normal,\n                    value=8.8,\n                ),\n            ],\n            aspect=Aspect(\n                name=\"conceited\", text=\"Deal 50%[x] [40 - 55]% increased damage while you have a Barrier active.\"\n            ),\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Shield,\n            name=\"conceited_dread_shield\",\n            original_name=\"CONCEITED DREAD SHIELD\",\n            power=767,\n            rarity=ItemRarity.Legendary,\n            seasonal_attribute=None,\n        ),\n    ),\n    # Magic item with 1 affix\n    (\n        [\n            \"FIERY RING\",\n            \"Magic Ring\",\n            \"274 Item Power\",\n            \"38 All Resist\",\n            \"x5% Fire Damage Multiplier [4 - 6]%\",\n            \"Requires Level 26\",\n            \"Sell Value: 1,107 Gold\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=6.0,\n                    min_value=4.0,\n                    name=\"fire_damage_multiplier\",\n                    text=\"x5% Fire Damage Multiplier [4 - 6]%\",\n                    type=AffixType.normal,\n                    value=5.0,\n                )\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Ring,\n            name=\"fiery_ring\",\n            original_name=\"FIERY RING\",\n            power=274,\n            rarity=ItemRarity.Magic,\n            seasonal_attribute=None,\n        ),\n    ),\n    # Magic item with 2 affixes that happens to be ancestral\n    (\n        [\n            \"CRIMSON REINFORCED CROSSBOW OF VENOM\",\n            \"Ancestral Magic Crossbow\",\n            \"900 Item Power\",\n            \"3,794 Damage Per Second (-351)\",\n            \"[3,448 - 4,984] Damage per Hit\",\n            \"0.90 Attacks per Second (Slow)\",\n            \"+378 Weapon Damage [230 - 383]\",\n            \"x20% Poison Damage Multiplier [14 - 20]%\",\n            \"Requires Level 70Rogue. Only\",\n            \"Sell Value: 14,789 Gold\",\n            \"Durability: 100/100\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[\n                Affix(\n                    max_value=383.0,\n                    min_value=230.0,\n                    name=\"weapon_damage\",\n                    text=\"+378 Weapon Damage [230 - 383]\",\n                    type=AffixType.normal,\n                    value=378.0,\n                ),\n                Affix(\n                    max_value=20.0,\n                    min_value=14.0,\n                    name=\"poison_damage_multiplier\",\n                    text=\"x20% Poison Damage Multiplier [14 - 20]%\",\n                    type=AffixType.normal,\n                    value=20.0,\n                ),\n            ],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Crossbow2H,\n            name=\"crimson_reinforced_crossbow_of_venom\",\n            original_name=\"CRIMSON REINFORCED CROSSBOW OF VENOM\",\n            power=900,\n            rarity=ItemRarity.Magic,\n            seasonal_attribute=None,\n        ),\n    ),\n    # Ancestral common\n    (\n        [\n            \"CROSSBOW\",\n            \"Ancestral Crossbow\",\n            \"900 Item Power\",\n            \"3,454 Damage Per Second (-691)\",\n            \"[3,070 - 4,606] Damage per Hit\",\n            \"0.90 Attacks per Second (Slow)\",\n            \"Requires Level 70Rogue. Only\",\n            \"Sell Value: 8,873 Gold\",\n            \"Durability: 100/100\",\n            \"Right mouse button\",\n        ],\n        Item(\n            affixes=[],\n            aspect=None,\n            codex_upgrade=False,\n            cosmetic_upgrade=False,\n            inherent=[],\n            is_in_shop=False,\n            item_type=ItemType.Crossbow2H,\n            name=\"crossbow\",\n            original_name=\"CROSSBOW\",\n            power=900,\n            rarity=ItemRarity.Common,\n            seasonal_attribute=None,\n        ),\n    ),\n]\n\n\n@pytest.mark.parametrize((\"input_item\", \"expected_item\"), items)\ndef test_items(input_item: list[str], expected_item: Item):\n    src.tts.LAST_ITEM = input_item\n    item = read_descr()\n    assert item == expected_item\n"
  },
  {
    "path": "tests/item/read_descr_tts_test.py",
    "content": "import src.tts\nfrom src.item.descr.read_descr_tts import read_descr\n\nLOOT_FILTER_TTS = [\"SELECT ALL\", \"Checkbox Disabled\", \"Item Power Range\", \"Left mouse button\"]\n\n\ndef test_loot_filter_controls_are_not_tts_item_start():\n    assert src.tts.find_item_start(LOOT_FILTER_TTS) is None\n\n\ndef test_loot_filter_controls_do_not_raise_tts_parser_error():\n    src.tts.LAST_ITEM = LOOT_FILTER_TTS\n\n    assert read_descr() is None\n"
  },
  {
    "path": "tests/template_finder_test.py",
    "content": "import cv2\n\nimport src.template_finder\nfrom src.utils.misc import is_in_roi\n\n\ndef test_search():\n    \"\"\"Test default search behavior (first match).\"\"\"\n    image = cv2.imread(\"tests/assets/template_finder/stash_slots.png\")\n    slash = cv2.imread(\"tests/assets/template_finder/stash_slot_slash.png\")\n    cross = cv2.imread(\"tests/assets/template_finder/stash_slot_cross.png\")\n    threshold = 0.6\n    result = src.template_finder.search([cross, slash], image, threshold)\n    match = result.matches[0]\n    assert threshold <= match.score < 1\n\n\ndef test_search_best_match():\n    \"\"\"Test search \"best_match\" behavior.\"\"\"\n    image = cv2.imread(\"tests/assets/template_finder/stash_slots.png\")\n    slash = cv2.imread(\"tests/assets/template_finder/stash_slot_slash.png\")\n    cross = cv2.imread(\"tests/assets/template_finder/stash_slot_cross.png\")\n    slash_expected_roi = [38, 0, 38, 38]\n    result = src.template_finder.search([cross, slash], image, threshold=0.6, mode=\"all\")\n    match = result.matches[0]\n    assert is_in_roi(slash_expected_roi, match.center)\n\n\ndef test_search_all():\n    \"\"\"Test all matches for a single template in argument.\"\"\"\n    image = cv2.imread(\"tests/assets/template_finder/stash_slots.png\")\n    empty = cv2.imread(\"tests/assets/template_finder/stash_slot_empty.png\")\n    result = src.template_finder.search(empty, image, threshold=0.98, mode=\"all\")\n    matches = result.matches\n    assert len(matches) == 3\n\n\ndef test_search_all_multiple_templates():\n    \"\"\"Test all matches with multiple templates in argument.\"\"\"\n    image = cv2.imread(\"tests/assets/template_finder/stash_slots.png\")\n    empty = cv2.imread(\"tests/assets/template_finder/stash_slot_empty.png\")\n    slash = cv2.imread(\"tests/assets/template_finder/stash_slot_slash.png\")\n    result = src.template_finder.search([empty, slash], image, threshold=0.98, mode=\"all\")\n    matches = result.matches\n    assert len(matches) == 4\n"
  },
  {
    "path": "tests/ui/__init__.py",
    "content": ""
  },
  {
    "path": "tests/ui/char_inventory_test.py",
    "content": "import cv2\nimport pytest\n\nfrom src.cam import Cam\nfrom src.config import BASE_DIR\nfrom src.ui.char_inventory import CharInventory\n\nBASE_PATH = BASE_DIR / \"tests/assets/ui\"\n\n\n@pytest.mark.parametrize(\n    (\"img_res\", \"input_img\"),\n    [\n        ((1920, 1080), f\"{BASE_PATH}/char_inv_open_1080p.png\"),\n        ((2560, 1440), f\"{BASE_PATH}/char_inv_open_1440p.png\"),\n        ((3440, 1440), f\"{BASE_PATH}/char_inv_open_1440p_wide.png\"),\n        ((5120, 1440), f\"{BASE_PATH}/char_inv_open_1440p_ultra_wide.png\"),\n        ((3840, 2160), f\"{BASE_PATH}/char_inv_open_2160p.png\"),\n    ],\n)\ndef test_char_inventory(img_res, input_img):\n    Cam().update_window_pos(0, 0, *img_res)\n    img = cv2.imread(input_img)\n    inv = CharInventory()\n    flag = inv.is_open(img)\n    assert flag\n\n\n@pytest.mark.parametrize(\n    (\"img_res\", \"input_img\", \"occupied\", \"junk\", \"fav\"),\n    [\n        ((1920, 1080), f\"{BASE_PATH}/char_inventory_fav_junk_1080p.png\", 13, 2, 7),\n        ((1920, 1080), f\"{BASE_PATH}/char_inventory_fav_junk_1080p_2.png\", 31, 18, 3),\n        ((3440, 1440), f\"{BASE_PATH}/char_inv_open_1440p_wide.png\", 12, 0, 0),\n    ],\n)\ndef test_get_item_slots(img_res, input_img, occupied, junk, fav):\n    Cam().update_window_pos(0, 0, *img_res)\n    img = cv2.imread(input_img)\n    inv = CharInventory()\n    occupied_slots, is_open = inv.get_item_slots(img)\n    num_junk = 0\n    num_fav = 0\n    for slot in occupied_slots:\n        if slot.is_fav:\n            num_fav += 1\n            cv2.circle(img, slot.center, 5, (0, 255, 0), 4)\n        elif slot.is_junk:\n            num_junk += 1\n            cv2.circle(img, slot.center, 5, (0, 0, 255), 4)\n        else:\n            cv2.circle(img, slot.center, 5, (255, 0, 0), 4)\n    for slot in is_open:\n        cv2.circle(img, slot.center, 5, (255, 255, 0), 4)\n    if False:\n        cv2.imshow(\"char_inv\", img)\n        cv2.waitKey(0)\n    assert occupied == len(occupied_slots)\n    assert fav == num_fav\n    assert junk == num_junk\n"
  },
  {
    "path": "tests/ui/chest_test.py",
    "content": "import cv2\nimport pytest\n\nfrom src.cam import Cam\nfrom src.config import BASE_DIR\nfrom src.ui.stash import Stash\n\nBASE_PATH = BASE_DIR / \"tests/assets/ui\"\n\n\n@pytest.mark.parametrize((\"img_res\", \"input_img\"), [((3440, 1440), f\"{BASE_PATH}/chest_open_1440p_wide.png\")])\ndef test_chest(img_res, input_img):\n    Cam().update_window_pos(0, 0, *img_res)\n    img = cv2.imread(input_img)\n    inv = Stash()\n    flag = inv.is_open(img)\n    assert flag\n"
  },
  {
    "path": "tests/utils/__init__.py",
    "content": ""
  },
  {
    "path": "tests/utils/image_operations_test.py",
    "content": "import numpy as np\nimport pytest\n\nfrom src.utils.image_operations import (\n    ThresholdTypes,\n    alpha_to_mask,\n    color_filter,\n    create_mask,\n    crop,\n    mask_by_roi,\n    overlay_image,\n    threshold,\n)\n\n\ndef test_binary_threshold() -> None:\n    # Create a dummy 3-channel image\n    # Left half is filled with 40s (intensity less than threshold)\n    # Right half is filled with 200s (intensity higher than threshold)\n    img = np.zeros((100, 100, 3), dtype=np.uint8)\n    img[:, :50] = [40, 40, 40]\n    img[:, 50:] = [200, 200, 200]\n\n    # Apply binary thresholding with threshold value = 100\n    binary_thresh_img = threshold(img, ThresholdTypes.BINARY, threshold=100)\n\n    # Check that left half of image is black\n    assert np.all(binary_thresh_img[:, :50] == 0)\n\n    # Check that right half of image is white\n    assert np.all(binary_thresh_img[:, 50:] == 255)\n\n\ndef test_crop() -> None:\n    # Test with a valid ROI\n    img = np.zeros((10, 10))\n    roi = (2, 2, 6, 6)\n    cropped = crop(img, roi)\n    assert cropped.shape == (6, 6)\n\n    # Test with an invalid ROI\n    roi = (-1, -1, 11, 11)\n    cropped = crop(img, roi)\n    assert np.array_equal(cropped, img)\n\n\ndef test_mask_by_roi() -> None:\n    # Test with type \"regular\" and an image of zeros\n    # We create an image of zeros and a region of interest (ROI) in the middle\n    img = np.zeros((10, 10, 3), dtype=np.uint8)\n    roi = (2, 2, 5, 5)\n    # After masking, the image should remain full of zeros, since the ROI is pasted onto a black image\n    masked = mask_by_roi(img, roi, \"regular\")\n    # Therefore, the count of non-zero elements should be 0\n    assert np.count_nonzero(masked) == 0\n\n    # Test with type \"regular\" and image of ones\n    # We create an image of ones (multiplied by 255 to represent a white image in RGB)\n    img = np.ones((10, 10, 3), dtype=np.uint8) * 255\n    roi = (2, 2, 5, 5)\n    # After masking, only the ROI area should remain white\n    masked = mask_by_roi(img, roi, \"regular\")\n    # The number of pixels in the ROI is 5*5 = 25\n    # Each pixel has 3 channels (RGB), so the total number of white values should be 25 * 3 = 75\n    assert np.count_nonzero(masked) == 25 * 3\n\n    # Test with type \"inverse\" and an image of ones\n    # We create an image of ones (multiplied by 255 to represent a white image in RGB)\n    img = np.ones((10, 10, 3), dtype=np.uint8) * 255\n    roi = (2, 2, 5, 5)\n    # After masking, the ROI area in the image should be blackened out\n    masked = mask_by_roi(img, roi, \"inverse\")\n    # The total number of pixels is 10*10 = 100\n    # The number of pixels in the ROI is 5*5 = 25\n    # So the number of white pixels outside the ROI is 100 - 25 = 75\n    # Each pixel has 3 channels (RGB), so the total number of white values should be 75 * 3 = 225\n    assert np.count_nonzero(masked) == (100 - 25) * 3\n\n    # Test with type \"inverse\" and image of zeros\n    # We create an image of zeros (a black image in RGB)\n    img = np.zeros((10, 10, 3), dtype=np.uint8)\n    roi = (2, 2, 5, 5)\n    # After masking, the image should remain full of zeros, since the ROI is made black on the already black image\n    masked = mask_by_roi(img, roi, \"inverse\")\n    # Therefore, the count of non-zero elements should be 0\n    assert np.count_nonzero(masked) == 0\n\n    # Test with unrecognized type\n    img = np.ones((10, 10, 3), dtype=np.uint8) * 255\n    roi = (2, 2, 5, 5)\n    masked = mask_by_roi(img, roi, \"unrecognized\")\n    assert masked is None\n\n\ndef test_alpha_to_mask() -> None:\n    # Test with an image that has an alpha channel\n    img = np.zeros((10, 10, 4), dtype=np.uint8)\n    img[0, 0, 3] = 255\n    mask = alpha_to_mask(img)\n    assert mask is not None\n\n    # Test with an image that does not have an alpha channel\n    img = np.zeros((10, 10, 3), dtype=np.uint8)\n    assert alpha_to_mask(img) is None\n\n\ndef test_create_mask() -> None:\n    size = (10, 10)\n    roi = (2, 2, 6, 6)\n    mask = create_mask(size, roi)\n    assert np.count_nonzero(mask) == 100 - 36\n\n\n@pytest.fixture\ndef filter_img() -> np.ndarray:\n    return np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)\n\n\n@pytest.fixture\ndef color_range() -> list[np.ndarray]:\n    return [np.array([0, 0, 0]), np.array([180, 255, 255])]\n\n\ndef test_color_filter_mask_shape(filter_img: np.ndarray, color_range: list[np.ndarray]) -> None:\n    color_mask, _ = color_filter(filter_img, color_range, calc_filtered_img=False)\n    assert color_mask.shape == filter_img.shape[:2]\n\n\ndef test_color_filter_no_img(filter_img: np.ndarray, color_range: list[np.ndarray]) -> None:\n    _, img = color_filter(filter_img, color_range, calc_filtered_img=False)\n    assert img is None\n\n\ndef test_color_filter_with_img(filter_img: np.ndarray, color_range: list[np.ndarray]) -> None:\n    _, img = color_filter(filter_img, color_range, calc_filtered_img=True)\n    assert isinstance(img, np.ndarray)\n\n\ndef test_overlay_image() -> None:\n    # Create two sample images of size 10x10\n    image1 = np.zeros((10, 10, 3), dtype=np.uint8)\n    image2 = np.ones((10, 10, 3), dtype=np.uint8) * 255\n\n    # Define the offsets\n    x_offset = 5\n    y_offset = 5\n\n    # Call the function to overlay the images\n    combined_image = overlay_image(image1, image2, x_offset, y_offset)\n\n    # Check the dimensions of the combined image\n    assert combined_image.shape == (15, 15, 3)\n\n    # Check if the first image is properly placed\n    assert np.array_equal(combined_image[:10, :5], image1[:, :5])  # Check the non-overlapping part of image1\n\n    # Check if the second image is properly overlaid on top of the first one\n    assert np.array_equal(combined_image[5:15, 5:15], image2)  # Check the part where image2 is overlaid\n\n    # Check if the rest of the area is blank\n    assert np.all(combined_image[15:] == 0)\n    assert np.all(combined_image[:, 15:] == 0)\n"
  },
  {
    "path": "tests/utils/roi_operations_test.py",
    "content": "from src.utils.roi_operations import bounding_box, get_center, intersect, is_in_roi\n\n\ndef test_get_center() -> None:\n    # Test with a rectangle\n    roi = (2, 2, 6, 6)\n    center = get_center(roi)\n    assert center == (5, 5)\n\n\ndef test_intersect() -> None:\n    # Test with intersecting rectangles\n    rects = [(2, 2, 6, 6), (4, 4, 6, 6)]\n    intersection = intersect(rects)\n    assert intersection == (4, 4, 4, 4)\n\n    # Test with non-intersecting rectangles\n    rects = [(2, 2, 2, 2), (5, 5, 2, 2)]\n    intersection = intersect(rects)\n    assert intersection is None\n\n\ndef test_bounding_box() -> None:\n    # Test with rectangles\n    rects = [(2, 2, 2, 2), (4, 4, 2, 2)]\n    bounding = bounding_box(rects)\n    assert bounding == (2, 2, 4, 4)\n\n    # Test with coordinates\n    coords = [(2, 2), (6, 6)]\n    bounding = bounding_box(coords)\n    assert bounding == (2, 2, 4, 4)\n\n    # Test with a mix of coordinates and rectangles\n    mixed = [(2, 2), (4, 4, 2, 2)]\n    bounding = bounding_box(mixed)\n    assert bounding == (2, 2, 4, 4)\n\n    # Test with multiple inputs\n    bounding = bounding_box((0, 0, 2, 2), (4, 4))\n    assert bounding == (0, 0, 4, 4)\n\n    # Test with an invalid argument\n    invalid_arg = [(2, 2, 2, 2, 2)]\n    bounding = bounding_box(invalid_arg)\n    assert bounding is None\n\n\ndef test_is_coor_in_roi() -> None:\n    rectangle = (0, 0, 10, 10)\n\n    # Points inside the rectangle\n    for coor in [(5, 5), (0, 10), (10, 0), (10, 10)]:\n        assert is_in_roi(coor, rectangle), f\"Expected {coor} to be inside {rectangle}\"\n\n    # Points outside the rectangle\n    for coor in [(-1, 0), (0, -1), (11, 0), (0, 11)]:\n        assert not is_in_roi(coor, rectangle), f\"Expected {coor} to be outside {rectangle}\"\n"
  },
  {
    "path": "tts/install_dll.cmd",
    "content": "@echo off\nsetlocal\ncd /d \"%~dp0\"\n\nnet session >nul 2>&1\nif %errorlevel% neq 0 (\n    echo Requesting administrator access...\n    powershell -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process -FilePath '%~f0' -Verb RunAs\"\n    exit /b\n)\n\nset \"_self=%~f0\"\nset \"_temp_ps1=%TEMP%\\install_dll_%RANDOM%%RANDOM%.ps1\"\n\npowershell -NoProfile -ExecutionPolicy Bypass -Command ^\n  \"$marker = '#===POWERSHELL==='; $lines = Get-Content -LiteralPath '%_self%'; $index = [Array]::IndexOf($lines, $marker); if ($index -lt 0) { Write-Error 'Embedded PowerShell payload not found.'; exit 1 }; Set-Content -LiteralPath '%_temp_ps1%' -Value $lines[($index + 1)..($lines.Length - 1)] -Encoding UTF8\"\n\nif errorlevel 1 (\n    echo Failed to extract the embedded PowerShell payload.\n    pause\n    exit /b 1\n)\n\npowershell -NoProfile -ExecutionPolicy Bypass -NoExit -File \"%_temp_ps1%\" -script_root \"%~dp0\" %*\nset \"exit_code=%errorlevel%\"\ndel \"%_temp_ps1%\" >nul 2>&1\nexit /b %exit_code%\n\n#===POWERSHELL===\nparam(\n    [string]$d4_path,\n\n    [string]$signtool_path,\n\n    [string]$script_root\n)\n\n$script:StepNumber = 0\n$script:InstallerRoot = if ([string]::IsNullOrWhiteSpace($script_root)) {\n    $PSScriptRoot\n}\nelse {\n    $script_root.Trim().Trim('\"').TrimEnd('\\')\n}\n\nfunction Write-UiRule {\n    Write-Host (\"=\" * 72) -ForegroundColor DarkGray\n}\n\nfunction Write-UiBanner {\n    param(\n        [string]$Title,\n        [string]$Subtitle\n    )\n\n    Write-Host \"\"\n    Write-UiRule\n    Write-Host (\"  \" + $Title) -ForegroundColor Cyan\n    if ($Subtitle) {\n        Write-Host (\"  \" + $Subtitle) -ForegroundColor Gray\n    }\n    Write-UiRule\n}\n\nfunction Start-Step {\n    param(\n        [string]$Title\n    )\n\n    $script:StepNumber += 1\n    Write-Host \"\"\n    Write-Host (\"[{0}] {1}\" -f $script:StepNumber, $Title) -ForegroundColor Yellow\n}\n\nfunction Write-InfoLine {\n    param(\n        [string]$Message\n    )\n\n    Write-Host (\"    \" + $Message) -ForegroundColor Gray\n}\n\nfunction Write-OkLine {\n    param(\n        [string]$Message\n    )\n\n    Write-Host (\"  OK  \" + $Message) -ForegroundColor Green\n}\n\nfunction Write-WarnLine {\n    param(\n        [string]$Message\n    )\n\n    Write-Host (\"  !   \" + $Message) -ForegroundColor Yellow\n}\n\nfunction Stop-WithError {\n    param(\n        [string]$Message,\n        [int]$ExitCode = 1\n    )\n\n    Write-Host \"\"\n    Write-Host (\"  X   \" + $Message) -ForegroundColor Red\n    exit $ExitCode\n}\n\nfunction Resolve-D4InstallPath {\n    param(\n        [string]$ProvidedPath\n    )\n\n    if ([string]::IsNullOrWhiteSpace($ProvidedPath)) {\n        return $null\n    }\n\n    if (-not (Test-Path $ProvidedPath -PathType Container)) {\n        Write-WarnLine \"The Diablo IV folder path does not exist: $ProvidedPath\"\n        return $null\n    }\n\n    $resolvedPath = (Resolve-Path $ProvidedPath).Path\n    $diabloExePath = Join-Path $resolvedPath \"Diablo IV.exe\"\n    if (-not (Test-Path $diabloExePath -PathType Leaf)) {\n        Write-WarnLine \"Diablo IV.exe was not found in: $resolvedPath\"\n        return $null\n    }\n\n    return $resolvedPath\n}\n\nfunction Resolve-InstallerFilePath {\n    param(\n        [string[]]$RelativeCandidates,\n        [string]$Description\n    )\n\n    foreach ($candidate in $RelativeCandidates) {\n        $fullPath = Join-Path $script:InstallerRoot $candidate\n        if (Test-Path $fullPath -PathType Leaf) {\n            return (Resolve-Path $fullPath).Path\n        }\n    }\n\n    $searchedPaths = $RelativeCandidates | ForEach-Object { Join-Path $script:InstallerRoot $_ }\n    Stop-WithError \"$Description was not found. Checked: $($searchedPaths -join ', '). Re-extract the D4LF release zip and try again.\"\n}\n\nfunction Get-D4InstallPathFromRunningProcess {\n    try {\n        $diabloProcess = Get-Process -Name \"Diablo IV\" -ErrorAction SilentlyContinue | Select-Object -First 1\n        if (-not $diabloProcess) {\n            return $null\n        }\n\n        $exePath = $diabloProcess.MainModule.FileName\n        if ([string]::IsNullOrWhiteSpace($exePath)) {\n            return $null\n        }\n\n        return Split-Path -Path $exePath -Parent\n    }\n    catch {\n        Write-WarnLine \"Diablo IV is running, but its install folder could not be read automatically.\"\n        return $null\n    }\n}\n\nfunction Get-D4Process {\n    return @(Get-Process -Name \"Diablo IV\" -ErrorAction SilentlyContinue)\n}\n\nfunction Stop-D4ProcessIfRunning {\n    $diabloProcesses = Get-D4Process\n    if (-not $diabloProcesses -or $diabloProcesses.Count -eq 0) {\n        return\n    }\n\n    Start-Step \"Closing Diablo IV\"\n    Write-WarnLine \"Diablo IV is currently running. It needs to be closed before saapi64.dll can be replaced.\"\n\n    try {\n        $diabloProcesses | Stop-Process -Force -ErrorAction Stop\n        Write-OkLine \"Closed Diablo IV.\"\n    }\n    catch {\n        Stop-WithError \"Unable to close Diablo IV automatically. Please close the game and run install_dll.cmd again.\"\n    }\n\n    $maxWaitSeconds = 15\n    for ($i = 0; $i -lt $maxWaitSeconds; $i++) {\n        Start-Sleep -Seconds 1\n        $remainingProcesses = Get-D4Process\n        if (-not $remainingProcesses -or $remainingProcesses.Count -eq 0) {\n            return\n        }\n    }\n\n    $remainingProcessText = (Get-D4Process | ForEach-Object { \"$($_.ProcessName) (PID $($_.Id))\" }) -join \", \"\n    if ([string]::IsNullOrWhiteSpace($remainingProcessText)) {\n        $remainingProcessText = \"unknown process instance\"\n    }\n\n    Write-WarnLine \"Diablo IV is still shutting down.\"\n    Write-InfoLine \"Remaining process: $remainingProcessText\"\n    Write-InfoLine \"Please wait a few seconds, make sure the game is fully closed, then run install_dll.cmd again.\"\n    Stop-WithError \"Diablo IV is still running and saapi64.dll cannot be replaced yet.\"\n}\n\nfunction Read-D4InstallPathInteractively {\n    param(\n        [string]$ProvidedPath\n    )\n\n    $resolvedProvidedPath = Resolve-D4InstallPath -ProvidedPath $ProvidedPath\n    if ($resolvedProvidedPath) {\n        return $resolvedProvidedPath\n    }\n\n    Start-Step \"Locating Diablo IV folder\"\n    Write-InfoLine \"To make installation easier, launch Diablo IV now.\"\n    Write-InfoLine \"If the game is already running, this helper will try to grab the folder automatically.\"\n\n    while ($true) {\n        $runningPath = Get-D4InstallPathFromRunningProcess\n        if ($runningPath) {\n            $resolvedRunningPath = Resolve-D4InstallPath -ProvidedPath $runningPath\n            if ($resolvedRunningPath) {\n                Write-OkLine \"Detected Diablo IV folder from the running game.\"\n                return $resolvedRunningPath\n            }\n        }\n\n        Write-Host \"\"\n        Write-Host \"  Open Diablo IV, then press Enter to try auto-detect again.\" -ForegroundColor Cyan\n        $manualPath = Read-Host \"  Or paste the folder that contains Diablo IV.exe\"\n\n        if (-not [string]::IsNullOrWhiteSpace($manualPath)) {\n            $resolvedManualPath = Resolve-D4InstallPath -ProvidedPath $manualPath\n            if ($resolvedManualPath) {\n                return $resolvedManualPath\n            }\n\n            Write-InfoLine \"Please open the game or paste the exact folder that contains Diablo IV.exe.\"\n            continue\n        }\n    }\n}\n\nfunction Install-LightweightSignTool {\n    param(\n        [string]$DestinationRoot\n    )\n\n    $version = \"10.0.28000.1-rtm\"\n    $packageDir = Join-Path $DestinationRoot \"Microsoft.Windows.SDK.BuildTools\\$version\"\n    $packageFile = Join-Path $packageDir \"Microsoft.Windows.SDK.BuildTools.$version.nupkg\"\n    $extractDir = Join-Path $packageDir \"sdk\"\n\n    New-Item -ItemType Directory -Force -Path $packageDir | Out-Null\n\n    if (-not (Test-Path $packageFile)) {\n        $packageUrl = \"https://www.nuget.org/api/v2/package/Microsoft.Windows.SDK.BuildTools/$version\"\n        Write-InfoLine \"Downloading official Microsoft BuildTools package...\"\n        Write-InfoLine $packageUrl\n        Invoke-WebRequest -Uri $packageUrl -OutFile $packageFile\n        Write-OkLine \"Package downloaded.\"\n    }\n    else {\n        Write-OkLine \"Lightweight package already downloaded.\"\n    }\n\n    if (-not (Test-Path $extractDir)) {\n        Write-InfoLine \"Extracting lightweight package...\"\n        Add-Type -AssemblyName System.IO.Compression.FileSystem\n        [System.IO.Compression.ZipFile]::ExtractToDirectory($packageFile, $extractDir)\n        Write-OkLine \"Package extracted to $extractDir\"\n    }\n    else {\n        Write-OkLine \"Lightweight package already extracted.\"\n    }\n\n    $signtool = Get-ChildItem -Path $extractDir -Recurse -Filter \"signtool.exe\" -ErrorAction SilentlyContinue |\n        Where-Object { $_.DirectoryName -match \"\\\\x64$\" } |\n        Select-Object -First 1\n\n    if (-not $signtool) {\n        $signtool = Get-ChildItem -Path $extractDir -Recurse -Filter \"signtool.exe\" -ErrorAction SilentlyContinue |\n            Select-Object -First 1\n    }\n\n    if (-not $signtool) {\n        Stop-WithError \"signtool.exe was not found after extracting the lightweight package.\"\n    }\n\n    return $signtool.FullName\n}\n\nfunction Resolve-SignTool {\n    param(\n        [string]$ProvidedPath\n    )\n\n    if ($ProvidedPath) {\n        if (-not (Test-Path $ProvidedPath -PathType Leaf)) {\n            Stop-WithError \"Provided signtool.exe path does not exist: $ProvidedPath\"\n        }\n\n        Write-OkLine \"Using provided signtool.exe path.\"\n        return (Resolve-Path $ProvidedPath).Path\n    }\n\n    $searchRoots = @(\n        (Join-Path $script:InstallerRoot \".tools\"),\n        \"C:\\Program Files (x86)\\Windows Kits\\10\\bin\\\"\n    )\n\n    foreach ($root in $searchRoots) {\n        if (-not (Test-Path $root)) {\n            continue\n        }\n\n        $allSigntools = Get-ChildItem -Path $root -Recurse -Filter \"signtool.exe\" -ErrorAction SilentlyContinue\n        $signtool = $allSigntools | Where-Object { $_.DirectoryName -match \"\\\\x64$\" } | Select-Object -First 1\n        if (-not $signtool) { $signtool = $allSigntools | Where-Object { $_.DirectoryName -match \"\\\\x86$\" } | Select-Object -First 1 }\n        if (-not $signtool) { $signtool = $allSigntools | Select-Object -First 1 }\n\n        if ($signtool) {\n            Write-OkLine \"Found signtool.exe in $root\"\n            return $signtool.FullName\n        }\n    }\n\n    $signtoolCommand = Get-Command \"signtool.exe\" -ErrorAction SilentlyContinue\n    if ($signtoolCommand) {\n        Write-OkLine \"Found signtool.exe on PATH.\"\n        return $signtoolCommand.Source\n    }\n\n    Write-WarnLine \"signtool.exe was not found locally. Switching to the lightweight Microsoft package.\"\n    return Install-LightweightSignTool -DestinationRoot (Join-Path $script:InstallerRoot \".tools\")\n}\n\nWrite-UiBanner -Title \"D4LF DLL Signing Helper\" -Subtitle \"Local signing for saapi64.dll\"\n\n# -- 0. Gather installer inputs ------------------------------------------------\n$d4_path = Read-D4InstallPathInteractively -ProvidedPath $d4_path\n$sourceDllPath = Resolve-InstallerFilePath -RelativeCandidates @(\"saapi64.dll\") -Description \"saapi64.dll\"\n\nWrite-InfoLine \"Diablo IV folder: $d4_path\"\nif ($signtool_path) {\n    Write-InfoLine \"Requested signtool.exe: $signtool_path\"\n}\n\n# -- 1. Validate and place the DLL ---------------------------------------------\nStart-Step \"Validating Diablo IV folder\"\nWrite-OkLine \"Found Diablo IV.exe in $d4_path\"\n\n$dllPath = Join-Path $d4_path \"saapi64.dll\"\n$sourceDllResolved = (Resolve-Path $sourceDllPath).Path\n$targetDllResolved = $dllPath\nif (Test-Path $dllPath -PathType Leaf) {\n    $targetDllResolved = (Resolve-Path $dllPath).Path\n}\n\nif ($sourceDllResolved -eq $targetDllResolved) {\n    Write-OkLine \"saapi64.dll is already in the Diablo IV folder.\"\n}\nelse {\n    Stop-D4ProcessIfRunning\n    Write-InfoLine \"Copying saapi64.dll into the Diablo IV folder...\"\n    try {\n        Copy-Item -Path $sourceDllPath -Destination $dllPath -Force -ErrorAction Stop\n    }\n    catch {\n        Stop-WithError \"Failed to copy saapi64.dll to $dllPath. $($_.Exception.Message)\"\n    }\n    Write-OkLine \"saapi64.dll copied to $dllPath\"\n}\n\n# -- 2. Create or reuse the signing certificate --------------------------------\nStart-Step \"Preparing code-signing certificate\"\n$cert = Get-ChildItem -Path \"Cert:\\CurrentUser\\My\" |\n    Where-Object { $_.Subject -eq \"CN=Cert for D4LF\" -and $_.HasPrivateKey } |\n    Select-Object -First 1\n\nif ($cert) {\n    Write-OkLine \"Certificate already exists: $($cert.Thumbprint)\"\n}\nelse {\n    Write-InfoLine \"Creating self-signed code-signing certificate...\"\n    $cert = New-SelfSignedCertificate `\n        -Type CodeSigningCert `\n        -Subject \"CN=Cert for D4LF\" `\n        -CertStoreLocation \"Cert:\\CurrentUser\\My\" `\n        -NotAfter (Get-Date).AddYears(10)\n    Write-OkLine \"Certificate created: $($cert.Thumbprint)\"\n}\n\nStart-Step \"Trusting the certificate for this Windows user\"\n$rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(\n    [System.Security.Cryptography.X509Certificates.StoreName]::Root,\n    [System.Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser\n)\n$rootStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)\n\n$alreadyTrusted = $rootStore.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint }\nif ($alreadyTrusted) {\n    Write-OkLine \"Certificate already trusted.\"\n}\nelse {\n    $rootStore.Add($cert)\n    Write-OkLine \"Certificate copied to Trusted Root.\"\n}\n$rootStore.Close()\n\n# -- 3. Locate signtool --------------------------------------------------------\nStart-Step \"Locating signtool.exe\"\n$signtool = Resolve-SignTool -ProvidedPath $signtool_path\nWrite-InfoLine \"Using signtool.exe at:\"\nWrite-InfoLine $signtool\n\n# -- 4. Sign the DLL and verify the result -------------------------------------\nStart-Step \"Signing saapi64.dll\"\nWrite-InfoLine \"Target DLL: $dllPath\"\n$sig = Get-AuthenticodeSignature -FilePath $dllPath\nif ($sig.Status -eq \"Valid\") {\n    Write-OkLine \"DLL is already signed and valid.\"\n    Write-Host \"\"\n    Write-UiRule\n    Write-Host \"  Ready to launch Diablo IV.\" -ForegroundColor Green\n    Write-UiRule\n    exit 0\n}\n\nWrite-InfoLine \"Running signtool...\"\n& $signtool sign /fd SHA256 /n \"Cert for D4LF\" $dllPath\n\nif ($LASTEXITCODE -ne 0) {\n    Stop-WithError \"signtool exited with code $LASTEXITCODE\" -ExitCode $LASTEXITCODE\n}\n\n$finalSig = Get-AuthenticodeSignature -FilePath $dllPath\nif ($finalSig.Status -ne \"Valid\") {\n    Stop-WithError \"Signing finished, but Windows still reports status '$($finalSig.Status)'.\"\n}\n\nWrite-OkLine \"DLL signed successfully.\"\nWrite-Host \"\"\nWrite-UiRule\nWrite-Host \"  Done. Diablo IV should now be able to load saapi64.dll.\" -ForegroundColor Green\nWrite-Host \"  Signature status: $($finalSig.Status)\" -ForegroundColor Gray\nWrite-UiRule\n"
  },
  {
    "path": "tts/saapi.cpp",
    "content": "#include \"saapi.h\"\n\n#include <tchar.h>\n\n#include <sstream>\n#include <string>\n#define WIN32_LEAN_AND_MEAN\n#include <windows.h>\n\nHANDLE hPipe;\n\nBOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {\n    switch (ul_reason_for_call) {\n        case DLL_PROCESS_ATTACH:\n            InitPipe();\n            SA_SayW(L\"CONNECTED\");\n            break;\n\n        case DLL_PROCESS_DETACH:\n            SA_SayW(L\"DISCONNECTED\");\n            break;\n\n        case DLL_THREAD_ATTACH:\n        case DLL_THREAD_DETACH:\n            break;\n    }\n    return TRUE;\n}\n\nvoid InitPipe() { hPipe = CreateFile(_T(\"\\\\\\\\.\\\\pipe\\\\d4lf\"), GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); }\n\nextern \"C\" bool SA_SayW(const wchar_t* str) {\n    if (!str) return false;\n\n    std::string narrowStr;\n    int size_needed = WideCharToMultiByte(CP_UTF8, 0, str, -1, nullptr, 0, nullptr, nullptr);\n    narrowStr.resize(size_needed);\n    WideCharToMultiByte(CP_UTF8, 0, str, -1, &narrowStr[0], size_needed, nullptr, nullptr);\n\n    DWORD bytesWritten = 0;\n    BOOL flg = WriteFile(hPipe, narrowStr.c_str(), static_cast<DWORD>(narrowStr.length()), &bytesWritten, NULL);\n    if (!flg) InitPipe();\n    return true;\n}\n\nextern \"C\" bool SA_BrlShowTextW(const wchar_t* str) { return true; }\n\nextern \"C\" bool SA_IsRunning() { return true; }\n\nextern \"C\" bool SA_StopAudio() { return true; }\n"
  },
  {
    "path": "tts/saapi.h",
    "content": "void InitPipe();\n\nextern \"C\" __declspec(dllexport) bool SA_SayW(const wchar_t* str);\nextern \"C\" __declspec(dllexport) bool SA_BrlShowTextW(const wchar_t* str);\nextern \"C\" __declspec(dllexport) bool SA_StopAudio();\nextern \"C\" __declspec(dllexport) bool SA_IsRunning();\n"
  },
  {
    "path": "tts/saapi.vcxproj",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<Project DefaultTargets=\"Build\" xmlns=\"http://schemas.microsoft.com/developer/msbuild/2003\">\n  <ItemGroup Label=\"ProjectConfigurations\">\n    <ProjectConfiguration Include=\"Release|x64\">\n      <Configuration>Release</Configuration>\n      <Platform>x64</Platform>\n    </ProjectConfiguration>\n  </ItemGroup>\n  <PropertyGroup Label=\"Globals\">\n    <VCProjectVersion>16.0</VCProjectVersion>\n    <Keyword>Win32Proj</Keyword>\n    <ProjectGuid>{175bbf6b-234c-41a2-8d0e-86070c0587f0}</ProjectGuid>\n    <RootNamespace>saapi</RootNamespace>\n    <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>\n  </PropertyGroup>\n  <Import Project=\"$(VCTargetsPath)\\Microsoft.Cpp.Default.props\" />\n  <PropertyGroup Condition=\"'$(Configuration)|$(Platform)'=='Release|x64'\" Label=\"Configuration\">\n    <ConfigurationType>DynamicLibrary</ConfigurationType>\n    <UseDebugLibraries>false</UseDebugLibraries>\n    <PlatformToolset>v143</PlatformToolset>\n    <WholeProgramOptimization>true</WholeProgramOptimization>\n    <CharacterSet>Unicode</CharacterSet>\n  </PropertyGroup>\n  <Import Project=\"$(VCTargetsPath)\\Microsoft.Cpp.props\" />\n  <ImportGroup Label=\"ExtensionSettings\">\n  </ImportGroup>\n  <ImportGroup Label=\"Shared\">\n  </ImportGroup>\n  <ImportGroup Label=\"PropertySheets\" Condition=\"'$(Configuration)|$(Platform)'=='Release|x64'\">\n    <Import Project=\"$(UserRootDir)\\Microsoft.Cpp.$(Platform).user.props\" Condition=\"exists('$(UserRootDir)\\Microsoft.Cpp.$(Platform).user.props')\" Label=\"LocalAppDataPlatform\" />\n  </ImportGroup>\n  <PropertyGroup Label=\"UserMacros\" />\n  <PropertyGroup Condition=\"'$(Configuration)|$(Platform)'=='Release|x64'\">\n    <TargetName>saapi64</TargetName>\n    <CopyCppRuntimeToOutputDir>false</CopyCppRuntimeToOutputDir>\n  </PropertyGroup>\n  <ItemDefinitionGroup Condition=\"'$(Configuration)|$(Platform)'=='Release|x64'\">\n    <ClCompile>\n      <WarningLevel>Level3</WarningLevel>\n      <FunctionLevelLinking>true</FunctionLevelLinking>\n      <IntrinsicFunctions>true</IntrinsicFunctions>\n      <SDLCheck>true</SDLCheck>\n      <PreprocessorDefinitions>NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>\n      <ConformanceMode>true</ConformanceMode>\n      <RuntimeLibrary>MultiThreaded</RuntimeLibrary>\n    </ClCompile>\n    <Link>\n      <SubSystem>Windows</SubSystem>\n      <EnableCOMDATFolding>true</EnableCOMDATFolding>\n      <OptimizeReferences>true</OptimizeReferences>\n      <EnableUAC>false</EnableUAC>\n    </Link>\n  </ItemDefinitionGroup>\n  <ItemGroup>\n    <ClInclude Include=\"saapi.h\" />\n  </ItemGroup>\n  <ItemGroup>\n    <ClCompile Include=\"saapi.cpp\" />\n  </ItemGroup>\n  <Import Project=\"$(VCTargetsPath)\\Microsoft.Cpp.targets\" />\n  <ImportGroup Label=\"ExtensionTargets\">\n  </ImportGroup>\n  <Target Name=\"PostBuild\" AfterTargets=\"Build\">\n    <Copy SourceFiles=\"$(TargetPath)\" DestinationFolder=\"$(SolutionDir)\" SkipUnchangedFiles=\"true\" />\n  </Target>\n</Project>\n"
  }
]