diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..bb6d07f8 --- /dev/null +++ b/.clang-format @@ -0,0 +1,7 @@ +--- +Language: Cpp +BasedOnStyle: Google +ColumnLimit: 140 +IndentWidth: 4 +TabWidth: 4 +UseTab: Never diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99a91237..5919f9f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: id: check_beta shell: powershell run: | - if ("${{ env.VERSION }}".Contains("beta")) { + if ($env:VERSION -like "*beta*" -or $env:VERSION -like "*alpha*") { echo "IS_BETA=true" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 } else { echo "IS_BETA=false" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 diff --git a/.gitignore b/.gitignore index 42093446..a0568dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ .pytest_cache/ .venv .vs/ +/tts/saapi +/tts/x64 __pycache__/ build/ config/bnip/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d83340d..b5c8f3b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,10 @@ +default_install_hook_types: [pre-push] repos: + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v19.1.2 + hooks: + - id: clang-format + files: \.(cpp|h)$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: @@ -18,7 +24,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.0 hooks: - id: ruff args: [--fix] diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index bf3fb6fb..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "configurations": [ - { - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "env": { - "PYDEVD_DISABLE_FILE_VALIDATION": "1" - }, - "justMyCode": true, - "name": "Python: Current File", - "program": "${file}", - "request": "launch", - "type": "debugpy" - }, - { - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "env": { - "PYDEVD_DISABLE_FILE_VALIDATION": "1" - }, - "justMyCode": true, - "module": "src.main", - "name": "Loot Filter", - "request": "launch", - "type": "debugpy" - }, - { - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "env": { - "PYDEVD_DISABLE_FILE_VALIDATION": "1" - }, - "justMyCode": true, - "module": "src.gui.qt_gui", - "name": "GUI", - "request": "launch", - "type": "debugpy" - }, - { - "args": [ - "../d4data", - "../Diablo4Companion" - ], - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "env": { - "PYDEVD_DISABLE_FILE_VALIDATION": "1" - }, - "justMyCode": true, - "name": "Gen assets", - "program": "src/tools/gen_data.py", - "request": "launch", - "type": "debugpy" - }, - { - "args": [], - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "env": { - "PYDEVD_DISABLE_FILE_VALIDATION": "1" - }, - "justMyCode": true, - "name": "Build .exe", - "program": "build.py", - "request": "launch", - "type": "debugpy" - } - ], - "version": "0.2.0" -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index f1842c13..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true - }, - "python.testing.pytestArgs": [ - "tests", - "-s" - ], - "python.testing.pytestEnabled": true, - "python.testing.unittestEnabled": false -} diff --git a/README.md b/README.md index 379ffc75..0af86276 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,38 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Due to your local windows settings, the tool might not be able to control the mouse. Just run the tool as admin and it should work. If you don't want to run it as admin, you can disable the mouse control in the params.ini by setting `vision_mode_only` to `true`. +- I am trying to import a trade search from diablo.trade and am getting an error that I need to install Chrome. + - diablo.trade has a setting enabled that will block all automated access, but we use a chrome-based browser to + circumvent this. For diablo.trade imports it is required that Chrome be installed. Regular build imports can + work with any browser. + +### TTS + +D4 uses a third-party TTS engine called Tolk. Tolk has a feature that allows custom third-party TTS DLLs to be loaded. +D4 automatically loads the DLL, which actually just sends the text to another application rather than reading it aloud. +This is similar to having a Braille TTS application for D4. + +To use TTS, you need to: + +- Copy `saapi64.dll` to your D4 directory +- Enable `Use Screen Reader` and `3rd Party Screen Reader` in D4 Accesibility settings +- Set `use_tts` in your `params.ini` to either `full` or `mixed` (or via the [GUI](#GUI)) + +#### Restrictions + +Currently, `use_tts` enables either a mixed mode where image processing is still used for item and affix position detection, +but TTS is used for everything text-related. This results in a small improvement in performance and a major improvement +in accuracy. Or a full mode where only TTS is used. This will be super fast but loses the overlay. + +**YOU NEED TO ENABLE ADVANCED TOOLTIP INFORMATION** + +**The following is currently supported using any form of tts:** + +- Full item detection for all wearable items, e.g. armor, weapons, and accessories. Both in `vision_mode` and +`loot_filter`. +- Basic item detection for all? other items, e.g. only type + rarity + +We might also discontinue the pure image processing mode and even mixed mode in the future, as TTS is easier to maintain. ### Configs @@ -75,17 +107,17 @@ The config folder in `C:/Users//.d4lf` contains: | [general] | Description | |---------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | profiles | A set of profiles separated by comma. d4lf will look for these yaml files in config/profiles and in C:/Users/WINDOWS_USER/.d4lf/profiles | -| keep_aspects | - `all`: Keep all legendary items
- `upgrade`: Keep all legendary items that upgrade your codex of power
- `none`: Keep no legendary items based on aspect (they are still filtered!) | +| browser | Which browser to use to get builds, please make sure you pick an installed browser: chrome, edge or firefox are currently supported. Note: Trade importing can only work with Chrome and ignores this setting. | +| 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 | +| full_dump | When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not | | handle_rares | - `filter`: Filter them based on your profiles
- `ignore`: Ignores all rares, vision mode shows them as blue and auto mode never junks or favorites them
- `junk`: Vision mode shows them always as red, auto mode always junks rares | | handle_uniques | How to handle uniques that do not match any filter. This property does not apply to filtered uniques. All mythics are favorited regardless of filter.
- `favorite`: Mark the unique as favorite and vision mode will show it as green (default)
- `ignore`: Do nothing with the unique and vision mode will show it as green
- `junk`: Mark any uniques that don't match any filters as junk and show as red in vision mode | -| 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 | -| 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 | -| move_to_inv_item_type
move_to_stash_item_type | Which types of items to move when using fast move functionality. Will only affect tabs defined in check_chest_tabs. You can select more than one option.
- `favorites`: Move favorites only
- `junk`: Move junk only
- `unmarked`: Only items not marked as favorite or junk
- `everything`: Move everything | +| keep_aspects | - `all`: Keep all legendary items
- `upgrade`: Keep all legendary items that upgrade your codex of power
- `none`: Keep no legendary items based on aspect (they are still filtered!) | | mark_as_favorite | Whether to favorite matched items or not. Defaults to true | | 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. | -| hidden_transparency | The overlay will become transparent after not hovering it for a while. This can be changed by specifying any value between [0, 1] with 0 being completely invisible and 1 completely visible | -| browser | Which browser to use to get builds, please make sure you pick an installed browser: chrome, edge or firefox are currently supported | -| full_dump | When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not | +| move_to_inv_item_type
move_to_stash_item_type | Which types of items to move when using fast move functionality. Will only affect tabs defined in check_chest_tabs. You can select more than one option.
- `favorites`: Move favorites only
- `junk`: Move junk only
- `unmarked`: Only items not marked as favorite or junk
- `everything`: Move everything | +| run_vision_mode_on_startup | If the vision mode should automatically start when starting d4lf. Otherwise has to be started manually with the vision button or the hotkey | +| use_tts | use TTS instead of OCR, see [TTS](#TTS) | | [char] | Description | |-----------|-----------------------------------| @@ -423,45 +455,46 @@ in [assets/lang/enUS/uniques.json](assets/lang/enUS/uniques.json). ### Python Setup -- You can use [miniconda](https://docs.conda.io/projects/miniconda/en/latest/) or just plain python. +- You can use plain python or something like [miniconda](https://docs.conda.io/projects/miniconda/en/latest/). -Conda setup: +Python setup (windows, linux venv activation differs): ```bash git clone https://github.com/aeon0/d4lf cd d4lf -conda env create -f environment.yml -conda activate d4lf +python -m venv venv +venv\Scripts\activate +python -m pip install -r requirements.txt python -m src.main ``` -Python setup (windows, linux venv activation differs): +Conda setup: ```bash git clone https://github.com/aeon0/d4lf cd d4lf -python -m venv venv -venv\Scripts\activate -python -m pip install -r requirements.txt +conda env create -f environment.yml +conda activate d4lf python -m src.main ``` ### Formatting & Linting -Ruff is used for linting and auto formatting. You can run it with: +Just use pre-commit. ```bash -ruff format +pre-commit install ``` +or directly via + ```bash -ruff check +pre-commit run -a ``` -Setup VS Code by using the ruff extension. Also turn on "trim trailing whitespaces" is VS Code settings. - ## Credits - Icon based of: [CarbotAnimations](https://www.youtube.com/carbotanimations/about) -- Some of the OCR code is originally from [@gleed](https://github.com/aliig). Good guy. +- Some of the OCR code is originally from [@gleed](https://github.com/aliig). Good guy - Names and textures for matching from [Blizzard](https://www.blizzard.com) +- Thanks to NekrosStratia for the initial idea and help with TTS mode diff --git a/build.py b/build.py index 564e5c37..0b7febbf 100644 --- a/build.py +++ b/build.py @@ -29,6 +29,7 @@ def clean_up(): def copy_additional_resources(release_dir: Path): shutil.copy("README.md", release_dir) + shutil.copy("tts/saapi64.dll", release_dir) shutil.copytree("assets", release_dir / "assets") diff --git a/requirements.txt b/requirements.txt index 863e925f..9ed1d2f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ ./dependencies/tesserocr-2.7.0-cp312-cp312-win_amd64.whl beautifultable colorama -coverage -cryptography httpx keyboard lxml @@ -10,7 +8,7 @@ mouse mss natsort numpy<2 -opencv-python==4.10.0.82 +opencv-python==4.10.0.84 Pillow pre-commit psutil diff --git a/src/__init__.py b/src/__init__.py index 99300f9a..0b3851a6 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ TP = concurrent.futures.ThreadPoolExecutor() -__version__ = "5.8.10" +__version__ = "6.0.0alpha1" diff --git a/src/config/__init__.py b/src/config/__init__.py index 2fa80ba9..936a1505 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -8,4 +8,6 @@ def get_base_dir(bundled: bool = False) -> Path: return Path(__file__).parent.parent.parent +AFFIX_COMPARISON_CHARS = 60 + BASE_DIR = get_base_dir(False) diff --git a/src/config/models.py b/src/config/models.py index 7ed94455..f62a081a 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -17,6 +17,7 @@ IS_HOTKEY_KEY = "is_hotkey" DEPRECATED_INI_KEYS = [ + "hidden_transparency", "import_build", "local_prefs_path", "move_item_type", @@ -29,17 +30,21 @@ class AspectFilterType(enum.StrEnum): upgrade = enum.auto() +class ComparisonType(enum.StrEnum): + larger = enum.auto() + smaller = enum.auto() + + class HandleRaresType(enum.StrEnum): filter = enum.auto() ignore = enum.auto() junk = enum.auto() -class MoveItemsType(enum.StrEnum): - everything = enum.auto() - favorites = enum.auto() - junk = enum.auto() - unmarked = enum.auto() +class ItemRefreshType(enum.StrEnum): + force_with_filter = enum.auto() + force_without_filter = enum.auto() + no_refresh = enum.auto() class LogLevels(enum.StrEnum): @@ -50,21 +55,23 @@ class LogLevels(enum.StrEnum): critical = enum.auto() +class MoveItemsType(enum.StrEnum): + everything = enum.auto() + favorites = enum.auto() + junk = enum.auto() + unmarked = enum.auto() + + class UnfilteredUniquesType(enum.StrEnum): favorite = enum.auto() ignore = enum.auto() junk = enum.auto() -class ComparisonType(enum.StrEnum): - larger = enum.auto() - smaller = enum.auto() - - -class ItemRefreshType(enum.StrEnum): - force_with_filter = enum.auto() - force_without_filter = enum.auto() - no_refresh = enum.auto() +class UseTTSType(enum.StrEnum): + full = enum.auto() + mixed = enum.auto() + off = enum.auto() class _IniBaseModel(BaseModel): @@ -247,15 +254,16 @@ class GeneralModel(_IniBaseModel): default=UnfilteredUniquesType.favorite, description="What should be done with uniques that do not match any profile. Mythics are always favorited. If mark_as_favorite is unchecked then uniques that match a profile will not be favorited.", ) - hidden_transparency: float = Field( - default=0.35, description="Transparency of the overlay when not hovering it (has a 3 second delay after hovering)" - ) keep_aspects: AspectFilterType = Field( default=AspectFilterType.upgrade, description="Whether to keep aspects that didn't match a filter" ) language: str = Field( default="enUS", description="Do not change. Only English is supported at this time", json_schema_extra={HIDE_FROM_GUI_KEY: "True"} ) + mark_as_favorite: bool = Field( + default=True, + description="Whether to favorite matched items or not", + ) minimum_overlay_font_size: int = Field( default=12, description="The minimum font size for the vision overlay, specifically the green text that shows which filter(s) are matching.", @@ -268,10 +276,6 @@ class GeneralModel(_IniBaseModel): default=[MoveItemsType.everything], description="When doing stash/inventory transfer, what types of items should be moved", ) - mark_as_favorite: bool = Field( - default=True, - description="Whether to favorite matched items or not", - ) profiles: list[str] = Field( default=[], description='Which filter profiles should be run. All .yaml files with "Aspects" and ' @@ -279,6 +283,7 @@ class GeneralModel(_IniBaseModel): "C:/Users/USERNAME/.d4lf/profiles/*.yaml", ) run_vision_mode_on_startup: bool = Field(default=True, description="Whether to run vision mode on startup or not") + use_tts: UseTTSType = Field(default=UseTTSType.off, description="Whether to use tts or not") @field_validator("check_chest_tabs", mode="before") def check_chest_tabs_index(cls, v: str) -> list[int]: @@ -302,12 +307,6 @@ def language_must_exist(cls, v: str) -> str: raise ValueError("language not supported") return v - @field_validator("hidden_transparency") - def transparency_in_range(cls, v: float) -> float: - if not 0 <= v <= 1: - raise ValueError("must be in [0, 1]") - return v - @field_validator("minimum_overlay_font_size") def font_size_in_range(cls, v: int) -> int: if not 10 <= v <= 20: diff --git a/src/dataloader.py b/src/dataloader.py index df1aeb75..873e6223 100644 --- a/src/dataloader.py +++ b/src/dataloader.py @@ -2,7 +2,7 @@ import logging import threading -from src.config import BASE_DIR +from src.config import AFFIX_COMPARISON_CHARS, BASE_DIR from src.config.loader import IniConfigLoader from src.item.data.item_type import ItemType @@ -67,5 +67,5 @@ def load_data(self): with open(BASE_DIR / f"assets/lang/{IniConfigLoader().general.language}/uniques.json", encoding="utf-8") as f: data = json.load(f) for key, d in data.items(): - self.aspect_unique_dict[key] = d["desc"][:60] + self.aspect_unique_dict[key] = d["desc"][:AFFIX_COMPARISON_CHARS] self.aspect_unique_num_idx[key] = d["num_idx"] diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 0a5fec26..0bf7250c 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -55,6 +55,10 @@ def import_maxroll(url: str): for item_id in active_profile["items"].values(): item_filter = ItemFilterModel() resolved_item = items[str(item_id)] + if (item_type := _find_item_type(mapping_data=mapping_data["items"], value=resolved_item["id"])) is None: + LOGGER.error("Couldn't find item type") + return + item_filter.itemType = [item_type] # magic/rare = 0, legendary = 1, unique = 2, mythic = 4 if resolved_item["id"] in mapping_data["items"] and mapping_data["items"][resolved_item["id"]]["magicType"] in [2, 4]: unique_model = UniqueModel() @@ -69,10 +73,6 @@ def import_maxroll(url: str): except Exception: LOGGER.exception(f"Unexpected error importing unique {unique_name}, please report a bug.") continue - if (item_type := _find_item_type(mapping_data=mapping_data["items"], value=resolved_item["id"])) is None: - LOGGER.error("Couldn't find item type") - return - item_filter.itemType = [item_type] item_filter.affixPool = [ AffixFilterCountModel( count=[ @@ -154,7 +154,7 @@ def _find_item_affixes(mapping_data: dict, item_affixes: dict) -> list[Affix]: else: if affix["attributes"][0]["param"] == -1460542966 and affix["attributes"][0]["id"] == 1033: attr_desc = "to core skills" - elif affix["attributes"][0]["param"] == -755407686 and affix["attributes"][0]["id"] == 1034: + elif affix["attributes"][0]["param"] == -755407686 and affix["attributes"][0]["id"] in [1034, 1091]: attr_desc = "to defensive skills" elif affix["attributes"][0]["param"] == 746476422 and affix["attributes"][0]["id"] == 1034: attr_desc = "to mastery skills" diff --git a/src/gui/qt_gui.py b/src/gui/qt_gui.py index b1aee2c5..57e6d27e 100644 --- a/src/gui/qt_gui.py +++ b/src/gui/qt_gui.py @@ -152,7 +152,8 @@ def on_worker_finished(): "https://diablo.trade/listings/items?exactPrice=true&itemType=equipment&price=50000000,999999999999&rarity=legendary&sold=true&sort=newest\n\n" "Please note that only legendary items are supported at the moment. The listing must also have an exact price.\n" "You can create such a filter by using the one above as a base and then add your custom data to it.\n" - f"It will then create a file based on the listings in: {IniConfigLoader().user_dir / "profiles"}" + f"It will then create a file based on the listings in: {IniConfigLoader().user_dir / "profiles"}\n\n" + "NOTE: You must have Chrome installed on your computer to use this feature." ) instructions_text.setReadOnly(True) font_metrics = instructions_text.fontMetrics() diff --git a/src/item/data/affix.py b/src/item/data/affix.py index b5e5da1e..ba30f9a1 100644 --- a/src/item/data/affix.py +++ b/src/item/data/affix.py @@ -12,13 +12,21 @@ class AffixType(enum.Enum): @dataclass class Affix: - name: str - type: AffixType = AffixType.normal loc: tuple[int, int] = None + max_value: float | None = None + min_value: float | None = None + name: str = "" text: str = "" + type: AffixType = AffixType.normal value: float | None = None def __eq__(self, other: "Affix") -> bool: if not isinstance(other, Affix): return False - return self.name == other.name and self.value == other.value and self.type == other.type + return ( + self.max_value == other.max_value + and self.min_value == other.min_value + and self.name == other.name + and self.value == other.value + and self.type == other.type + ) diff --git a/src/item/data/item_type.py b/src/item/data/item_type.py index 6a8c1d21..8aa4777d 100644 --- a/src/item/data/item_type.py +++ b/src/item/data/item_type.py @@ -32,7 +32,76 @@ class ItemType(Enum): Tome = "tome" Wand = "wand" # Custom Types + Compass = "compass" + Consumable = "consumable" + Gem = "gem" Incense = "incense" Material = "material" - Sigil = "sigil" + Rune = "rune" + Sigil = "nightmare sigil" TemperManual = "temper manual" + Tribute = "tribute" + + +def is_armor(item_type: ItemType) -> bool: + return item_type in [ + ItemType.Boots, + ItemType.ChestArmor, + ItemType.Gloves, + ItemType.Helm, + ItemType.Legs, + ] + + +def is_consumable(item_type: ItemType) -> bool: + return item_type in [ + ItemType.Consumable, + ItemType.Elixir, + ItemType.Incense, + ItemType.TemperManual, + ] + + +def is_mapping(item_type: ItemType) -> bool: + return item_type in [ + ItemType.Compass, + ItemType.Sigil, + ] + + +def is_jewelry(item_type: ItemType) -> bool: + return item_type in [ + ItemType.Amulet, + ItemType.Ring, + ] + + +def is_socketable(item_type: ItemType) -> bool: + return item_type in [ + ItemType.Gem, + ItemType.Rune, + ] + + +def is_weapon(item_type: ItemType) -> bool: + return item_type in [ + ItemType.Axe, + ItemType.Axe2H, + ItemType.Bow, + ItemType.Crossbow2H, + ItemType.Dagger, + ItemType.Focus, + ItemType.Glaive, + ItemType.Mace, + ItemType.Mace2H, + ItemType.OffHandTotem, + ItemType.Polearm, + ItemType.Quarterstaff, + ItemType.Scythe, + ItemType.Scythe2H, + ItemType.Staff, + ItemType.Sword, + ItemType.Sword2H, + ItemType.Tome, + ItemType.Wand, + ] diff --git a/src/item/descr/__init__.py b/src/item/descr/__init__.py index e69de29b..11067109 100644 --- a/src/item/descr/__init__.py +++ b/src/item/descr/__init__.py @@ -0,0 +1,2 @@ +def keep_letters_and_spaces(text): + return "".join(char for char in text if char.isalpha() or char.isspace()).strip().replace(" ", " ") diff --git a/src/item/descr/find_aspect.py b/src/item/descr/find_aspect.py index 7b49e8db..7e1fbe86 100644 --- a/src/item/descr/find_aspect.py +++ b/src/item/descr/find_aspect.py @@ -2,6 +2,7 @@ import numpy as np +from src.config import AFFIX_COMPARISON_CHARS from src.dataloader import Dataloader from src.item.data.aspect import Aspect from src.item.descr.text import clean_str, closest_match, find_number @@ -22,8 +23,7 @@ def find_aspect(img_item_descr: np.ndarray, aspect_bullet: TemplateMatch, do_pre concatenated_str = image_to_text(img_full_aspect, do_pre_proc=do_pre_proc).text.lower().replace("\n", " ") cleaned_str = clean_str(concatenated_str) - # Note: If you adjust the [:45] it also needs to be adapted in the dataloader - cleaned_str = cleaned_str[:60] + cleaned_str = cleaned_str[:AFFIX_COMPARISON_CHARS] found_key = closest_match(cleaned_str, Dataloader().aspect_unique_dict) num_idx = Dataloader().aspect_unique_num_idx diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py new file mode 100644 index 00000000..6190a67f --- /dev/null +++ b/src/item/descr/read_descr_tts.py @@ -0,0 +1,333 @@ +import copy +import logging +import re + +import numpy as np +import rapidfuzz + +import src.tts +from src import TP +from src.config import AFFIX_COMPARISON_CHARS +from src.dataloader import Dataloader +from src.item.data.affix import Affix, AffixType +from src.item.data.aspect import Aspect +from src.item.data.item_type import ItemType, is_armor, is_consumable, is_jewelry, is_mapping, is_socketable, is_weapon +from src.item.data.rarity import ItemRarity +from src.item.descr import keep_letters_and_spaces +from src.item.descr.text import clean_str, closest_match, find_number +from src.item.descr.texture import find_affix_bullets, find_aspect_bullet, find_seperator_short, find_seperators_long +from src.item.models import Item +from src.template_finder import TemplateMatch +from src.utils.window import screenshot + +_AFFIX_RE = re.compile( + r"(?P[0-9]+)[^0-9]+\[(?P[0-9]+) - (?P[0-9]+)\]|" + r"(?P[0-9]+\.[0-9]+).+?\[(?P[0-9]+\.[0-9]+) - (?P[0-9]+\.[0-9]+)\]|" + r"(?P[.0-9]+)[^0-9]+\[(?P[.0-9]+)\]|" + r".?![^\[\]]*[\[\]](?P\d+.?:\.\d+?)(?P[ ]*)|" + r"(?P\d+)(?![^\[]*\[).*", + re.DOTALL, +) + +_AFFIX_REPLACEMENTS = [ + "%", + "+", + ",", + "[+]", + "[x]", +] +LOGGER = logging.getLogger(__name__) + + +def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: + inherent_num = 0 + affixes_num = 3 if item.rarity == ItemRarity.Legendary else 4 + if is_weapon(item.item_type) or item.item_type in [ItemType.Amulet, ItemType.Boots]: + inherent_num = 1 + elif item.item_type in [ItemType.Ring]: + inherent_num = 2 + elif item.item_type in [ItemType.Shield]: + inherent_num = 4 + affixes = _get_affixes_from_tts_section(tts_section, item, inherent_num + affixes_num) + for i, affix_text in enumerate(affixes): + if i < inherent_num: + affix = _get_affix_from_text(affix_text) + affix.type = AffixType.inherent + item.inherent.append(affix) + elif i < inherent_num + affixes_num: + affix = _get_affix_from_text(affix_text) + item.affixes.append(affix) + else: + name = closest_match(clean_str(affix_text)[:AFFIX_COMPARISON_CHARS], Dataloader().aspect_unique_dict) + item.aspect = Aspect( + name=name, + text=affix_text, + value=find_number(affix_text), + ) + return item + + +def _add_affixes_from_tts_mixed( + tts_section: list[str], + item: Item, + inherent_affix_bullets: list[TemplateMatch], + affix_bullets: list[TemplateMatch], + aspect_bullet: TemplateMatch | None, +) -> Item: + affixes = _get_affixes_from_tts_section( + tts_section, + item, + len(inherent_affix_bullets) + + len([x for x in affix_bullets if any(x.name.startswith(s) for s in ["affix", "greater_affix", "rerolled"])]), + ) + for i, affix_text in enumerate(affixes): + if i < len(inherent_affix_bullets): + affix = _get_affix_from_text(affix_text) + affix.type = AffixType.inherent + affix.loc = inherent_affix_bullets[i].center + item.inherent.append(affix) + elif i < len(inherent_affix_bullets) + len(affix_bullets): + affix = _get_affix_from_text(affix_text) + affix.loc = affix_bullets[i - len(inherent_affix_bullets)].center + if affix_bullets[i - len(inherent_affix_bullets)].name.startswith("greater_affix"): + affix.type = AffixType.greater + elif affix_bullets[i - len(inherent_affix_bullets)].name.startswith("rerolled"): + affix.type = AffixType.rerolled + else: + affix.type = AffixType.normal + item.affixes.append(affix) + else: + name = closest_match(clean_str(affix_text)[:AFFIX_COMPARISON_CHARS], Dataloader().aspect_unique_dict) + item.aspect = Aspect( + name=name, + loc=aspect_bullet.center, + text=affix_text, + value=find_number(affix_text), + ) + return item + + +def _create_base_item_from_tts(tts_item: list[str]) -> Item | None: + if tts_item[0].startswith(src.tts.ItemIdentifiers.COMPASS.value): + return Item(rarity=ItemRarity.Common, item_type=ItemType.Compass) + if tts_item[0].startswith(src.tts.ItemIdentifiers.NIGHTMARE_SIGIL.value): + return Item(rarity=ItemRarity.Common, item_type=ItemType.Sigil) + if tts_item[0].startswith(src.tts.ItemIdentifiers.TRIBUTE.value): + item = Item(item_type=ItemType.Tribute) + search_string_split = tts_item[1].split(" ") + item.rarity = _get_item_rarity(search_string_split[0]) + return item + if tts_item[0].startswith(src.tts.ItemIdentifiers.WHISPERING_KEY.value): + return Item(item_type=ItemType.Consumable) + if any(tts_item[1].lower().endswith(x) for x in ["summoning"]): + return Item(item_type=ItemType.Material) + if any(tts_item[1].lower().endswith(x) for x in ["gem"]): + return Item(item_type=ItemType.Gem) + if "rune of" in tts_item[1].lower(): + item = Item(item_type=ItemType.Rune) + search_string_split = tts_item[1].lower().split(" rune of ") + item.rarity = _get_item_rarity(search_string_split[0]) + return item + item = Item() + if tts_item[1].lower().endswith("elixir"): + item.item_type = ItemType.Elixir + elif tts_item[1].lower().endswith("incense"): + item.item_type = ItemType.Incense + elif any(tts_item[1].lower().endswith(x) for x in ["consumable", "scroll"]): + item.item_type = ItemType.Consumable + if is_consumable(item.item_type): + search_string_split = tts_item[1].split(" ") + item.rarity = _get_item_rarity(search_string_split[0]) + return item + + search_string = tts_item[1].lower().replace("ancestral", "").strip() + search_string_split = search_string.split(" ") + item.rarity = _get_item_rarity(search_string_split[0]) + item.item_type = _get_item_type(" ".join(search_string_split[1:])) + for _i, line in enumerate(tts_item): + if "item power" in line.lower(): + item.power = int(find_number(line)) + break + return item + + +def _get_affixes_from_tts_section(tts_section: list[str], item: Item, length: int): + if item.rarity in [ItemRarity.Mythic, ItemRarity.Unique]: + length += 1 + dps = None + item_power = None + masterwork = None + start = 0 + for i, line in enumerate(tts_section): + if "masterwork" in line.lower(): + masterwork = i + if "item power" in line.lower(): + item_power = i + if "damage per second" in line.lower(): + dps = i + break # this will always be the last line of the 3 + base_value = masterwork if masterwork else item_power + if is_weapon(item.item_type): + start = dps + 2 + elif is_jewelry(item.item_type): + start = base_value + elif is_armor(item.item_type): + start = base_value + 1 + elif item.item_type == ItemType.Shield: + start = base_value + 3 + start += 1 + return tts_section[start : start + length] + + +def _get_affix_from_text(text: str) -> Affix: + result = Affix(text=text) + for x in _AFFIX_REPLACEMENTS: + text = text.replace(x, "") + matched_groups = {} + for match in _AFFIX_RE.finditer(text): + matched_groups = {name: value for name, value in match.groupdict().items() if value is not None} + if not matched_groups: + raise Exception(f"Could not match affix text: {text}") + for x in ["minvalue1", "minvalue2"]: + if matched_groups.get(x) is not None: + result.min_value = float(matched_groups[x]) + break + for x in ["maxvalue1", "maxvalue2"]: + if matched_groups.get(x) is not None: + result.max_value = float(matched_groups[x]) + break + for x in ["affixvalue1", "affixvalue2", "affixvalue3", "affixvalue4"]: + if matched_groups.get(x) is not None: + result.value = float(matched_groups[x]) + break + for x in ["greateraffix1", "greateraffix2"]: + if matched_groups.get(x) is not None: + result.type = AffixType.greater + if x == "greateraffix2": + result.value = float(matched_groups[x]) + break + if matched_groups.get("onlyvalue") is not None: + result.min_value = float(matched_groups.get("onlyvalue")) + result.max_value = float(matched_groups.get("onlyvalue")) + result.name = rapidfuzz.process.extractOne( + keep_letters_and_spaces(text), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance + )[0] + return result + + +def _get_item_rarity(data: str) -> ItemRarity | None: + res = rapidfuzz.process.extractOne(data, [rar.value for rar in ItemRarity], scorer=rapidfuzz.distance.Levenshtein.distance) + try: + return ItemRarity(res[0]) if res else None + except ValueError: + return None + + +def _get_item_type(data: str): + res = rapidfuzz.process.extractOne(data, [it.value for it in ItemType], scorer=rapidfuzz.distance.Levenshtein.distance) + try: + return ItemType(res[0]) if res else None + except ValueError: + return None + + +def _is_codex_upgrade(tts_section: list[str], item: Item) -> bool: + return any("upgrades an aspect in the codex of power on salvage" in line.lower() for line in tts_section) + + +def read_descr_mixed(img_item_descr: np.ndarray) -> Item | None: + tts_section = copy.copy(src.tts.LAST_ITEM) + if not tts_section: + return None + if (item := _create_base_item_from_tts(tts_section)) is None: + return None + if any( + [ + is_consumable(item.item_type), + is_mapping(item.item_type), + is_socketable(item.item_type), + item.item_type in [ItemType.Material, ItemType.Tribute], + ] + ): + return item + if all([not is_armor(item.item_type), not is_jewelry(item.item_type), not is_weapon(item.item_type)]): + return None + + if (sep_short_match := find_seperator_short(img_item_descr)) is None: + LOGGER.warning("Could not detect item_seperator_short.") + screenshot("failed_seperator_short", img=img_item_descr) + return None + futures = { + "sep_long": TP.submit(find_seperators_long, img_item_descr, sep_short_match), + "aspect_bullet": ( + TP.submit(find_aspect_bullet, img_item_descr, sep_short_match) + if item.rarity in [ItemRarity.Legendary, ItemRarity.Unique, ItemRarity.Mythic] + else None + ), + } + + affix_bullets = find_affix_bullets(img_item_descr, sep_short_match) + sep_long_match = futures["sep_long"].result() if futures["sep_long"] is not None else None + if sep_long_match is None: + # Split affix bullets into inherent and others + # ========================= + if item.rarity == ItemRarity.Mythic: # TODO should refactor so we either apply some logic OR we look for separator + # Just pick the last 4 matches as affixes + inherent_affix_bullets = affix_bullets[:-4] + affix_bullets = affix_bullets[-4:] + elif item.item_type in [ItemType.ChestArmor, ItemType.Helm, ItemType.Gloves, ItemType.Legs]: + inherent_affix_bullets = [] + elif item.item_type in [ItemType.Ring]: + inherent_affix_bullets = affix_bullets[:2] + affix_bullets = affix_bullets[2:] + elif item.item_type in [ItemType.Sigil]: + inherent_affix_bullets = affix_bullets[:3] + affix_bullets = affix_bullets[3:] + elif item.item_type in [ItemType.Shield]: + inherent_affix_bullets = affix_bullets[:4] + affix_bullets = affix_bullets[4:] + else: + # default for: Amulets, Boots, All Weapons + inherent_affix_bullets = affix_bullets[:1] + affix_bullets = affix_bullets[1:] + else: + # check how many affix bullets are below the long separator. if there are more below, then the long separator is the inherent separator. + inherent_sep = (None, 0) + for sep in sep_long_match: + candidate = (sep, len([True for bullet in affix_bullets if bullet.center[1] > sep.center[1]])) + if candidate[1] > inherent_sep[1]: + inherent_sep = candidate + if inherent_sep[0] is None: + number_inherents = 0 + else: + number_inherents = len([True for bullet in affix_bullets if bullet.center[1] < inherent_sep[0].center[1]]) + inherent_affix_bullets = affix_bullets[:number_inherents] + affix_bullets = affix_bullets[number_inherents:] + + item.codex_upgrade = _is_codex_upgrade(tts_section, item) + aspect_bullet = futures["aspect_bullet"].result() if futures["aspect_bullet"] is not None else None + return _add_affixes_from_tts_mixed(tts_section, item, inherent_affix_bullets, affix_bullets, aspect_bullet=aspect_bullet) + + +def read_descr() -> Item | None: + tts_section = copy.copy(src.tts.LAST_ITEM) + if not tts_section: + return None + if (item := _create_base_item_from_tts(tts_section)) is None: + return None + if any( + [ + is_consumable(item.item_type), + is_mapping(item.item_type), + is_socketable(item.item_type), + item.item_type in [ItemType.Material, ItemType.Tribute], + ] + ): + return item + if all([not is_armor(item.item_type), not is_jewelry(item.item_type), not is_weapon(item.item_type)]): + return None + if item.rarity not in [ItemRarity.Legendary, ItemRarity.Mythic, ItemRarity.Unique]: + return item + + item.codex_upgrade = _is_codex_upgrade(tts_section, item) + return _add_affixes_from_tts(tts_section, item) diff --git a/src/item/filter.py b/src/item/filter.py index 315f60e3..11018b00 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -33,16 +33,16 @@ @dataclass -class _MatchedFilter: +class MatchedFilter: profile: str matched_affixes: list[Affix] = field(default_factory=list) did_match_aspect: bool = False @dataclass -class _FilterResult: +class FilterResult: keep: bool - matched: list[_MatchedFilter] + matched: list[MatchedFilter] unique_aspect_in_profile = False all_unique_filters_are_aspects = False @@ -78,10 +78,10 @@ def __new__(cls): cls._instance = super().__new__(cls) return cls._instance - def _check_affixes(self, item: Item) -> _FilterResult: - res = _FilterResult(False, []) + def _check_affixes(self, item: Item) -> FilterResult: + res = FilterResult(False, []) if not self.affix_filters: - return _FilterResult(True, []) + return FilterResult(True, []) non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered] for profile_name, profile_filter in self.affix_filters.items(): for filter_item in profile_filter: @@ -113,27 +113,27 @@ def _check_affixes(self, item: Item) -> _FilterResult: all_matches = matched_affixes + matched_inherents LOGGER.info(f"Matched {profile_name}.Affixes.{filter_name}: {[x.name for x in all_matches]}") res.keep = True - res.matched.append(_MatchedFilter(f"{profile_name}.{filter_name}", all_matches)) + res.matched.append(MatchedFilter(f"{profile_name}.{filter_name}", all_matches)) return res @staticmethod - def _check_aspect(item: Item) -> _FilterResult: - res = _FilterResult(False, []) + def _check_aspect(item: Item) -> FilterResult: + res = FilterResult(False, []) if IniConfigLoader().general.keep_aspects == AspectFilterType.none or ( IniConfigLoader().general.keep_aspects == AspectFilterType.upgrade and not item.codex_upgrade ): return res LOGGER.info("Matched Aspects that updates codex") res.keep = True - res.matched.append(_MatchedFilter("Aspects", did_match_aspect=True)) + res.matched.append(MatchedFilter("Aspects", did_match_aspect=True)) return res - def _check_sigil(self, item: Item) -> _FilterResult: - res = _FilterResult(False, []) + def _check_sigil(self, item: Item) -> FilterResult: + res = FilterResult(False, []) if not self.sigil_filters.items(): LOGGER.info("Matched Sigils") res.keep = True - res.matched.append(_MatchedFilter("default")) + res.matched.append(MatchedFilter("default")) for profile_name, profile_filter in self.sigil_filters.items(): # check item power if not self._match_item_power(max_power=profile_filter.maxTier, min_power=profile_filter.minTier, item_power=item.power): @@ -164,15 +164,15 @@ def _check_sigil(self, item: Item) -> _FilterResult: continue LOGGER.info(f"Matched {profile_name}.Sigils") res.keep = True - res.matched.append(_MatchedFilter(f"{profile_name}")) + res.matched.append(MatchedFilter(f"{profile_name}")) return res - def _check_unique_item(self, item: Item) -> _FilterResult: - res = _FilterResult(False, []) + def _check_unique_item(self, item: Item) -> FilterResult: + res = FilterResult(False, []) all_filters_are_aspect = True if not self.unique_filters: keep = IniConfigLoader().general.handle_uniques != UnfilteredUniquesType.junk or item.rarity == ItemRarity.Mythic - return _FilterResult(keep, []) + return FilterResult(keep, []) for profile_name, profile_filter in self.unique_filters.items(): for filter_item in profile_filter: if not filter_item.aspect: @@ -202,7 +202,7 @@ def _check_unique_item(self, item: Item) -> _FilterResult: matched_full_name = f"{profile_name}.{item.aspect.name}" if filter_item.profileAlias: matched_full_name = f"{filter_item.profileAlias}.{item.aspect.name}" - res.matched.append(_MatchedFilter(matched_full_name, did_match_aspect=True)) + res.matched.append(MatchedFilter(matched_full_name, did_match_aspect=True)) res.all_unique_filters_are_aspects = all_filters_are_aspect # Always keep mythics no matter what @@ -348,11 +348,11 @@ def load_files(self): sys.exit(1) self.last_loaded = time.time() - def should_keep(self, item: Item) -> _FilterResult: + def should_keep(self, item: Item) -> FilterResult: if not self.files_loaded or self._did_files_change(): self.load_files() - res = _FilterResult(False, []) + res = FilterResult(False, []) if item.item_type is None or item.power is None: return res diff --git a/src/main.py b/src/main.py index 1217bff1..c9d49c4f 100644 --- a/src/main.py +++ b/src/main.py @@ -4,20 +4,19 @@ import time import traceback -import keyboard from beautifultable import BeautifulTable from PIL import Image # noqa # Note: Somehow needed, otherwise the binary has an issue with tesserocr import src.logger -from src import __version__ +from src import __version__, tts from src.cam import Cam from src.config.loader import IniConfigLoader -from src.config.models import ItemRefreshType +from src.config.models import UseTTSType from src.gui.qt_gui import start_gui from src.item.filter import Filter from src.logger import LOG_DIR from src.overlay import Overlay -from src.utils.process_handler import safe_exit +from src.scripts.handler import ScriptHandler from src.utils.window import WindowSpec, start_detecting_window LOGGER = logging.getLogger(__name__) @@ -55,22 +54,11 @@ def main(): while not Cam().is_offset_set(): time.sleep(0.2) - overlay = None + ScriptHandler() - keyboard.add_hotkey(IniConfigLoader().advanced_options.run_scripts, lambda: overlay.run_scripts() if overlay is not None else None) - keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: safe_exit()) - if not IniConfigLoader().advanced_options.vision_mode_only: - keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: overlay.filter_items() if overlay is not None else None) - keyboard.add_hotkey( - IniConfigLoader().advanced_options.run_filter_force_refresh, - lambda: overlay.filter_items(ItemRefreshType.force_with_filter) if overlay is not None else None, - ) - keyboard.add_hotkey( - IniConfigLoader().advanced_options.force_refresh_only, - lambda: overlay.filter_items(ItemRefreshType.force_without_filter) if overlay is not None else None, - ) - keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_inv, lambda: overlay.move_items_to_inventory()) - keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_chest, lambda: overlay.move_items_to_stash()) + if IniConfigLoader().general.use_tts in [UseTTSType.full, UseTTSType.mixed]: + LOGGER.debug(f"TTS mode: {IniConfigLoader().general.use_tts.value}") + tts.start_connection() overlay = Overlay() overlay.run() diff --git a/src/overlay.py b/src/overlay.py index 6eb3deff..c5f7749a 100644 --- a/src/overlay.py +++ b/src/overlay.py @@ -1,225 +1,22 @@ -import ctypes import logging import threading import tkinter as tk -import typing - -from src.cam import Cam -from src.config.loader import IniConfigLoader -from src.config.models import ItemRefreshType -from src.config.ui import ResManager -from src.loot_filter import run_loot_filter -from src.loot_mover import move_items_to_inventory, move_items_to_stash -from src.scripts.vision_mode import vision_mode -from src.utils.process_handler import kill_thread -from src.utils.window import WindowSpec, move_window_to_foreground LOGGER = logging.getLogger(__name__) LOCK = threading.Lock() -class TextLogHandler(logging.Handler): - def __init__(self, text): - logging.Handler.__init__(self) - self.text = text - self.text.tag_configure("wrapindent", lmargin2=60) - - def emit(self, record): - log_entry = self.format(record) - padded_text = " " * 1 + log_entry + " \n" * 1 - self.text.insert(tk.END, padded_text, "wrapindent") - self.text.yview(tk.END) # Auto-scroll to the end - - class Overlay: def __init__(self): - self.loot_interaction_thread = None - self.script_threads = [] - self.is_minimized = True self.root = tk.Tk() - self.root.title("LootFilter Overlay") - self.root.attributes("-alpha", 0.94) - self.hide_id = self.root.after(8000, lambda: self.root.attributes("-alpha", IniConfigLoader().general.hidden_transparency)) self.root.overrideredirect(True) - self.root.wm_attributes("-topmost", True) - - self.screen_width = Cam().window_roi["width"] - self.screen_height = Cam().window_roi["height"] - self.initial_height = int(Cam().window_roi["height"] * 0.03) - self.initial_width = int(self.screen_width * 0.068) - self.maximized_height = int(self.initial_height * 3.4) - self.maximized_width = int(self.initial_width * 5) - - self.screen_off_x = Cam().window_roi["left"] - self.screen_off_y = Cam().window_roi["top"] - self.canvas = tk.Canvas(self.root, bg="black", height=self.initial_height, width=self.initial_width, highlightthickness=0) - self.root.geometry( - f"{self.initial_width}x{self.initial_height}+{self.screen_width // 2 - self.initial_width // 2 + self.screen_off_x}+{self.screen_height - self.initial_height + self.screen_off_y}" - ) - self.canvas.pack() - self.root.bind("", self.show_canvas) - self.root.bind("", self.hide_canvas) - - self.toggle_button = tk.Button(self.root, text="max", bg="#222222", fg="#555555", borderwidth=0, command=self.toggle_size) - self.canvas.create_window(int(self.initial_width * 0.19), self.initial_height // 2, window=self.toggle_button) - - self.start_scripts_button = tk.Button(self.root, text="vision", bg="#222222", fg="#555555", borderwidth=0, command=self.run_scripts) - - if not IniConfigLoader().advanced_options.vision_mode_only: - self.filter_button = tk.Button(self.root, text="filter", bg="#222222", fg="#555555", borderwidth=0, command=self.filter_items) - self.canvas.create_window(int(self.initial_width * 0.48), self.initial_height // 2, window=self.filter_button) - - self.start_scripts_button = tk.Button(self.root, text="vision", bg="#222222", fg="#555555", borderwidth=0, command=self.run_scripts) - self.canvas.create_window(int(self.initial_width * 0.81), self.initial_height // 2, window=self.start_scripts_button) - - font_size = 8 - window_height = ResManager().pos.window_dimensions[1] - if window_height == 1440: - font_size = 9 - elif window_height > 1440: - font_size = 10 - self.terminal_text = tk.Text( - self.canvas, - bg="black", - fg="white", - highlightcolor="white", - highlightthickness=0, - selectbackground="#222222", - borderwidth=0, - font=("Courier New", font_size), - wrap="word", - ) - self.terminal_text.place( - relx=0, rely=0, relwidth=1, relheight=1 - (self.initial_height / self.maximized_height), y=self.initial_height - ) - - if IniConfigLoader().general.hidden_transparency == 0: - self.root.update() - hwnd = self.root.winfo_id() - style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) - ctypes.windll.user32.SetWindowLongW(hwnd, -20, style | 0x80000 | 0x20) - - # Setup the listbox logger handler - textlog_handler = TextLogHandler(self.terminal_text) - log_level = LOGGER.root.handlers[0].level if LOGGER.root.handlers else 0 - textlog_handler.setLevel(log_level) - LOGGER.root.addHandler(textlog_handler) - - if IniConfigLoader().general.run_vision_mode_on_startup: - self.run_scripts() - - def show_canvas(self, _): - # Cancel the pending hide if it exists - if self.hide_id: - self.root.after_cancel(self.hide_id) - self.hide_id = None - # Make the window visible - self.root.attributes("-alpha", 0.94) - - def hide_canvas(self, _): - # Reset the hide timer - if self.is_minimized: - if self.hide_id is not None: - self.root.after_cancel(self.hide_id) - self.hide_id = self.root.after(3000, lambda: self.root.attributes("-alpha", IniConfigLoader().general.hidden_transparency)) - - def toggle_size(self): - if not self.is_minimized: - self.canvas.config(height=self.initial_height, width=self.initial_width) - self.root.geometry( - f"{self.initial_width}x{self.initial_height}+{self.screen_width // 2 - self.initial_width // 2 + self.screen_off_x}+{self.screen_height - self.initial_height + self.screen_off_y}" - ) - else: - self.canvas.config(height=self.maximized_height, width=self.maximized_width) - self.root.geometry( - f"{self.maximized_width}x{self.maximized_height}+{self.screen_width // 2 - self.maximized_width // 2 + self.screen_off_x}+{self.screen_height - self.maximized_height + self.screen_off_y}" - ) - self.is_minimized = not self.is_minimized - if self.is_minimized: - self.hide_canvas(None) - self.toggle_button.config(text="max") - else: - self.show_canvas(None) - self.toggle_button.config(text="min") - win_spec = WindowSpec(IniConfigLoader().advanced_options.process_name) - move_window_to_foreground(win_spec) - - def filter_items(self, force_refresh=ItemRefreshType.no_refresh): - self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh,)) - - def move_items_to_inventory(self): - self._start_or_stop_loot_interaction_thread(move_items_to_inventory) - - def move_items_to_stash(self): - self._start_or_stop_loot_interaction_thread(move_items_to_stash) - - def _start_or_stop_loot_interaction_thread(self, loot_interaction_method: typing.Callable, method_args=()): - if LOCK.acquire(blocking=False): - try: - if self.loot_interaction_thread is not None: - LOGGER.info("Stopping filter or move process") - kill_thread(self.loot_interaction_thread) - self.loot_interaction_thread = None - self.filter_button.config(fg="#555555") - else: - if self.is_minimized: - self.toggle_size() - self.loot_interaction_thread = threading.Thread( - target=self._wrapper_run_loot_interaction_method, args=(loot_interaction_method, method_args), daemon=True - ) - self.loot_interaction_thread.start() - self.filter_button.config(fg="#006600") - finally: - LOCK.release() - else: - return - - def _wrapper_run_loot_interaction_method(self, loot_interaction_method: typing.Callable, method_args=()): - try: - # We will stop all scripts if they are currently running and restart them afterwards if needed - did_stop_scripts = False - if len(self.script_threads) > 0: - LOGGER.info("Stopping Scripts") - self.start_scripts_button.config(fg="#555555") - for script_thread in self.script_threads: - kill_thread(script_thread) - self.script_threads = [] - did_stop_scripts = True - - loot_interaction_method(*method_args) - - if did_stop_scripts: - self.run_scripts() - finally: - if not self.is_minimized: - self.toggle_size() - self.loot_interaction_thread = None - self.filter_button.config(fg="#555555") - - def run_scripts(self): - if LOCK.acquire(blocking=False): - try: - if len(self.script_threads) > 0: - LOGGER.info("Stopping Vision Mode") - self.start_scripts_button.config(fg="#555555") - for script_thread in self.script_threads: - kill_thread(script_thread) - self.script_threads = [] - else: - if not IniConfigLoader().advanced_options.scripts: - LOGGER.info("No scripts configured") - return - for name in IniConfigLoader().advanced_options.scripts: - if name == "vision_mode": - vision_mode_thread = threading.Thread(target=vision_mode, daemon=True) - vision_mode_thread.start() - self.script_threads.append(vision_mode_thread) - self.start_scripts_button.config(fg="#006600") - finally: - LOCK.release() - else: - return + self.root.attributes("-topmost", True) + self.root.attributes("-transparentcolor", "white") + self.root.attributes("-alpha", 1.0) + self.canvas = tk.Canvas(self.root, bg="white", highlightthickness=0) + self.canvas.pack(fill=tk.BOTH, expand=True) + self.canvas.config(height=self.root.winfo_screenheight(), width=self.root.winfo_screenwidth()) def run(self): self.root.mainloop() diff --git a/src/scripts/common.py b/src/scripts/common.py new file mode 100644 index 00000000..ff417278 --- /dev/null +++ b/src/scripts/common.py @@ -0,0 +1,45 @@ +import logging +import time + +import keyboard + +from src.cam import Cam +from src.utils.custom_mouse import mouse + +LOGGER = logging.getLogger(__name__) + + +def mark_as_junk(): + keyboard.send("space") + time.sleep(0.13) + + +def mark_as_favorite(): + LOGGER.info("Mark as favorite") + keyboard.send("space") + time.sleep(0.17) + keyboard.send("space") + time.sleep(0.13) + + +def reset_canvas(root, canvas): + canvas.delete("all") + canvas.config(height=0, width=0) + root.geometry("0x0+0+0") + root.update_idletasks() + root.update() + + +def reset_item_status(occupied, inv): + for item_slot in occupied: + if item_slot.is_fav: + inv.hover_item(item_slot) + keyboard.send("space") + if item_slot.is_junk: + inv.hover_item(item_slot) + keyboard.send("space") + time.sleep(0.13) + keyboard.send("space") + + if occupied: + mouse.move(*Cam().abs_window_to_monitor((0, 0))) diff --git a/src/scripts/handler.py b/src/scripts/handler.py new file mode 100644 index 00000000..3660d0c7 --- /dev/null +++ b/src/scripts/handler.py @@ -0,0 +1,151 @@ +import logging +import threading +import time +import typing + +import keyboard + +import src.item.descr.read_descr_tts +import src.logger +import src.scripts.loot_filter +import src.scripts.loot_filter_tts +import src.scripts.vision_mode +import src.scripts.vision_mode_tts +import src.tts +from src.cam import Cam +from src.config.loader import IniConfigLoader +from src.config.models import ItemRefreshType, UseTTSType +from src.loot_mover import move_items_to_inventory, move_items_to_stash +from src.ui.char_inventory import CharInventory +from src.ui.chest import Chest +from src.utils.custom_mouse import mouse +from src.utils.process_handler import kill_thread, safe_exit +from src.utils.window import screenshot + +LOGGER = logging.getLogger(__name__) + +LOCK = threading.Lock() + + +class ScriptHandler: + def __init__(self): + self.loot_interaction_thread = None + self.script_threads = [] + + self.setup_key_binds() + if IniConfigLoader().general.run_vision_mode_on_startup: + self.run_scripts() + + def setup_key_binds(self): + keyboard.add_hotkey(IniConfigLoader().advanced_options.run_scripts, lambda: self.run_scripts()) + keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: safe_exit()) + if not IniConfigLoader().advanced_options.vision_mode_only: + keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: self.filter_items()) + keyboard.add_hotkey( + IniConfigLoader().advanced_options.run_filter_force_refresh, + lambda: self.filter_items(ItemRefreshType.force_with_filter), + ) + keyboard.add_hotkey( + IniConfigLoader().advanced_options.force_refresh_only, + lambda: self.filter_items(ItemRefreshType.force_without_filter), + ) + keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_inv, lambda: self.move_items_to_inventory()) + keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_chest, lambda: self.move_items_to_stash()) + + def filter_items(self, force_refresh=ItemRefreshType.no_refresh): + if IniConfigLoader().general.use_tts in [UseTTSType.full, UseTTSType.mixed]: + self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, True)) + else: + self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, False)) + + def move_items_to_inventory(self): + self._start_or_stop_loot_interaction_thread(move_items_to_inventory) + + def move_items_to_stash(self): + self._start_or_stop_loot_interaction_thread(move_items_to_stash) + + def _start_or_stop_loot_interaction_thread(self, loot_interaction_method: typing.Callable, method_args=()): + if LOCK.acquire(blocking=False): + try: + if self.loot_interaction_thread is not None: + LOGGER.info("Stopping filter or move process") + kill_thread(self.loot_interaction_thread) + self.loot_interaction_thread = None + else: + self.loot_interaction_thread = threading.Thread( + target=self._wrapper_run_loot_interaction_method, args=(loot_interaction_method, method_args), daemon=True + ) + self.loot_interaction_thread.start() + finally: + LOCK.release() + else: + return + + def _wrapper_run_loot_interaction_method(self, loot_interaction_method: typing.Callable, method_args=()): + try: + # We will stop all scripts if they are currently running and restart them afterward if needed + did_stop_scripts = False + if len(self.script_threads) > 0: + LOGGER.info("Stopping Scripts") + for script_thread in self.script_threads: + kill_thread(script_thread) + self.script_threads = [] + did_stop_scripts = True + + loot_interaction_method(*method_args) + + if did_stop_scripts: + self.run_scripts() + finally: + self.loot_interaction_thread = None + + def run_scripts(self): + if LOCK.acquire(blocking=False): + try: + if len(self.script_threads) > 0: + LOGGER.info("Stopping Vision Mode") + for script_thread in self.script_threads: + kill_thread(script_thread) + self.script_threads = [] + else: + if not IniConfigLoader().advanced_options.scripts: + LOGGER.info("No scripts configured") + return + for name in IniConfigLoader().advanced_options.scripts: + if name == "vision_mode": + if IniConfigLoader().general.use_tts == UseTTSType.full: + vision_mode_thread = threading.Thread(target=src.scripts.vision_mode_tts.VisionMode().start, daemon=True) + else: + vision_mode_thread = threading.Thread(target=src.scripts.vision_mode.vision_mode, daemon=True) + vision_mode_thread.start() + self.script_threads.append(vision_mode_thread) + finally: + LOCK.release() + else: + return + + +def run_loot_filter(force_refresh: ItemRefreshType = ItemRefreshType.no_refresh, tts: bool = False): + LOGGER.info("Run Loot filter") + mouse.move(*Cam().abs_window_to_monitor((0, 0))) + check_items = src.scripts.loot_filter_tts.check_items if tts else src.scripts.loot_filter.check_items + + inv = CharInventory() + chest = Chest() + + if chest.is_open(): + for i in IniConfigLoader().general.check_chest_tabs: + chest.switch_to_tab(i) + time.sleep(0.3) + check_items(chest, force_refresh) + mouse.move(*Cam().abs_window_to_monitor((0, 0))) + time.sleep(0.3) + check_items(inv, force_refresh) + else: + if not inv.open(): + screenshot("inventory_not_open", img=Cam().grab()) + LOGGER.error("Inventory did not open up") + return + check_items(inv, force_refresh) + mouse.move(*Cam().abs_window_to_monitor((0, 0))) + LOGGER.info("Loot Filter done") diff --git a/src/loot_filter.py b/src/scripts/loot_filter.py similarity index 73% rename from src/loot_filter.py rename to src/scripts/loot_filter.py index 2397bba2..90eaaa94 100644 --- a/src/loot_filter.py +++ b/src/scripts/loot_filter.py @@ -1,8 +1,6 @@ import logging import time -import keyboard - from src.cam import Cam from src.config.loader import IniConfigLoader from src.config.models import HandleRaresType, ItemRefreshType, UnfilteredUniquesType @@ -11,10 +9,8 @@ from src.item.descr.read_descr import read_descr from src.item.filter import Filter from src.item.find_descr import find_descr -from src.ui.char_inventory import CharInventory -from src.ui.chest import Chest +from src.scripts.common import mark_as_favorite, mark_as_junk, reset_item_status from src.ui.inventory_base import InventoryBase -from src.utils.custom_mouse import mouse from src.utils.image_operations import compare_histograms from src.utils.window import screenshot @@ -52,7 +48,7 @@ def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): screenshot("failed_descr_detection", img=img) break inv.hover_item(item) - time.sleep(0.18) + time.sleep(0.2) img = Cam().grab() start_detect = time.time() found, rarity, cropped_descr, _ = find_descr(img, item.center) @@ -73,7 +69,7 @@ def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): item_descr = read_descr(rarity, cropped_descr, False) if item_descr is None: LOGGER.info("Retry item detection") - time.sleep(0.3) + time.sleep(0.2) found, rarity, cropped_descr, _ = find_descr(Cam().grab(), item.center) if found: item_descr = read_descr(rarity, cropped_descr) @@ -98,15 +94,13 @@ def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): LOGGER.info("Matched: Temper Manual") continue if rarity in [ItemRarity.Magic, ItemRarity.Common] and item_descr.item_type != ItemType.Sigil: - keyboard.send("space") - time.sleep(0.13) + mark_as_junk() continue if rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.ignore: LOGGER.info("Matched: Rare, ignore Item") continue if rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.junk: - keyboard.send("space") - time.sleep(0.13) + mark_as_junk() continue # Check if we want to keep the item @@ -120,71 +114,19 @@ def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): # property if item_descr.rarity == ItemRarity.Unique: if not res.keep: - _mark_as_junk() + mark_as_junk() elif res.keep: if res.all_unique_filters_are_aspects and not res.unique_aspect_in_profile: if IniConfigLoader().general.handle_uniques == UnfilteredUniquesType.favorite: - _mark_as_favorite() + mark_as_favorite() elif IniConfigLoader().general.mark_as_favorite: - _mark_as_favorite() + mark_as_favorite() else: if not res.keep: - _mark_as_junk() + mark_as_junk() elif ( res.keep and (matched_any_affixes or item_descr.rarity == ItemRarity.Mythic or item_descr.item_type == ItemType.Sigil) and IniConfigLoader().general.mark_as_favorite ): - _mark_as_favorite() - - -def _mark_as_junk(): - keyboard.send("space") - time.sleep(0.13) - - -def _mark_as_favorite(): - LOGGER.info("Mark as favorite") - keyboard.send("space") - time.sleep(0.17) - keyboard.send("space") - time.sleep(0.13) - - -def reset_item_status(occupied, inv): - for item_slot in occupied: - if item_slot.is_fav: - inv.hover_item(item_slot) - keyboard.send("space") - if item_slot.is_junk: - inv.hover_item(item_slot) - keyboard.send("space") - time.sleep(0.13) - keyboard.send("space") - - if occupied: - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - - -def run_loot_filter(force_refresh: ItemRefreshType = ItemRefreshType.no_refresh): - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - LOGGER.info("Run Loot filter") - inv = CharInventory() - chest = Chest() - - if chest.is_open(): - for i in IniConfigLoader().general.check_chest_tabs: - chest.switch_to_tab(i) - time.sleep(0.5) - check_items(chest, force_refresh) - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - time.sleep(0.5) - check_items(inv, force_refresh) - else: - if not inv.open(): - screenshot("inventory_not_open", img=Cam().grab()) - LOGGER.error("Inventory did not open up") - return - check_items(inv, force_refresh) - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - LOGGER.info("Loot Filter done") + mark_as_favorite() diff --git a/src/scripts/loot_filter_tts.py b/src/scripts/loot_filter_tts.py new file mode 100644 index 00000000..74f3638d --- /dev/null +++ b/src/scripts/loot_filter_tts.py @@ -0,0 +1,108 @@ +import logging +import time + +import src.item.descr.read_descr_tts +from src.cam import Cam +from src.config.loader import IniConfigLoader +from src.config.models import HandleRaresType, ItemRefreshType, UnfilteredUniquesType +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity +from src.item.filter import Filter +from src.scripts.common import mark_as_favorite, mark_as_junk, reset_item_status +from src.ui.inventory_base import InventoryBase +from src.utils.window import screenshot + +LOGGER = logging.getLogger(__name__) + + +def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): + occupied, _ = inv.get_item_slots() + + if force_refresh == ItemRefreshType.force_with_filter or force_refresh == ItemRefreshType.force_without_filter: + reset_item_status(occupied, inv) + occupied, _ = inv.get_item_slots() + + if force_refresh == ItemRefreshType.force_without_filter: + return + + num_fav = sum(1 for slot in occupied if slot.is_fav) + num_junk = sum(1 for slot in occupied if slot.is_junk) + LOGGER.info(f"Items: {len(occupied)} (favorite: {num_fav}, junk: {num_junk}) in {inv.menu_name}") + for item in occupied: + if item.is_junk or item.is_fav: + continue + inv.hover_item(item) + time.sleep(0.15) + img = Cam().grab() + item_descr = None + try: + item_descr = src.item.descr.read_descr_tts.read_descr() + LOGGER.debug(f"Parsed item based on TTS: {item_descr}") + except Exception: + screenshot("tts_error", img=img) + LOGGER.exception(f"Error in TTS read_descr. {src.tts.LAST_ITEM=}") + if item_descr is None: + LOGGER.info("Retry item detection") + time.sleep(0.3) + try: + item_descr = src.item.descr.read_descr_tts.read_descr() + LOGGER.debug(f"Parsed item based on TTS: {item_descr}") + except Exception: + screenshot("tts_error", img=img) + LOGGER.exception(f"Error in TTS read_descr. {src.tts.LAST_ITEM=}") + if item_descr is None: + continue + + # Hardcoded filters + if item_descr.rarity == ItemRarity.Common and item_descr.item_type == ItemType.Material: + LOGGER.info("Matched: Material") + continue + if item_descr.rarity == ItemRarity.Legendary and item_descr.item_type == ItemType.Material: + LOGGER.info("Matched: Extracted Aspect") + continue + if item_descr.item_type == ItemType.Elixir: + LOGGER.info("Matched: Elixir") + continue + if item_descr.item_type == ItemType.Incense: + LOGGER.info("Matched: Incense") + continue + if item_descr.item_type == ItemType.TemperManual: + LOGGER.info("Matched: Temper Manual") + continue + if item_descr.rarity in [ItemRarity.Magic, ItemRarity.Common] and item_descr.item_type != ItemType.Sigil: + mark_as_junk() + continue + if item_descr.rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.ignore: + LOGGER.info("Matched: Rare, ignore Item") + continue + if item_descr.rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.junk: + mark_as_junk() + continue + + # Check if we want to keep the item + start_filter = time.time() + res = Filter().should_keep(item_descr) + matched_any_affixes = len(res.matched) > 0 and len(res.matched[0].matched_affixes) > 0 + LOGGER.debug(f" Runtime (Filter): {time.time() - start_filter:.2f}s") + + # Uniques have special handling. If they have an aspect specifically called out by a profile they are treated + # like any other item. If not, and there are no non-aspect filters, then they are handled by the handle_uniques + # property + if item_descr.rarity == ItemRarity.Unique: + if not res.keep: + mark_as_junk() + elif res.keep: + if res.all_unique_filters_are_aspects and not res.unique_aspect_in_profile: + if IniConfigLoader().general.handle_uniques == UnfilteredUniquesType.favorite: + mark_as_favorite() + elif IniConfigLoader().general.mark_as_favorite: + mark_as_favorite() + else: + if not res.keep: + mark_as_junk() + elif ( + res.keep + and (matched_any_affixes or item_descr.rarity == ItemRarity.Mythic or item_descr.item_type == ItemType.Sigil) + and IniConfigLoader().general.mark_as_favorite + ): + mark_as_favorite() diff --git a/src/scripts/vision_mode.py b/src/scripts/vision_mode.py index 419b1f28..b332b653 100644 --- a/src/scripts/vision_mode.py +++ b/src/scripts/vision_mode.py @@ -2,26 +2,28 @@ import math import time import tkinter as tk -import traceback from tkinter.font import Font import numpy as np +import src.item.descr.read_descr_tts import src.logger +import src.tts from src.cam import Cam from src.config.loader import IniConfigLoader -from src.config.models import HandleRaresType +from src.config.models import HandleRaresType, UseTTSType from src.config.ui import ResManager -from src.item.data.item_type import ItemType +from src.item.data.item_type import ItemType, is_armor, is_consumable, is_jewelry, is_mapping, is_socketable, is_weapon from src.item.data.rarity import ItemRarity from src.item.descr.read_descr import read_descr from src.item.filter import Filter from src.item.find_descr import find_descr +from src.scripts.common import reset_canvas from src.ui.char_inventory import CharInventory from src.ui.chest import Chest from src.utils.custom_mouse import mouse -from src.utils.image_operations import compare_histograms, crop -from src.utils.ocr.read import image_to_text +from src.utils.image_operations import compare_histograms +from src.utils.window import screenshot LOGGER = logging.getLogger(__name__) @@ -84,20 +86,6 @@ def draw_text(canvas, text, color, previous_text_y, offset, canvas_center_x) -> return int(previous_text_y - offset - text_height) -def reset_canvas(root, canvas): - canvas.delete("all") - canvas.config(height=0, width=0) - root.geometry("0x0+0+0") - root.update_idletasks() - root.update() - - -def is_vendor_open(img: np.ndarray): - cropped = crop(img, ResManager().roi.vendor_text) - res = image_to_text(cropped, do_pre_proc=False) - return res.text.strip().lower() == "vendor" - - def create_signal_rect(canvas, w, thick, color): canvas.create_rectangle(0, 0, w, thick * 2, outline="", fill=color) steps = int((thick * 20) / 40) @@ -203,10 +191,18 @@ def vision_mode(): root.update() # Check if the item is a match based on our filters - match = True last_top_left_corner = top_left_corner last_center = item_center - item_descr = read_descr(rarity, cropped_descr, False) + item_descr = None + if IniConfigLoader().general.use_tts == UseTTSType.mixed: + try: + item_descr = src.item.descr.read_descr_tts.read_descr_mixed(cropped_descr) + LOGGER.debug(f"Parsed item based on TTS: {item_descr}") + except Exception: + screenshot("tts_error", img=cropped_descr) + LOGGER.exception(f"Error in TTS read_descr. {src.tts.LAST_ITEM=}") + else: + item_descr = read_descr(rarity, cropped_descr, False) if item_descr is None: last_center = None last_top_left_corner = None @@ -214,27 +210,28 @@ def vision_mode(): continue ignored_item = False - if item_descr.item_type == ItemType.Material: - LOGGER.info("Matched: Material") + if is_consumable(item_descr.item_type): + LOGGER.info("Matched: Consumable") ignored_item = True - elif item_descr.item_type == ItemType.Elixir: - LOGGER.info("Matched: Elixir") + if is_mapping(item_descr.item_type): + LOGGER.info("Matched: Mapping") ignored_item = True - elif item_descr.item_type == ItemType.Incense: - LOGGER.info("Matched: Incense") + if is_socketable(item_descr.item_type): + LOGGER.info("Matched: Socketable") ignored_item = True - elif item_descr.item_type == ItemType.TemperManual: - LOGGER.info("Matched: Temper Manual") + elif item_descr.item_type == ItemType.Tribute: + LOGGER.info("Matched: Tribute") + ignored_item = True + elif item_descr.item_type == ItemType.Material: + LOGGER.info("Matched: Material") ignored_item = True - elif rarity in [ItemRarity.Magic, ItemRarity.Common] and item_descr.item_type != ItemType.Sigil: - match = False - item_descr = None - elif rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.ignore: + if ( + item_descr.rarity == ItemRarity.Rare + and (is_armor(item_descr.item_type) or is_weapon(item_descr.item_type) or is_jewelry(item_descr.item_type)) + and IniConfigLoader().general.handle_rares in [HandleRaresType.ignore, HandleRaresType.junk] + ): LOGGER.info("Matched: Rare, ignore Item") ignored_item = True - elif rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.junk: - match = False - item_descr = None if ignored_item: create_signal_rect(canvas, w, thick, "#00b3b3") @@ -242,9 +239,15 @@ def vision_mode(): root.update() continue - if item_descr is not None: - res = Filter().should_keep(item_descr) - match = res.keep + if item_descr is None: + LOGGER.info("Unknown Item") + create_signal_rect(canvas, w, thick, "#ce7e00") + root.update_idletasks() + root.update() + continue + + res = Filter().should_keep(item_descr) + match = res.keep # Adapt colors based on config if match: @@ -277,20 +280,3 @@ def vision_mode(): except Exception: LOGGER.exception("Error in vision mode. Please create a bug report") time.sleep(1) - - -if __name__ == "__main__": - try: - from src.utils.window import WindowSpec, start_detecting_window - - src.logger.setup() - win_spec = WindowSpec(IniConfigLoader().advanced_options.process_name) - start_detecting_window(win_spec) - while not Cam().is_offset_set(): - time.sleep(0.2) - Filter().load_files() - vision_mode() - except Exception: - traceback.print_exc() - print("Press Enter to exit ...") - input() diff --git a/src/scripts/vision_mode_tts.py b/src/scripts/vision_mode_tts.py new file mode 100644 index 00000000..4a43bf9c --- /dev/null +++ b/src/scripts/vision_mode_tts.py @@ -0,0 +1,165 @@ +import logging +import queue +import tkinter as tk +from tkinter import font + +import src.item.descr.read_descr_tts +import src.logger +import src.tts +from src.cam import Cam +from src.config.helper import singleton +from src.config.loader import IniConfigLoader +from src.config.models import HandleRaresType +from src.config.ui import ResManager +from src.item.data.item_type import ItemType, is_armor, is_consumable, is_jewelry, is_mapping, is_socketable, is_weapon +from src.item.data.rarity import ItemRarity +from src.item.filter import Filter, MatchedFilter +from src.tts import Publisher +from src.utils.custom_mouse import mouse +from src.utils.window import screenshot + +LOGGER = logging.getLogger(__name__) + + +@singleton +class VisionMode: + def __init__(self): + self.root = tk.Tk() + self.root.overrideredirect(True) + self.root.attributes("-topmost", True) + self.root.attributes("-transparentcolor", "white") + self.root.attributes("-alpha", 1.0) + self.canvas = tk.Canvas(self.root, bg="white", highlightthickness=0) + self.canvas.pack(fill=tk.BOTH, expand=True) + self.canvas.config(height=self.root.winfo_screenheight(), width=self.root.winfo_screenwidth()) + self.textbox = tk.Text(self.root, bg="black", fg="black", wrap=tk.WORD, borderwidth=0, highlightthickness=0) + self.textbox.config(state=tk.DISABLED) + self.clear_timer_id = None + self.queue = queue.Queue() + self.draw_from_queue() + + def adjust_textbox_size(self): + self.textbox.config(state=tk.NORMAL) + self.textbox.update_idletasks() + text_content = self.textbox.get(1.0, tk.END) + line_count = text_content.count("\n") + + text_font = font.Font(font=self.textbox.tag_cget("colored", "font")) + line_height = text_font.metrics("linespace") + max_line_length = max(len(line) for line in text_content.splitlines()) + + width = max_line_length * text_font.measure("0") + height = (line_count + 1) * line_height + + mouse_pos = Cam().monitor_to_window(mouse.get_position()) + self.textbox.config(x=mouse_pos[0], y=mouse_pos[1], width=width // 9, height=(height // line_height) - 2) + + self.textbox.config(state=tk.DISABLED) + + def clear_textbox(self): + if hasattr(self, "textbox"): + self.textbox.destroy() + + def create_textbox(self): + self.clear_textbox() + + self.textbox = tk.Text(self.root, bg="black", wrap=tk.WORD, borderwidth=0, highlightthickness=0) + x, y = ResManager().resolution + self.textbox.place(x=x / 2, y=y / 5) + self.textbox.config(state=tk.DISABLED) + + def draw_from_queue(self): + try: + task = self.queue.get_nowait() + if task[0] == "text": + self.insert_colored_text(task[1], task[2]) + if task[0] == "clear": + self.clear_textbox() + except queue.Empty: + pass + + self.canvas.after(10, self.draw_from_queue) + + def insert_colored_text(self, text, color): + self.create_textbox() + self.textbox.config(state=tk.NORMAL) + self.textbox.insert(tk.END, text + "\n", "colored") + self.textbox.tag_configure("colored", foreground=color) + self.adjust_textbox_size() + self.refresh_clear_timer() + self.textbox.config(state=tk.DISABLED) + + def refresh_clear_timer(self): + if self.clear_timer_id is not None: + self.root.after_cancel(self.clear_timer_id) + + self.clear_timer_id = self.root.after(5000, self.clear_textbox) + + def request_clear(self): + self.queue.put(("clear",)) + + def request_draw(self, text, color): + self.queue.put(("text", text, color)) + + def on_tts(self, _): + try: + img = Cam().grab() + item_descr = None + try: + item_descr = src.item.descr.read_descr_tts.read_descr() + LOGGER.debug(f"Parsed item based on TTS: {item_descr}") + except Exception: + screenshot("tts_error", img=img) + LOGGER.exception(f"Error in TTS read_descr. {src.tts.LAST_ITEM=}") + if item_descr is None: + return None + + ignored_item = False + if is_consumable(item_descr.item_type): + LOGGER.info("Matched: Consumable") + ignored_item = True + if is_mapping(item_descr.item_type): + LOGGER.info("Matched: Mapping") + ignored_item = True + if is_socketable(item_descr.item_type): + LOGGER.info("Matched: Socketable") + ignored_item = True + elif item_descr.item_type == ItemType.Tribute: + LOGGER.info("Matched: Tribute") + ignored_item = True + elif item_descr.item_type == ItemType.Material: + LOGGER.info("Matched: Material") + ignored_item = True + if ( + item_descr.rarity == ItemRarity.Rare + and (is_armor(item_descr.item_type) or is_weapon(item_descr.item_type) or is_jewelry(item_descr.item_type)) + and IniConfigLoader().general.handle_rares in [HandleRaresType.ignore, HandleRaresType.junk] + ): + LOGGER.info("Matched: Rare, ignore Item") + ignored_item = True + + if ignored_item: + self.request_clear() + return None + + if item_descr is None: + LOGGER.info("Unknown Item") + return self.request_draw("Unknown item", "#ce7e00") + + res = Filter().should_keep(item_descr) + match = res.keep + + if match: + text = create_match_text(reversed(res.matched)) + return self.request_draw("\n".join(text), "#23fc5d") + self.request_clear() + except Exception: + LOGGER.exception("Error in vision mode. Please create a bug report") + + def start(self): + LOGGER.info("Starting Vision Filter") + Publisher().subscribe(self.on_tts) + + +def create_match_text(matches: list[MatchedFilter]): + return [f"{match.profile}\n" + "\n".join(f" - {ma.name}" for ma in match.matched_affixes) for match in matches] diff --git a/src/tts.py b/src/tts.py new file mode 100644 index 00000000..9dbfe9c4 --- /dev/null +++ b/src/tts.py @@ -0,0 +1,131 @@ +import enum +import logging +import queue +import re +import threading + +import win32file +import win32pipe + +from src.config.helper import singleton + +LAST_ITEM = [] +LOGGER = logging.getLogger(__name__) +_DATA_QUEUE = queue.Queue(maxsize=100) + + +class ItemIdentifiers(enum.Enum): + COMPASS = "Compass" + NIGHTMARE_SIGIL = "Nightmare Sigil" + TRIBUTE = "TRIBUTE OF" + WHISPERING_KEY = "WHISPERING KEY" + + +@singleton +class Publisher: + def __init__(self): + self._subscribers = set() + + def find_item(self) -> None: + local_cache = [] + while True: + data = fix_data(_DATA_QUEUE.get()) + local_cache.append(data) + if not filter_data and ( + any(word in data.lower() for word in ["mouse button"]) and (start := find_item_start(local_cache)) is not None + ): + global LAST_ITEM + LAST_ITEM = local_cache[start:] + LOGGER.debug(f"TTS Found: {LAST_ITEM}") + local_cache = [] + self.publish(LAST_ITEM) + + def publish(self, data): + for subscriber in self._subscribers: + subscriber(data) + + def subscribe(self, subscriber): + self._subscribers.add(subscriber) + + def unsubscribe(self, subscriber): + self._subscribers.remove(subscriber) + + +def create_pipe() -> int: + return win32pipe.CreateNamedPipe( + r"\\.\pipe\d4lf", + win32pipe.PIPE_ACCESS_INBOUND, + win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_WAIT, + 1, + 2048, + 10 * 2 ** (10 * 2), + 0, + None, + ) + + +def read_pipe() -> None: + while True: + handle = create_pipe() + LOGGER.debug("Waiting for TTS client to connect") + + win32pipe.ConnectNamedPipe(handle, None) + LOGGER.debug("TTS client connected") + + while True: + try: + # Block until data is available (assumes PIPE_WAIT) + win32file.ReadFile(handle, 0, None) + # Query message size + _, _, message_size = win32pipe.PeekNamedPipe(handle, 0) + # Read message + _, data = win32file.ReadFile(handle, message_size, None) + data = data.decode().replace("\x00", "") + if not data: + continue + if "DISCONNECTED" in data: + break + _DATA_QUEUE.put(data) + except Exception as e: + print(f"Error while reading data: {e}") + + win32file.CloseHandle(handle) + print("TTS client disconnected") + + +def find_item_start(data: list[str]) -> int | None: + ignored_words = ["COMPASS AFFIXES", "DUNGEON AFFIXES"] + + for index, item in reversed(list(enumerate(data))): + if any(ignored in item for ignored in ignored_words): + continue + + if any(item.startswith(x) for x in [y.value for y in ItemIdentifiers]): + return index + + cleaned_str = re.sub(r"[^A-Za-z]", "", item) + if len(cleaned_str) >= 3 and item.isupper(): + return index + + return None + + +def filter_data(data: str) -> True: + to_filter = ["Champions who earn the favor of"] + + return any(word in data for word in to_filter) + + +def fix_data(data: str) -> str: + to_remove = ["'", """, "[FAVORITED ITEM]. ", "ᅡᅠ", "(Spiritborn Only)", "[MARKED AS JUNK]. "] + + for item in to_remove: + data = data.replace(item, "") + + return data.strip() + + +def start_connection() -> None: + LOGGER.info("Starting tts listener") + threading.Thread(target=Publisher().find_item, daemon=True).start() + threading.Thread(target=read_pipe, daemon=True).start() diff --git a/src/ui/char_inventory.py b/src/ui/char_inventory.py index b8019b4d..79de0067 100644 --- a/src/ui/char_inventory.py +++ b/src/ui/char_inventory.py @@ -2,7 +2,6 @@ from src.config.ui import ResManager from src.template_finder import SearchArgs from src.ui.inventory_base import InventoryBase -from src.ui.menu import ToggleMethod class CharInventory(InventoryBase): @@ -13,7 +12,4 @@ def __init__(self): ref=["sort_icon", "sort_icon_hover"], threshold=0.8, roi=ResManager().roi.sort_icon, use_grayscale=False ) self.open_hotkey = IniConfigLoader().char.inventory - self.open_method = ToggleMethod.HOTKEY - self.close_hotkey = "esc" - self.close_method = ToggleMethod.HOTKEY self.delay = 1 # Needed as they added a "fad-in" for the items diff --git a/src/ui/chest.py b/src/ui/chest.py index 9e431215..0aa9eecb 100644 --- a/src/ui/chest.py +++ b/src/ui/chest.py @@ -5,7 +5,6 @@ from src.config.ui import ResManager from src.template_finder import SearchArgs from src.ui.inventory_base import InventoryBase -from src.ui.menu import ToggleMethod from src.utils.custom_mouse import mouse LOGGER = logging.getLogger(__name__) @@ -18,8 +17,6 @@ def __init__(self): self.is_open_search_args = SearchArgs( ref=["stash_menu_icon", "stash_menu_icon_medium"], threshold=0.8, roi="stash_menu_icon", use_grayscale=True ) - self.close_hotkey = "esc" - self.close_method = ToggleMethod.HOTKEY self.curr_tab = 0 @staticmethod @@ -32,7 +29,7 @@ def switch_to_tab(tab_idx) -> bool: section_length = w // NUMBER_TABS centers = [(x + (i + 0.5) * section_length, y + h // 2) for i in range(NUMBER_TABS)] mouse.move(*Cam().window_to_monitor(centers[tab_idx]), randomize=2) - time.sleep(0.5) + time.sleep(0.1) mouse.click("left") - time.sleep(0.5) + time.sleep(0.2) return True diff --git a/src/ui/inventory_base.py b/src/ui/inventory_base.py index 5589cc7e..b9cbdcdc 100644 --- a/src/ui/inventory_base.py +++ b/src/ui/inventory_base.py @@ -83,4 +83,4 @@ def get_item_slots(self, img: np.ndarray | None = None) -> tuple[list[ItemSlot], return occupied_slots, empty_slots def hover_item(self, item: ItemSlot): - mouse.move(*Cam().window_to_monitor(item.center), randomize=15, delay_factor=(1.1, 1.3)) + mouse.move(*Cam().window_to_monitor(item.center), randomize=15) diff --git a/src/ui/menu.py b/src/ui/menu.py index 0515a528..8b25ee25 100644 --- a/src/ui/menu.py +++ b/src/ui/menu.py @@ -5,9 +5,8 @@ import keyboard import numpy as np -from src.template_finder import SearchArgs, SearchResult, TemplateMatch +from src.template_finder import SearchArgs, SearchResult from src.utils.misc import run_until_condition -from src.utils.mouse_selector import select_search_result LOGGER = logging.getLogger(__name__) @@ -22,35 +21,9 @@ def __init__(self): self.menu_name: str = "" self.parent_menu: Menu | None = None self.is_open_search_args: SearchArgs | None = None - self.open_button_search_args: SearchArgs | None = None - self.close_button_search_args = self.open_button_search_args self.open_hotkey: str = "" - self.close_hotkey: str = "esc" - self.open_method: ToggleMethod = ToggleMethod.BUTTON - self.close_method: ToggleMethod = ToggleMethod.HOTKEY self.delay = 0 - @staticmethod - def select_button(search: SearchArgs | TemplateMatch) -> bool: - """ - Selects a button based on a given search object. - :param search: A SearchArgs or TemplateMatch object to identify the button - :return: True if the button is successfully selected, False otherwise. - """ - if isinstance(search, SearchArgs): - result = search.detect() - if not result.success: - LOGGER.error(f"Could not find {search.ref} button") - return False - match = result.matches[0] - elif isinstance(search, TemplateMatch): - match = search - else: - LOGGER.error(f"Invalid type {type(search)} for search") - return False - select_search_result(match) - return True - def open(self) -> bool: """ Opens the menu by clicking the open button. @@ -61,35 +34,12 @@ def open(self) -> bool: LOGGER.error(f"Could not open parent menu {self.parent_menu.menu_name}") return False if not (is_open := self.is_open()): - debug_string = f"Opening {self.menu_name} with" - if self.open_method == ToggleMethod.BUTTON: - debug_string += f" button {self.open_button_search_args.ref}" - self.select_button(self.open_button_search_args) - elif self.open_method == ToggleMethod.HOTKEY: - debug_string += f" hotkey {self.open_hotkey}" - keyboard.send(self.open_hotkey) - LOGGER.debug(debug_string) + LOGGER.debug(f"Opening {self.menu_name} using hotkey {self.open_hotkey}") + keyboard.send(self.open_hotkey) else: LOGGER.debug(f"{self.menu_name} already open") return is_open or self.wait_until_open() - def close(self) -> bool: - """ - Closes the menu by pressing the escape key. - :return: True if the menu is successfully closed, False otherwise. - """ - if self.is_open(): - debug_string = f"Closing {self.menu_name} with" - if self.close_method == ToggleMethod.BUTTON: - debug_string += f" button {self.close_button_search_args.ref}" - self.select_button(self.close_button_search_args) - elif self.close_method == ToggleMethod.HOTKEY: - debug_string += f" hotkey {self.close_hotkey}" - keyboard.send(self.close_hotkey) - LOGGER.debug(debug_string) - return self.wait_until_closed(timeout=3) - return True - def _check_match(self, res: SearchResult) -> bool: """ Checks if the given TemplateMatch is a match for the menu. @@ -120,17 +70,3 @@ def wait_until_open(self, timeout: float = 10) -> bool: LOGGER.warning(f"Could not find {self.menu_name} after {timeout} seconds") time.sleep(self.delay) return success - - def wait_until_closed(self, timeout: float = 10, mode: str = "all") -> bool: - """ - Waits until the menu is closed. - :param timeout: The number of seconds to wait before timing out. - :param mode: The mode to use when searching for the menu. See template_finder.py for more info. - :return: True if the menu is closed, False otherwise. - """ - args: SearchArgs = self.is_open_search_args - args.mode = mode - _, success = run_until_condition(lambda: args.is_visible(), lambda res: not res, timeout) - if not success: - LOGGER.warning(f"{self.menu_name} still visible after {timeout} seconds") - return success diff --git a/src/utils/custom_mouse.py b/src/utils/custom_mouse.py index f0c4d189..e0550c10 100644 --- a/src/utils/custom_mouse.py +++ b/src/utils/custom_mouse.py @@ -244,11 +244,12 @@ def move(x: int, y: int, absolute: bool = True, randomize: int | tuple[int, int] from_point, (x, y), offsetBoundaryX=offsetBoundaryX, offsetBoundaryY=offsetBoundaryY, targetPoints=targetPoints ) - duration = min(0.5, max(0.05, dist * 0.0004) * random.uniform(delay_factor[0], delay_factor[1])) + duration = min(0.3, max(0.05, dist * 0.0004) * random.uniform(delay_factor[0], delay_factor[1])) delta = duration / len(human_curve.points) for point in human_curve.points: _mouse.move(point[0], point[1], duration=delta) + time.sleep(0.05) @staticmethod def _is_clicking_safe(): diff --git a/src/utils/mouse_selector.py b/src/utils/mouse_selector.py deleted file mode 100644 index 45417ffd..00000000 --- a/src/utils/mouse_selector.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging -import time - -from src.template_finder import SearchResult, TemplateMatch -from src.utils.custom_mouse import mouse - -LOGGER = logging.getLogger(__name__) - - -def select_search_result(result: SearchResult | TemplateMatch, delay_factor: tuple[float, float] = (0.9, 1.1), click: str = "left") -> None: - move_to_search_result(result, delay_factor) - time.sleep(0.05) - mouse.click(click) - time.sleep(0.05) - - -def move_to_search_result(result: SearchResult | TemplateMatch, delay_factor: tuple[float, float] = (0.9, 1.1)) -> None: - if isinstance(result, SearchResult): - # default to first match - match = result.matches[0] - elif isinstance(result, TemplateMatch): - match = result - else: - LOGGER.error(f"move_to_search_result: Invalid type {type(result)} for result") - return - mouse.move(*match.center_monitor, delay_factor=delay_factor) diff --git a/tests/item/filter/filter_test.py b/tests/item/filter/filter_test.py index a4545c25..264dae20 100644 --- a/tests/item/filter/filter_test.py +++ b/tests/item/filter/filter_test.py @@ -4,7 +4,7 @@ import tests.item.filter.data.filters as filters from src.config.models import SigilPriority -from src.item.filter import Filter, _FilterResult +from src.item.filter import Filter, FilterResult from src.item.models import Item from tests.item.filter.data.affixes import affixes from tests.item.filter.data.sigils import sigil_jalal, sigil_priority, sigils @@ -21,7 +21,7 @@ def _create_mocked_filter(mocker: MockerFixture) -> Filter: @pytest.mark.parametrize(("name", "result", "item"), natsorted(affixes), ids=[name for name, _, _ in natsorted(affixes)]) def test_affixes(name: str, result: list[str], item: Item, mocker: MockerFixture): test_filter = _create_mocked_filter(mocker) - mocker.patch("item.filter.Filter._check_aspect", return_value=_FilterResult(keep=False, matched=[])) + mocker.patch("item.filter.Filter._check_aspect", return_value=FilterResult(keep=False, matched=[])) test_filter.affix_filters = {filters.affix.name: filters.affix.Affixes} assert natsorted([match.profile for match in test_filter.should_keep(item).matched]) == natsorted(result) diff --git a/tests/item/read_descr_season6_tts_test.py b/tests/item/read_descr_season6_tts_test.py new file mode 100644 index 00000000..781fb393 --- /dev/null +++ b/tests/item/read_descr_season6_tts_test.py @@ -0,0 +1,116 @@ +import pytest + +import src.tts +from src.item.data.affix import AffixType +from src.item.data.item_type import ItemType +from src.item.data.rarity import ItemRarity +from src.item.descr.read_descr_tts import read_descr +from src.item.models import Affix, Item + +items = [ + ( + [ + "BLOOD BOILING LOOP OF BALEFUL INTENT", + "Legendary Ring", + "750 Item Power", + "+10.0% Resistance to All Elements", + "+10.0% Cold Resistance", + "+97 Dexterity", + "+261 Maximum Life", + "+54.0% Overpower Damage", + "When your Core Skills Overpower an enemy, you spawn 3 Volatile Blood Drops. Collecting a Volatile Blood Drop causes it to explode, dealing 813 Physical damage around you.. Every 20 seconds, your next Skill is guaranteed to Overpower.", + "Empty Socket", + "Requires Level 60", + "Sell Value: 45,145 Gold", + "Tempers: 5/5", + "Mousewheel scroll down", + "Scroll Down", + "Right mouse button", + ], + Item( + affixes=[ + Affix(name="dexterity", type=AffixType.greater, value=97), + Affix(name="maximum_life", type=AffixType.greater, value=261), + Affix(name="overpower_damage", type=AffixType.greater, value=54), + ], + inherent=[ + Affix(name="resistance_to_all_elements", type=AffixType.inherent, value=10), + Affix(name="cold_resistance", type=AffixType.inherent, value=10), + ], + item_type=ItemType.Ring, + power=750, + rarity=ItemRarity.Legendary, + ), + ), + ( + [ + "TORMENTORS GOLDEN QUARTERSTAFF", + "Legendary Quarterstaff", + "750 Item Power", + "523 Damage Per Second (-73)", + "[381 - 571] Damage per Hit", + "1.10 Attacks per Second (Fast)", + "40% Block Chance", + "+182 Dexterity", + "+544 Maximum Life", + "+112.0% Damage Over Time", + "Enemies who move while Poisoned by you additionally take 160% of your Thorns damage per second.", + "Empty Socket", + "Empty Socket", + "Requires Level 60. Spiritborn. Vessel of Hatred Item", + "Sell Value: 82,765 Gold", + "Durability: 100/100. Tempers: 5/5", + "Right mouse button", + ], + Item( + affixes=[ + Affix(name="dexterity", type=AffixType.greater, value=182), + Affix(name="maximum_life", type=AffixType.greater, value=544), + Affix(name="damage_over_time", type=AffixType.greater, value=112), + ], + inherent=[Affix(name="block_chance", type=AffixType.inherent, value=40)], + item_type=ItemType.Quarterstaff, + power=750, + rarity=ItemRarity.Legendary, + ), + ), + ( + [ + "TORMENTORS ELEGANT POLEARM", + "Legendary Polearm", + "750 Item Power", + "523 Damage Per Second", + "[466 - 698] Damage per Hit", + "0.90 Attacks per Second (Slow)", + "+76.5% Vulnerable Damage [76.5]%", + "+185 Dexterity +[178 - 198]", + "+2 Vigor On Kill +[2]", + "+72.0% Vulnerable Damage [70.0 - 90.0]%", + "Enemies who move while Poisoned by you additionally take 280% [100 - 380]% of your Thorns damage per second.", + "Empty Socket", + "Empty Socket", + "Requires Level 60. Spiritborn. Vessel of Hatred Item", + "Sell Value: 82,765 Gold", + "Durability: 100/100. Tempers: 5/5", + "Right mouse button", + ], + Item( + affixes=[ + Affix(max_value=198.0, min_value=178.0, name="dexterity", type=AffixType.normal, value=185.0), + Affix(max_value=2.0, min_value=2.0, name="vigor_on_kill", type=AffixType.normal, value=2.0), + Affix(max_value=90.0, min_value=70.0, name="vulnerable_damage", type=AffixType.normal, value=72.0), + ], + inherent=[Affix(max_value=76.5, min_value=76.5, name="vulnerable_damage", type=AffixType.inherent, value=76.5)], + item_type=ItemType.Polearm, + power=750, + rarity=ItemRarity.Legendary, + ), + ), +] + + +@pytest.mark.parametrize(("input_item", "expected_item"), items) +def test_items(input_item: list[str], expected_item: Item): + src.tts.LAST_ITEM = input_item + item = read_descr() + assert item == expected_item diff --git a/tts/saapi.cpp b/tts/saapi.cpp new file mode 100644 index 00000000..30282984 --- /dev/null +++ b/tts/saapi.cpp @@ -0,0 +1,50 @@ +#include "saapi.h" + +#include + +#include +#include +#define WIN32_LEAN_AND_MEAN +#include + +HANDLE hPipe; + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { + switch (ul_reason_for_call) { + case DLL_PROCESS_ATTACH: + InitPipe(); + SA_SayW(L"CONNECTED"); + break; + + case DLL_PROCESS_DETACH: + SA_SayW(L"DISCONNECTED"); + break; + + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + } + return TRUE; +} + +void InitPipe() { hPipe = CreateFile(_T("\\\\.\\pipe\\d4lf"), GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); } + +extern "C" bool SA_SayW(const wchar_t *str) { + if (!str) return false; + + std::string narrowStr; + int size_needed = WideCharToMultiByte(CP_UTF8, 0, str, -1, nullptr, 0, nullptr, nullptr); + narrowStr.resize(size_needed); + WideCharToMultiByte(CP_UTF8, 0, str, -1, &narrowStr[0], size_needed, nullptr, nullptr); + + DWORD bytesWritten = 0; + BOOL flg = WriteFile(hPipe, narrowStr.c_str(), static_cast(narrowStr.length()), &bytesWritten, NULL); + if (!flg) InitPipe(); + return true; +} + +extern "C" bool SA_BrlShowTextW(const wchar_t *str) { return true; } + +extern "C" bool SA_IsRunning() { return true; } + +extern "C" bool SA_StopAudio() { return true; } diff --git a/tts/saapi.h b/tts/saapi.h new file mode 100644 index 00000000..ba5d96d7 --- /dev/null +++ b/tts/saapi.h @@ -0,0 +1,6 @@ +void InitPipe(); + +extern "C" __declspec(dllexport) bool SA_SayW(const wchar_t *str); +extern "C" __declspec(dllexport) bool SA_BrlShowTextW(const wchar_t *str); +extern "C" __declspec(dllexport) bool SA_StopAudio(); +extern "C" __declspec(dllexport) bool SA_IsRunning(); diff --git a/tts/saapi.vcxproj b/tts/saapi.vcxproj new file mode 100644 index 00000000..9fc575ad --- /dev/null +++ b/tts/saapi.vcxproj @@ -0,0 +1,66 @@ + + + + + Release + x64 + + + + 16.0 + Win32Proj + {175bbf6b-234c-41a2-8d0e-86070c0587f0} + saapi + 10.0 + + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + saapi64 + false + + + + Level3 + true + true + true + NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + + + Windows + true + true + false + + + + + + + + + + + + + + + diff --git a/tts/saapi64.dll b/tts/saapi64.dll new file mode 100644 index 00000000..607639ec Binary files /dev/null and b/tts/saapi64.dll differ