From 864ac15282db87099aa24c2ad49b1fa1b51a8fbb Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:07:37 +0800 Subject: [PATCH 01/55] dep: upgrade to pydantic v2 --- requirements-in.txt | 2 +- requirements.txt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements-in.txt b/requirements-in.txt index 255a687..610cf95 100644 --- a/requirements-in.txt +++ b/requirements-in.txt @@ -20,7 +20,7 @@ jellyfish pyyaml inflection prettytable==2.2.1 -pydantic +pydantic>=2.4 # OCR pponnxcr==2.0 diff --git a/requirements.txt b/requirements.txt index d353e9e..1f3978f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ adbutils==1.2.9 # via -r requirements-in.txt, uiautomator2 aiofiles==23.1.0 # via -r requirements-in.txt +annotated-types==0.5.0 # via pydantic anyio==3.6.2 # via watchgod apkutils2==1.0.0 # via adbutils asgiref==3.6.0 # via uvicorn @@ -48,7 +49,8 @@ protobuf==4.23.0 # via onnxruntime psutil==5.9.3 # via -r requirements-in.txt py==1.11.0 # via retry pyclipper==1.3.0.post4 # via pponnxcr -pydantic==1.10.7 # via -r requirements-in.txt +pydantic==2.4.2 # via -r requirements-in.txt +pydantic-core==2.10.1 # via pydantic pyelftools==0.29 # via apkutils2 pygments==2.15.1 # via rich pynput==1.7.6 # via -r requirements-in.txt @@ -68,7 +70,7 @@ starlette==0.14.2 # via -r requirements-in.txt sympy==1.12 # via onnxruntime tornado==6.3.1 # via pywebio tqdm==4.65.0 # via -r requirements-in.txt -typing-extensions==4.5.0 # via pydantic +typing-extensions==4.8.0 # via pydantic, pydantic-core ua-parser==0.16.1 # via user-agents uiautomator2==2.16.17 # via -r requirements-in.txt uiautomator2cache==0.3.0.1 # via -r requirements-in.txt From a397539c692ba7a37d04ea192bd7985673737b25 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:08:44 +0800 Subject: [PATCH 02/55] feat: add simple markdown table generator --- module/base/code_generator.py | 64 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/module/base/code_generator.py b/module/base/code_generator.py index 097bb7f..e146f72 100644 --- a/module/base/code_generator.py +++ b/module/base/code_generator.py @@ -33,23 +33,33 @@ class TabWrapper: self.suffix += suffix +class VariableWrapper: + def __init__(self, name): + self.name = name + + def __repr__(self): + return str(self.name) + + __str__ = __repr__ + + class CodeGenerator: def __init__(self): self.tab_count = 0 self.lines = [] - def generate(self) -> t.Iterable[str]: - yield '' - def add(self, line, comment=False, newline=True): self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline)) + def generate(self) -> t.Iterable[str]: + return ''.join(self.lines) + def print(self): - lines = ''.join(self.lines) + lines = self.generate() print(lines) def write(self, file: str = None): - lines = ''.join(self.lines) + lines = self.generate() with open(file, 'w', encoding='utf-8', newline='') as f: f.write(lines) @@ -77,7 +87,10 @@ class CodeGenerator: return TabWrapper(self) def Empty(self): - self.add('') + self.lines.append('\n') + + def Pass(self): + self.add('pass') def Import(self, text, empty=2): for line in text.strip().split('\n'): @@ -86,6 +99,9 @@ class CodeGenerator: for _ in range(empty): self.Empty() + def Variable(self, name): + return VariableWrapper(name) + def Value(self, key=None, value=None, type_=None, **kwargs): if key is not None: if type_ is not None: @@ -117,7 +133,7 @@ class CodeGenerator: if key is not None: return TabWrapper(self, prefix=str(key) + ' = [', suffix=']') else: - return TabWrapper(self, prefix='[', suffix=']', newline=False) + return TabWrapper(self, prefix='[', suffix=']') def ListItem(self, value): if isinstance(value, TabWrapper): @@ -131,7 +147,7 @@ class CodeGenerator: if key is not None: return TabWrapper(self, prefix=str(key) + ' = {', suffix='}') else: - return TabWrapper(self, prefix='{', suffix='}', newline=False) + return TabWrapper(self, prefix='{', suffix='}') def DictItem(self, key=None, value=None): if isinstance(value, TabWrapper): @@ -147,7 +163,7 @@ class CodeGenerator: if key is not None: return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')') else: - return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False) + return TabWrapper(self, prefix=f'{object_class}(', suffix=')') def ObjectAttr(self, key=None, value=None): if isinstance(value, TabWrapper): @@ -179,3 +195,33 @@ Value = generator.Value Comment = generator.Comment Dict = generator.Dict DictItem = generator.DictItem + + +class MarkdownGenerator: + def __init__(self, column: t.List[str]): + self.rows = [column] + + def add_row(self, row): + self.rows.append([str(ele) for ele in row]) + + def product_line(self, row, max_width): + row = [ele.ljust(width) for ele, width in zip(row, max_width)] + row = ' | '.join(row) + row = '| ' + row + ' |' + return row + + def generate(self) -> t.List[str]: + import numpy as np + width = np.array([ + [len(ele) for ele in row] for row in self.rows + ]) + max_width = np.max(width, axis=0) + dash = ['-' * width for width in max_width] + + rows = [ + self.product_line(self.rows[0], max_width), + self.product_line(dash, max_width), + ] + [ + self.product_line(row, max_width) for row in self.rows[1:] + ] + return rows From 004af69d8f6828a67719d06b3b3678feec30f41d Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:20:03 +0800 Subject: [PATCH 03/55] fix: use 'area' in 'ClickButton' --- module/base/base.py | 2 +- module/device/control.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/module/base/base.py b/module/base/base.py index d097c7f..1069480 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -219,7 +219,7 @@ class ModuleBase: point = fit_points(points, mod=image_size(image), encourage=encourage) point = ensure_int(point + area[:2]) button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point) - return ClickButton(button=button_area, name=name) + return ClickButton(area=button_area, name=name) def interval_reset(self, button, interval=5): if isinstance(button, (list, tuple)): diff --git a/module/device/control.py b/module/device/control.py index 1d21dd6..010caca 100644 --- a/module/device/control.py +++ b/module/device/control.py @@ -1,3 +1,5 @@ +import numpy as np + from module.base.button import ClickButton from module.base.decorator import cached_property from module.base.timer import Timer @@ -8,8 +10,6 @@ from module.device.method.minitouch import Minitouch from module.device.method.scrcpy import Scrcpy from module.logger import logger -import numpy as np - class Control(Hermit, Minitouch, Scrcpy, MaaTouch): def handle_control_check(self, button): @@ -169,7 +169,7 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch): logger.warning(f'Control method {method} does not support drag well, ' f'falling back to ADB swipe may cause unexpected behaviour') self.swipe_adb(p1, p2, duration=ensure_time(swipe_duration * 2)) - self.click(ClickButton(button=area_offset(point_random, p2), name=name)) + self.click(ClickButton(area=area_offset(point_random, p2), name=name)) # just used in cafe def pinch(self, box=(35, 130, 1250, 560), name='PINCH'): From 8dc527b06bb2e9f84a0fcce8c347607c93335061 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:21:11 +0800 Subject: [PATCH 04/55] fix: remove semicolon and dot in ocr results --- module/ocr/keyword.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index bd7c4a0..4e48932 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -6,12 +6,13 @@ from typing import ClassVar import module.config.server as server from module.exception import ScriptError -REGEX_PUNCTUATION = re.compile(r'[ ,.\'"“”,。::!!??·•\-—/\\\n\t()\[\]()「」『』【】《》[]]') +# ord('.') = 65294 +REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。::;;!!??·•\-—/\\\n\t()\[\]()「」『』【】《》[]]') def parse_name(n): n = REGEX_PUNCTUATION.sub('', str(n)).lower() - return n + return n.strip() @dataclass From aaa56b928f8469f4a9df70de88f328c3edcd0ed8 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:34:09 +0800 Subject: [PATCH 05/55] feat: add interval timer management methods for buttons --- module/base/base.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/module/base/base.py b/module/base/base.py index 1069480..4a441ee 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -221,6 +221,25 @@ class ModuleBase: button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point) return ClickButton(area=button_area, name=name) + def get_interval_timer(self, button, interval=5) -> Timer: + if hasattr(button, 'name'): + name = button.name + elif callable(button): + name = button.__name__ + else: + name = str(button) + + try: + timer = self.interval_timer[name] + if timer.limit != interval: + timer = Timer(interval) + self.interval_timer[name] = timer + return timer + except KeyError: + timer = Timer(interval) + self.interval_timer[name] = timer + return timer + def interval_reset(self, button, interval=5): if isinstance(button, (list, tuple)): for b in button: @@ -228,10 +247,7 @@ class ModuleBase: return if button is not None: - if button.name in self.interval_timer: - self.interval_timer[button.name].reset() - else: - self.interval_timer[button.name] = Timer(interval).reset() + self.get_interval_timer(button, interval=interval).reset() def interval_clear(self, button, interval=5): if isinstance(button, (list, tuple)): @@ -240,19 +256,11 @@ class ModuleBase: return if button is not None: - if button.name in self.interval_timer: - self.interval_timer[button.name].clear() - else: - self.interval_timer[button.name] = Timer(interval).clear() + self.get_interval_timer(button, interval=interval).clear() def interval_is_reached(self, button, interval=5): - if button.name in self.interval_timer: - if self.interval_timer[button.name].limit != interval: - self.interval_timer[button.name] = Timer(interval) - else: - self.interval_timer[button.name] = Timer(interval) + return self.get_interval_timer(button, interval=interval).reached() - return self.interval_timer[button.name].reached() _image_file = '' From 0b1a4efd09906200e7b266c8d833c9127a2a0829 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:36:01 +0800 Subject: [PATCH 06/55] fix: renew timer in `interval_is_reached()` only --- module/base/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/base/base.py b/module/base/base.py index 4a441ee..66e1c4c 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -221,7 +221,7 @@ class ModuleBase: button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point) return ClickButton(area=button_area, name=name) - def get_interval_timer(self, button, interval=5) -> Timer: + def get_interval_timer(self, button, interval=5, renew=False) -> Timer: if hasattr(button, 'name'): name = button.name elif callable(button): @@ -231,7 +231,7 @@ class ModuleBase: try: timer = self.interval_timer[name] - if timer.limit != interval: + if renew and timer.limit != interval: timer = Timer(interval) self.interval_timer[name] = timer return timer @@ -259,7 +259,7 @@ class ModuleBase: self.get_interval_timer(button, interval=interval).clear() def interval_is_reached(self, button, interval=5): - return self.get_interval_timer(button, interval=interval).reached() + return self.get_interval_timer(button, interval=interval, renew=True).reached() _image_file = '' From a7290f253e225c53fd0d2d98fd21d444de788b55 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:37:22 +0800 Subject: [PATCH 07/55] fix: remove another dash in ocr results --- module/ocr/keyword.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index 4e48932..1c25175 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -7,7 +7,7 @@ import module.config.server as server from module.exception import ScriptError # ord('.') = 65294 -REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。::;;!!??·•\-—/\\\n\t()\[\]()「」『』【】《》[]]') +REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。::;;!!??·•\-—-/\\\n\t()\[\]()「」『』【】《》[]]') def parse_name(n): From 07a7a19929dc9d75c9417f8129bef5a6fecd9b00 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:40:00 +0800 Subject: [PATCH 08/55] feat: add area_center function and enhance OCR processing for white letters --- module/base/utils/utils.py | 14 ++++++++++++++ module/ocr/ocr.py | 20 +++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/module/base/utils/utils.py b/module/base/utils/utils.py index ce29352..4f2abea 100644 --- a/module/base/utils/utils.py +++ b/module/base/utils/utils.py @@ -274,6 +274,20 @@ def area_size(area): ) +def area_center(area): + """ + Get the center of an area + + Args: + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y) + + Returns: + tuple: (x, y) + """ + x1, y1, x2, y2 = area + return (x1 + x2) / 2, (y1 + y2) / 2 + + def point_limit(point, area): """ Limit point in an area. diff --git a/module/ocr/ocr.py b/module/ocr/ocr.py index 4d36f7a..6c3b390 100644 --- a/module/ocr/ocr.py +++ b/module/ocr/ocr.py @@ -3,13 +3,14 @@ import time from datetime import timedelta from typing import Optional +import cv2 import numpy as np from pponnxcr.predict_system import BoxedResult import module.config.server as server from module.base.button import ButtonWrapper from module.base.decorator import cached_property -from module.base.utils import area_pad, corner2area, crop, float2str +from module.base.utils import area_pad, corner2area, crop, extract_white_letters, float2str from module.exception import ScriptError from module.logger import logger from module.ocr.keyword import Keyword @@ -416,3 +417,20 @@ class Duration(Ocr): if number is None: return 0 return int(number) + + +class OcrWhiteLetterOnComplexBackground(Ocr): + def pre_process(self, image): + image = extract_white_letters(image, threshold=255) + image = cv2.merge([image, image, image]) + return image + + def detect_and_ocr(self, *args, **kwargs): + # Try hard to lower TextSystem.box_thresh + backup = self.model.text_detector.box_thresh + self.model.text_detector.box_thresh = 0.2 + + result = super().detect_and_ocr(*args, **kwargs) + + self.model.text_detector.box_thresh = backup + return result From da34dc4b7dfcea39f8c65b3e2ee7d02be04b8f1a Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:41:20 +0800 Subject: [PATCH 09/55] fix: recheck in-game text language during waiting for next run --- module/base/resource.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/module/base/resource.py b/module/base/resource.py index aab6f23..d6a87b2 100644 --- a/module/base/resource.py +++ b/module/base/resource.py @@ -78,5 +78,11 @@ def release_resources(next_task=''): # logger.info(f'Release {obj}') obj.resource_release() + # If no task, check in-game text language again at next run + # cause user may change it + if not next_task: + from tasks.base.main_page import MainPage + MainPage._lang_checked = False + # Useless in most cases, but just call it # gc.collect() \ No newline at end of file From 227513363832c25691ff822661f742316a14a487 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:42:50 +0800 Subject: [PATCH 10/55] dep: update to pywebio 1.8.3 --- requirements-in.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-in.txt b/requirements-in.txt index 610cf95..c34c5d5 100644 --- a/requirements-in.txt +++ b/requirements-in.txt @@ -26,7 +26,7 @@ pydantic>=2.4 pponnxcr==2.0 # Webui -pywebio==1.6.2 +pywebio==1.8.3 starlette==0.14.2 uvicorn[standard]==0.17.6 aiofiles diff --git a/requirements.txt b/requirements.txt index 1f3978f..311038d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ pynput==1.7.6 # via -r requirements-in.txt pyparsing==3.0.9 # via packaging pyreadline3==3.4.1 # via humanfriendly python-dotenv==1.0.0 # via uvicorn -pywebio==1.6.2 # via -r requirements-in.txt +pywebio==1.8.3 # via -r requirements-in.txt pyyaml==6.0 # via -r requirements-in.txt, uvicorn requests==2.30.0 # via adbutils, uiautomator2 retry==0.9.2 # via adbutils, uiautomator2 From f6f2470e5b82c5e8a0790231160fc4b28736e1ec Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:56:12 +0800 Subject: [PATCH 11/55] refactor: add config update callback --- module/config/config_updater.py | 41 +++++++++++++++++++++++++++++++++ module/webui/app.py | 28 +++++++++------------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/module/config/config_updater.py b/module/config/config_updater.py index abe1b75..f6bb98b 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -1,3 +1,4 @@ +import typing as t from copy import deepcopy from cached_property import cached_property @@ -569,6 +570,46 @@ class ConfigUpdater: # set_daily('Use_Consumables_1_time', 'achievable') # return data + def save_callback(self, key: str, value: t.Any) -> t.Iterable[t.Tuple[str, t.Any]]: + """ + Args: + key: Key path in config json, such as "Main.Emotion.Fleet1Value" + value: Value set by user, such as "98" + + Yields: + str: Key path to set config json, such as "Main.Emotion.Fleet1Record" + any: Value to set, such as "2020-01-01 00:00:00" + """ + # if key.startswith('Dungeon.Dungeon') or key.startswith('Dungeon.DungeonDaily'): + # from tasks.dungeon.keywords.dungeon import DungeonList + # from module.exception import ScriptError + # try: + # dungeon = DungeonList.find(value) + # except ScriptError: + # return + # if key.endswith('Name'): + # if dungeon.is_Calyx_Golden: + # yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value + # yield 'Dungeon.DungeonDaily.CalyxGolden', value + # elif dungeon.is_Calyx_Crimson: + # yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value + # yield 'Dungeon.DungeonDaily.CalyxCrimson', value + # elif dungeon.is_Stagnant_Shadow: + # yield 'Dungeon.DungeonDaily.StagnantShadow', value + # elif dungeon.is_Cavern_of_Corrosion: + # yield 'Dungeon.Dungeon.NameAtDoubleRelic', value + # yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value + # elif key.endswith('NameAtDoubleCalyx'): + # if dungeon.is_Calyx_Golden: + # yield 'Dungeon.DungeonDaily.CalyxGolden', value + # elif dungeon.is_Calyx_Crimson: + # yield 'Dungeon.DungeonDaily.CalyxCrimson', value + # elif key.endswith('NameAtDoubleRelic'): + # yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value + # elif key.endswith('CavernOfCorrosion'): + # yield 'Dungeon.Dungeon.NameAtDoubleRelic', value + pass + def read_file(self, config_name, is_template=False): """ Read and update config file. diff --git a/module/webui/app.py b/module/webui/app.py index f23f26d..b181e71 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -456,8 +456,7 @@ class AlasGUI(Frame): try: d = self.modified_config_queue.get(timeout=10) config_name = self.alas_name - read = self.alas_config.read_file - write = self.alas_config.write_file + config_updater = self.alas_config except queue.Empty: continue modified[d["name"]] = d["value"] @@ -466,7 +465,7 @@ class AlasGUI(Frame): d = self.modified_config_queue.get(timeout=1) modified[d["name"]] = d["value"] except queue.Empty: - self._save_config(modified, config_name, read, write) + self._save_config(modified, config_name, config_updater) modified.clear() break @@ -474,13 +473,12 @@ class AlasGUI(Frame): self, modified: Dict[str, str], config_name: str, - read=State.config_updater.read_file, - write=State.config_updater.write_file, + config_updater: AzurLaneConfig = State.config_updater, ) -> None: try: valid = [] invalid = [] - config = read(config_name) + config = config_updater.read_file(config_name) for k, v in modified.copy().items(): valuetype = deep_get(self.ALAS_ARGS, k + ".valuetype") v = parse_pin_value(v, valuetype) @@ -497,16 +495,12 @@ class AlasGUI(Frame): modified[k] = v valid.append(k) - # update Emotion Record if Emotion Value is changed - if "Emotion" in k and "Value" in k: - k = k.split(".") - k[-1] = k[-1].replace("Value", "Record") - k = ".".join(k) - v = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - modified[k] = v - deep_set(config, k, v) - valid.append(k) - pin["_".join(k.split("."))] = v + for set_key, set_value in config_updater.save_callback(k, v): + logger.info([set_key, set_value, pin["_".join(set_key.split("."))]]) + modified[set_key] = set_value + deep_set(config, set_key, set_value) + valid.append(set_key) + pin["_".join(set_key.split("."))] = set_value else: modified.pop(k) invalid.append(k) @@ -523,7 +517,7 @@ class AlasGUI(Frame): logger.info( f"Save config {filepath_config(config_name)}, {dict_to_kv(modified)}" ) - write(config_name, config) + config_updater.write_file(config_name, config) except Exception as e: logger.exception(e) From 6f6dab1c04b25ea0cbf0cc6c2b80580426ccc9da Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 16:57:17 +0800 Subject: [PATCH 12/55] fix: correct function to retrieve last server update --- module/config/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/config/utils.py b/module/config/utils.py index ae39235..fe2d731 100644 --- a/module/config/utils.py +++ b/module/config/utils.py @@ -539,7 +539,7 @@ def get_server_last_monday_update(daily_trigger): Returns: datetime.datetime """ - update = get_server_next_update(daily_trigger) + update = get_server_last_update(daily_trigger) diff = update.weekday() update = update - timedelta(days=diff) return update From c43f8e54585e121e0cb6228b64b645460392a0b7 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:02:27 +0800 Subject: [PATCH 13/55] feat: add is_draggable method to improve swipe responsiveness --- module/ui/scroll.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/module/ui/scroll.py b/module/ui/scroll.py index a896f65..623a76f 100644 --- a/module/ui/scroll.py +++ b/module/ui/scroll.py @@ -110,6 +110,20 @@ class Scroll: """ return np.mean(self.match_color(main)) > 0.1 + def is_draggable(self, main): + """ + If scroll `length` is just a little smaller than `total`, + game client may not respond to such a short swipe. + + Args: + main (ModuleBase): + + Returns: + bool: + """ + _ = self.cal_position(main) + return self.length / self.total < 0.95 + def at_top(self, main): return self.cal_position(main) < self.edge_threshold From 091b5c011a633bcbffea19f2cc019f72d946841b Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:07:17 +0800 Subject: [PATCH 14/55] refactor: replace direct import of deque with collections.deque for clarity --- module/device/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/device/device.py b/module/device/device.py index 7f7bf27..e9d207d 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -1,5 +1,5 @@ +import collections import sys -from collections import deque from module.base.timer import Timer from module.device.app_control import AppControl @@ -23,7 +23,7 @@ else: class Device(Screenshot, Control, AppControl, Platform): _screen_size_checked = False detect_record = set() - click_record = deque(maxlen=15) + click_record = collections.deque(maxlen=15) stuck_timer = Timer(60, count=60).start() def __init__(self, *args, **kwargs): From 8741cb614266a06cbda33ecb862cc56295bf758b Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:11:07 +0800 Subject: [PATCH 15/55] fix: checkbox cannot be updated dynamically --- module/webui/app.py | 4 ++-- module/webui/utils.py | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/module/webui/app.py b/module/webui/app.py index b181e71..0f9cbc1 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -71,6 +71,7 @@ from module.webui.utils import ( parse_pin_value, raise_exception, re_fullmatch, + to_pin_value, ) from module.webui.widgets import ( BinarySwitchButton, @@ -496,11 +497,10 @@ class AlasGUI(Frame): valid.append(k) for set_key, set_value in config_updater.save_callback(k, v): - logger.info([set_key, set_value, pin["_".join(set_key.split("."))]]) modified[set_key] = set_value deep_set(config, set_key, set_value) valid.append(set_key) - pin["_".join(set_key.split("."))] = set_value + pin["_".join(set_key.split("."))] = to_pin_value(set_value) else: modified.pop(k) invalid.append(k) diff --git a/module/webui/utils.py b/module/webui/utils.py index 0ac6b86..da869e8 100644 --- a/module/webui/utils.py +++ b/module/webui/utils.py @@ -11,9 +11,7 @@ from typing import Callable, Generator, List import pywebio from pywebio.input import PASSWORD, input from pywebio.output import PopupSize, popup, put_html, toast -from pywebio.session import eval_js -from pywebio.session import info as session_info -from pywebio.session import register_thread, run_js +from pywebio.session import eval_js, info as session_info, register_thread, run_js from rich.console import Console from rich.terminal_theme import TerminalTheme @@ -427,6 +425,18 @@ def parse_pin_value(val, valuetype: str = None): return v +def to_pin_value(val): + """ + Convert bool to checkbox + """ + if val is True: + return [True] + elif val is False: + return [] + else: + return val + + def login(password): if get_localstorage("password") == str(password): return True From 63d4b5046155517489948b613d1944fb65502324 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:12:50 +0800 Subject: [PATCH 16/55] fix: add retry for `check_mumu_app_keep_alive()` --- module/device/connection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/device/connection.py b/module/device/connection.py index d0f087e..87705a6 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -1,6 +1,5 @@ import ipaddress import logging -import platform import re import socket import subprocess @@ -12,6 +11,7 @@ from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem from adbutils.errors import AdbError import module.config.server as server_ +import platform from module.base.decorator import Config, cached_property, del_cached_property from module.base.utils import SelectedGrids, ensure_time from module.device.connection_attr import ConnectionAttr @@ -266,6 +266,7 @@ class Connection(ConnectionAttr): return True return False + @retry def check_mumu_app_keep_alive(self): if not self.is_mumu_family: return False From 247a9a87709dde49a8ee2f8be52755140a9359d6 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:19:11 +0800 Subject: [PATCH 17/55] feat: save tracking screenshots --- module/alas.py | 11 ++++++++--- module/base/base.py | 37 +++++++++++++++++++++++++++++++++++++ module/device/screenshot.py | 4 ++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/module/alas.py b/module/alas.py index 56c0193..5abfd0a 100644 --- a/module/alas.py +++ b/module/alas.py @@ -65,6 +65,7 @@ class AzurLaneAutoScript: def run(self, command): try: self.device.screenshot() + self.device.screenshot_tracking.clear() self.__getattribute__(command)() return True except TaskEnd: @@ -139,15 +140,19 @@ class AzurLaneAutoScript: from module.base.utils import save_image from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs) if self.config.Error_SaveError: - if not os.path.exists('./log/error'): - os.mkdir('./log/error') folder = f'./log/error/{int(time.time() * 1000)}' logger.warning(f'Saving error: {folder}') - os.mkdir(folder) + os.makedirs(folder, exist_ok=True) for data in self.device.screenshot_deque: image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f') image = handle_sensitive_image(data['image']) save_image(image, f'{folder}/{image_time}.png') + if self.device.screenshot_tracking: + os.makedirs(f'{folder}/tracking', exist_ok=True) + for data in self.device.screenshot_tracking: + image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f') + with open(f'{folder}/tracking/{image_time}.png', 'wb') as f: + f.write(data['image'].getvalue()) with open(logger.log_file, 'r', encoding='utf-8') as f: lines = f.readlines() start = 0 diff --git a/module/base/base.py b/module/base/base.py index 66e1c4c..46ca350 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -1,3 +1,5 @@ +from concurrent.futures import ThreadPoolExecutor + import module.config.server as server_ from module.base.button import Button, ButtonWrapper, ClickButton, match_template from module.base.timer import Timer @@ -5,6 +7,7 @@ from module.base.utils import * from module.config.config import AzurLaneConfig from module.device.device import Device from module.logger import logger +from module.webui.setting import cached_class_property class ModuleBase: @@ -45,6 +48,15 @@ class ModuleBase: self.interval_timer = {} + @cached_class_property + def worker(self) -> ThreadPoolExecutor: + """ + A thread pool to run things at background + """ + logger.hr('Creating worker') + pool = ThreadPoolExecutor(1) + return pool + def match_template(self, button, interval=0, similarity=0.85): """ Args: @@ -290,3 +302,28 @@ class ModuleBase: """ server_.set_lang(lang) logger.attr('Lang', self.config.LANG) + + def screenshot_tracking_add(self): + """ + Add a tracking image, image will be saved + """ + logger.info('screenshot_tracking_add') + data = self.device.screenshot_deque[-1] + image = data['image'] + now = data['time'] + + def image_encode(im, ti): + import io + from module.handler.sensitive_info import handle_sensitive_image + + output = io.BytesIO() + im = handle_sensitive_image(im) + Image.fromarray(im, mode='RGB').save(output, format='png') + output.seek(0) + + self.device.screenshot_tracking.append({ + 'time': ti, + 'image': output + }) + + ModuleBase.worker.submit(image_encode, image, now) diff --git a/module/device/screenshot.py b/module/device/screenshot.py index c054bcc..cb990fa 100644 --- a/module/device/screenshot.py +++ b/module/device/screenshot.py @@ -100,6 +100,10 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): def screenshot_deque(self): return deque(maxlen=int(self.config.Error_ScreenshotLength)) + @cached_property + def screenshot_tracking(self): + return [] + def save_screenshot(self, genre='items', interval=None, to_base_folder=False): """Save a screenshot. Use millisecond timestamp as file name. From 0a10c61c59c89fdbc3362fc14eb0a93257daf37e Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:22:09 +0800 Subject: [PATCH 18/55] feat: show function calls on error --- module/device/device.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/module/device/device.py b/module/device/device.py index e9d207d..a9d92c7 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -20,6 +20,46 @@ else: from module.device.platform.platform_base import PlatformBase as Platform +def show_function_call(): + """ + INFO 21:07:31.554 │ Function calls: + L1 + spawn.py L116 spawn_main() + spawn.py L129 _main() + process.py L314 _bootstrap() + process.py L108 run() + process_manager.py L149 run_process() + alas.py L285 loop() + alas.py L69 run() + src.py L55 rogue() + rogue.py L36 run() + rogue.py L18 rogue_once() + entry.py L335 rogue_world_enter() + path.py L193 rogue_path_select() + """ + import os + import traceback + stack = traceback.extract_stack() + func_list = [] + for row in stack: + filename, line_number, function_name, _ = row + filename = os.path.basename(filename) + # /tasks/character/switch.py:64 character_update() + func_list.append([filename, str(line_number), function_name]) + max_filename = max([len(row[0]) for row in func_list]) + max_linenum = max([len(row[1]) for row in func_list]) + 1 + + def format_(file, line, func): + file = file.rjust(max_filename, " ") + line = f'L{line}'.rjust(max_linenum, " ") + if not func.startswith('<'): + func = f'{func}()' + return f'{file} {line} {func}' + + func_list = [f'\n{format_(*row)}' for row in func_list] + logger.info('Function calls:' + ''.join(func_list)) + + class Device(Screenshot, Control, AppControl, Platform): _screen_size_checked = False detect_record = set() @@ -104,6 +144,7 @@ class Device(Screenshot, Control, AppControl, Platform): if not reached: return False + show_function_call() logger.warning('Wait too long') logger.warning(f'Waiting for {self.detect_record}') self.stuck_record_clear() @@ -155,11 +196,13 @@ class Device(Screenshot, Control, AppControl, Platform): count[key] = count.get(key, 0) + 1 count = sorted(count.items(), key=lambda item: item[1]) if count[0][1] >= 12: + show_function_call() logger.warning(f'Too many click for a button: {count[0][0]}') logger.warning(f'History click: {[str(prev) for prev in self.click_record]}') self.click_record_clear() raise GameTooManyClickError(f'Too many click for a button: {count[0][0]}') if len(count) >= 2 and count[0][1] >= 6 and count[1][1] >= 6: + show_function_call() logger.warning(f'Too many click between 2 buttons: {count[0][0]}, {count[1][0]}') logger.warning(f'History click: {[str(prev) for prev in self.click_record]}') self.click_record_clear() From 5df288b577c09d212dcbc792df9d8a8979f215b2 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:29:56 +0800 Subject: [PATCH 19/55] feat: hide custom settings if custom not selected --- module/config/config_updater.py | 19 +++++++++++++++++++ module/webui/app.py | 12 ++++++++++++ module/webui/base.py | 31 +++++++++++++++++++++++++++++++ module/webui/widgets.py | 24 ++++++++++++++++++++++-- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/module/config/config_updater.py b/module/config/config_updater.py index f6bb98b..8ff1886 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -610,6 +610,25 @@ class ConfigUpdater: # yield 'Dungeon.Dungeon.NameAtDoubleRelic', value pass + def iter_hidden_args(self, data) -> t.Iterator[str]: + """ + Args: + data (dict): config + + Yields: + str: Arg path that should be hidden + """ + # if deep_get(data, 'Rogue.RoguePath.PresetResonanceFilter') != 'custom': + # yield 'Rogue.RoguePath.CustomResonanceFilter' + pass + + def get_hidden_args(self, data) -> t.Set[str]: + """ + Return a set of hidden args + """ + out = list(self.iter_hidden_args(data)) + return set(out) + def read_file(self, config_name, is_template=False): """ Read and update config file. diff --git a/module/webui/app.py b/module/webui/app.py index 0f9cbc1..3682c02 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -106,6 +106,7 @@ class AlasGUI(Frame): self.alas_name = "" self.alas_mod = "alas" self.alas_config = AzurLaneConfig("template") + self.alas_config_hidden = set() self.initial() @use_scope("aside", clear=True) @@ -236,6 +237,7 @@ class AlasGUI(Frame): ) config = self.alas_config.read_file(self.alas_name) + self.alas_config_hidden = self.alas_config.get_hidden_args(config) for group, arg_dict in deep_iter(self.ALAS_ARGS[task], depth=1): if self.set_group(group, arg_dict, config, task): self.set_navigator(group) @@ -291,6 +293,9 @@ class AlasGUI(Frame): if o is not None: # output will inherit current scope when created, override here o.spec["scope"] = f"#pywebio-scope-group_{group_name}" + # Add hidden-arg + if f"{task}.{group_name}.{arg_name}" in self.alas_config_hidden: + o.style("display:none") output_list.append(o) if not output_list: @@ -507,6 +512,13 @@ class AlasGUI(Frame): logger.warning(f"Invalid value {v} for key {k}, skip saving.") self.pin_remove_invalid_mark(valid) self.pin_set_invalid_mark(invalid) + new_hidden_args = config_updater.get_hidden_args(config) + for k in new_hidden_args - self.alas_config_hidden: + self.pin_set_hidden_arg(k, type_=deep_get(self.ALAS_ARGS, f"{k}.type")) + for k in self.alas_config_hidden - new_hidden_args: + self.pin_remove_hidden_arg(k, type_=deep_get(self.ALAS_ARGS, f"{k}.type")) + self.alas_config_hidden = new_hidden_args + if modified: toast( t("Gui.Toast.ConfigSaved"), diff --git a/module/webui/base.py b/module/webui/base.py index 6834424..a115592 100644 --- a/module/webui/base.py +++ b/module/webui/base.py @@ -4,6 +4,7 @@ from pywebio.output import clear, put_html, put_scope, put_text, use_scope from pywebio.session import defer_call, info, run_js from module.webui.utils import Icon, WebIOTaskHandler, set_localstorage +from module.webui.widgets import type_to_html class Base: @@ -167,3 +168,33 @@ class Frame(Base): run_js(js) # for key in keys: # pin_update(key, valid_status=0) + + @staticmethod + def pin_set_hidden_arg(key, type_) -> None: + """ + Hide arg + + Args: + key: Path + type_: Type in _widget_type_to_func + """ + type_ = type_to_html(type_) + key = "_".join(key.split(".")) + key = f"pywebio-scope-arg_container-{type_}-{key}" + # This aims to be a typo, don't correct it, leave it as it is + if type_ == 'textarea': + key = key.replace('container', 'contianer') + js = f"""$("#{key}").css("display","none");""" + if js: + run_js(js) + + @staticmethod + def pin_remove_hidden_arg(key, type_) -> None: + type_ = type_to_html(type_) + key = "_".join(key.split(".")) + key = f"pywebio-scope-arg_container-{type_}-{key}" + if type_ == 'textarea': + key = key.replace('container', 'contianer') + js = f"""$("#{key}").removeAttr('style');""" + if js: + run_js(js) diff --git a/module/webui/widgets.py b/module/webui/widgets.py index c14ebc6..298f5cd 100644 --- a/module/webui/widgets.py +++ b/module/webui/widgets.py @@ -2,7 +2,7 @@ import copy import json import random import string -from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Union +from typing import Any, Callable, Dict, Generator, List, Optional, TYPE_CHECKING, Union from pywebio.exceptions import SessionException from pywebio.io_ctrl import Output @@ -10,7 +10,7 @@ from pywebio.output import * from pywebio.session import eval_js, local, run_js from rich.console import ConsoleRenderable -from module.logger import WEB_THEME, Highlighter, HTMLConsole +from module.logger import HTMLConsole, Highlighter, WEB_THEME from module.webui.lang import t from module.webui.pin import put_checkbox, put_input, put_select, put_textarea from module.webui.process_manager import ProcessManager @@ -424,6 +424,7 @@ def put_arg_textarea(kwargs: T_Output_Kwargs) -> Output: ) return put_scope( + # This aims to be a typo, don't correct it, leave it as it is f"arg_contianer-textarea-{name}", [ get_title_help(kwargs), @@ -507,6 +508,25 @@ def put_output(output_kwargs: T_Output_Kwargs) -> Optional[Output]: return _widget_type_to_func[output_kwargs["widget_type"]](output_kwargs) +def type_to_html(type_: str) -> str: + """ + Args: + type_: Type defined in _widget_type_to_func and argument.yaml + + Returns: + str: Html element name + """ + if type_ == "checkbox": + return "checkbox" + if type_ in ["input", "lock", "datetime"]: + return "input" + if type_ in ["select", "state"]: + return "select" + if type_ in ["textarea", "storage"]: + return "textarea" + return type_ + + def get_loading_style(shape: str, fill: bool) -> str: if fill: return f"--loading-{shape}-fill--" From 808a02ddf57a0da07e0183082791002f57bd5ccf Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:31:12 +0800 Subject: [PATCH 20/55] feat: add find_name method to retrieve Keyword instances by name --- module/ocr/keyword.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index 1c25175..5b8ad91 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -180,3 +180,24 @@ class Keyword: # Not found raise ScriptError(f'Cannot find a {cls.__name__} instance that matches "{name}"') + + @classmethod + def find_name(cls, name): + """ + Args: + name: Attribute name of keyword. + + Returns: + Keyword instance. + + Raises: + ScriptError: If nothing found. + """ + if isinstance(name, Keyword): + return name + for instance in cls.instances.values(): + if name == instance.name: + return instance + + # Not found + raise ScriptError(f'Cannot find a {cls.__name__} instance that matches "{name}"') From e8cf0a3517686244633a9921444eb5c41d6feb13 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:34:39 +0800 Subject: [PATCH 21/55] fix: extend height of log-bar in daemon overview --- module/webui/app.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/module/webui/app.py b/module/webui/app.py index 3682c02..c8fc06c 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -617,7 +617,7 @@ class AlasGUI(Frame): [ put_scope("scheduler-bar"), put_scope("groups"), - put_scope("log-bar"), + put_scope("daemon-log-bar"), put_scope("log", [put_html("")]), ], ) @@ -631,7 +631,7 @@ class AlasGUI(Frame): [ put_scope( "_daemon_upper", - [put_scope("scheduler-bar"), put_scope("log-bar")], + [put_scope("scheduler-bar"), put_scope("daemon-log-bar")], ), put_scope("groups"), put_scope("log", [put_html("")]), @@ -660,16 +660,17 @@ class AlasGUI(Frame): scope="scheduler_btn", ) - with use_scope("log-bar"): - put_text(t("Gui.Overview.Log")).style( - "font-size: 1.25rem; margin: auto .5rem auto;" - ) - put_scope( - "log-bar-btns", - [ - put_scope("log_scroll_btn"), - ], - ) + with use_scope("daemon-log-bar"): + with use_scope("log-title"): + put_text(t("Gui.Overview.Log")).style( + "font-size: 1.25rem; margin: auto .5rem auto;" + ) + put_scope( + "log-bar-btns", + [ + put_scope("log_scroll_btn"), + ], + ) switch_log_scroll = BinarySwitchButton( label_on=t("Gui.Button.ScrollON"), From fcab832be11a918e7f5f6fc4f8891f8020d38259 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:36:36 +0800 Subject: [PATCH 22/55] fix: update func_list handling to ensure proper task binding --- module/config/config.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/module/config/config.py b/module/config/config.py index af93afc..75c5580 100644 --- a/module/config/config.py +++ b/module/config/config.py @@ -3,8 +3,6 @@ import datetime import operator import threading -import pywebio - from module.base.decorator import cached_property, del_cached_property from module.base.filter import Filter from module.config.config_generated import GeneratedConfig @@ -129,13 +127,17 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher """ Args: func (str, Function): Function to run - func_list (set): Set of tasks to be bound + func_list (list[str]): List of tasks to be bound """ - if func_list is None: - func_list = ["Alas"] if isinstance(func, Function): func = func.command - func_list.append(func) + # func_list: ["Alas", , *func_list] + if func_list is None: + func_list = [] + if func not in func_list: + func_list.insert(0, func) + if "Alas" not in func_list: + func_list.insert(0, "Alas") logger.info(f"Bind task {func_list}") # Bind arguments From 7d084db10780d719db28d88388a9a5b06d9494cd Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:47:26 +0800 Subject: [PATCH 23/55] fix: do not track screenshot when save error disabled --- module/base/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/module/base/base.py b/module/base/base.py index 46ca350..d3bd400 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -307,6 +307,9 @@ class ModuleBase: """ Add a tracking image, image will be saved """ + if not self.config.Error_SaveError: + return + logger.info('screenshot_tracking_add') data = self.device.screenshot_deque[-1] image = data['image'] From af3e178c889f54497218fc014ba2ac8f8d8574d3 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:50:14 +0800 Subject: [PATCH 24/55] fix: limit length of screenshot deque --- module/device/screenshot.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/module/device/screenshot.py b/module/device/screenshot.py index cb990fa..9101a1d 100644 --- a/module/device/screenshot.py +++ b/module/device/screenshot.py @@ -98,7 +98,14 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): @cached_property def screenshot_deque(self): - return deque(maxlen=int(self.config.Error_ScreenshotLength)) + try: + length = int(self.config.Error_ScreenshotLength) + except ValueError: + logger.error(f'Error_ScreenshotLength={self.config.Error_ScreenshotLength} is not an integer') + raise RequestHumanTakeover + # Limit in 1~300 + length = max(1, min(length, 300)) + return deque(maxlen=length) @cached_property def screenshot_tracking(self): From 84ac3dc9b804b559abcbe3bcd6dfae9b022206c4 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:51:46 +0800 Subject: [PATCH 25/55] feat: add is_format_matched method and improve regex for result formatting --- module/ocr/ocr.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/module/ocr/ocr.py b/module/ocr/ocr.py index 6c3b390..a099d26 100644 --- a/module/ocr/ocr.py +++ b/module/ocr/ocr.py @@ -350,6 +350,10 @@ class DigitCounter(Ocr): def __init__(self, button: ButtonWrapper, lang='en', name=None): super().__init__(button, lang=lang, name=name) + @classmethod + def is_format_matched(cls, result) -> bool: + return '/' in result + def format_result(self, result) -> tuple[int, int, int]: """ Do OCR on a counter, such as `14/15`, and returns 14, 1, 15 @@ -360,7 +364,7 @@ class DigitCounter(Ocr): result = super().after_process(result) logger.attr(name=self.name, text=str(result)) - res = re.search(r'(\d+)/(\d+)', result) + res = re.search(r'(\d+)\s*/\s*(\d+)', result) if res: groups = [int(s) for s in res.groups()] current, total = int(groups[0]), int(groups[1]) From fc5847d6d8d26f958d7541e479700fbf7bff58ac Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:55:16 +0800 Subject: [PATCH 26/55] feat: add KeywordDigitCounter class for filtering digit counters in OCR results --- module/ocr/keyword.py | 15 +++++++++++++++ module/ocr/ocr.py | 4 +--- module/ocr/utils.py | 4 ++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index 5b8ad91..2452ab4 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -201,3 +201,18 @@ class Keyword: # Not found raise ScriptError(f'Cannot find a {cls.__name__} instance that matches "{name}"') + + +class KeywordDigitCounter(Keyword): + """ + A fake Keyword class to filter digit counters in ocr results + OcrResultButton.match_keyword will be a str + """ + + @classmethod + def find(cls, name, lang: str = None, ignore_punctuation=True): + from module.ocr.ocr import DigitCounter + if DigitCounter.is_format_matched(name): + return name + else: + raise ScriptError diff --git a/module/ocr/ocr.py b/module/ocr/ocr.py index a099d26..82fb32d 100644 --- a/module/ocr/ocr.py +++ b/module/ocr/ocr.py @@ -1,7 +1,6 @@ import re import time from datetime import timedelta -from typing import Optional import cv2 import numpy as np @@ -13,13 +12,12 @@ from module.base.decorator import cached_property from module.base.utils import area_pad, corner2area, crop, extract_white_letters, float2str from module.exception import ScriptError from module.logger import logger -from module.ocr.keyword import Keyword from module.ocr.models import OCR_MODEL, TextSystem from module.ocr.utils import merge_buttons class OcrResultButton: - def __init__(self, boxed_result: BoxedResult, matched_keyword: Optional[Keyword]): + def __init__(self, boxed_result: BoxedResult, matched_keyword): """ Args: boxed_result: BoxedResult from ppocr-onnx diff --git a/module/ocr/utils.py b/module/ocr/utils.py index 357836f..d7a1584 100644 --- a/module/ocr/utils.py +++ b/module/ocr/utils.py @@ -74,8 +74,8 @@ def pair_buttons(group1, group2, relative_area): Pair buttons in group1 with those in group2 in the relative_area. Args: - group1 (list[OcrResultButton]): - group2 (list[OcrResultButton]): + group1 (list[OcrResultButton], Iterable[OcrResultButton]): + group2 (list[OcrResultButton], Iterable[OcrResultButton]): relative_area (tuple[int, int, int, int]): Yields: From baddf23df5c5f4b11c696b44d3c8be4396fa837f Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 17:56:17 +0800 Subject: [PATCH 27/55] refactor: update scroll methods to return results for better chaining --- module/ui/scroll.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/ui/scroll.py b/module/ui/scroll.py index 623a76f..7500e9c 100644 --- a/module/ui/scroll.py +++ b/module/ui/scroll.py @@ -203,10 +203,10 @@ class Scroll: multiply = self.length / (self.total - self.length) target = current + page * multiply target = round(min(max(target, 0), 1), 3) - self.set(target, main=main, random_range=random_range, skip_first_screenshot=True) + return self.set(target, main=main, random_range=random_range, skip_first_screenshot=True) def next_page(self, main, page=0.8, random_range=(-0.01, 0.01), skip_first_screenshot=True): - self.drag_page(page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot) + return self.drag_page(page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot) def prev_page(self, main, page=0.8, random_range=(-0.01, 0.01), skip_first_screenshot=True): - self.drag_page(-page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot) + return self.drag_page(-page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot) From 64bb8e3a123135623f76eee5195496a5ed922568 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:00:40 +0800 Subject: [PATCH 28/55] feat: add method to increment value in `StoredCounter` --- module/config/stored/classes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/module/config/stored/classes.py b/module/config/stored/classes.py index 792e2cd..2bc8cc3 100644 --- a/module/config/stored/classes.py +++ b/module/config/stored/classes.py @@ -157,6 +157,9 @@ class StoredCounter(StoredBase): def get_remain(self) -> int: return self.total - self.value + def add(self, value=1): + self.value += value + @cached_property def _attrs(self) -> dict: attrs = super()._attrs From 24e9bde5c7711a4df2fda8bb6fbc8ef5eca17bc8 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:01:38 +0800 Subject: [PATCH 29/55] fix: update punctuation regex to include ellipsis --- module/ocr/keyword.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index 2452ab4..af1db53 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -7,7 +7,7 @@ import module.config.server as server from module.exception import ScriptError # ord('.') = 65294 -REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。::;;!!??·•\-—-/\\\n\t()\[\]()「」『』【】《》[]]') +REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。…::;;!!??·•\-—-/\\\n\t()\[\]()「」『』【】《》[]]') def parse_name(n): From 5ffaec0fb16cda08504fbe165ba7591b876ec7bd Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:09:57 +0800 Subject: [PATCH 30/55] feat: add stop method to Login class and implement button handling in base.py --- aas.py | 4 +++ module/alas.py | 14 +++++++++- module/base/base.py | 52 +++++++++++++++++++++++++++++++++-- module/device/app_control.py | 13 +++++---- module/device/method/utils.py | 38 +++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/aas.py b/aas.py index 4a3997c..afe1205 100644 --- a/aas.py +++ b/aas.py @@ -11,6 +11,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript): from tasks.login.login import Login Login(self.config, device=self.device).app_start() + def stop(self): + from tasks.login.login import Login + Login(self.config, device=self.device).app_stop() + def goto_main(self): from tasks.login.login import Login from tasks.base.ui import UI diff --git a/module/alas.py b/module/alas.py index 5abfd0a..34d1d7b 100644 --- a/module/alas.py +++ b/module/alas.py @@ -62,6 +62,18 @@ class AzurLaneAutoScript: logger.exception(e) exit(1) + def restart(self): + raise NotImplemented + + def start(self): + raise NotImplemented + + def stop(self): + raise NotImplemented + + def goto_main(self): + raise NotImplemented + def run(self, command): try: self.device.screenshot() @@ -211,7 +223,7 @@ class AzurLaneAutoScript: method = self.config.Optimization_WhenTaskQueueEmpty if method == 'close_game': logger.info('Close game during wait') - self.device.app_stop() + self.run('stop') release_resources() self.device.release_during_wait() if not self.wait_until(task.next_run): diff --git a/module/base/base.py b/module/base/base.py index d3bd400..06ba45f 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -6,6 +6,7 @@ from module.base.timer import Timer from module.base.utils import * from module.config.config import AzurLaneConfig from module.device.device import Device +from module.device.method.utils import HierarchyButton from module.logger import logger from module.webui.setting import cached_class_property @@ -132,9 +133,56 @@ class ModuleBase: return appear - appear = match_template + def xpath(self, xpath) -> HierarchyButton: + if isinstance(xpath, str): + return HierarchyButton(self.device.hierarchy, xpath) + else: + return xpath + + def xpath_appear(self, xpath: str, interval=0): + button = self.xpath(xpath) + + self.device.stuck_record_add(button) + + if interval and not self.interval_is_reached(button, interval=interval): + return False + + appear = bool(button) + + if appear and interval: + self.interval_reset(button, interval=interval) + + return appear + + def appear(self, button, interval=0, similarity=0.85): + """ + Args: + button (Button, ButtonWrapper, HierarchyButton, str): + interval (int, float): interval between two active events. + + Returns: + bool: + + Examples: + Template match: + ``` + self.device.screenshot() + self.appear(POPUP_CONFIRM) + ``` + + Hierarchy detection (detect elements with xpath): + ``` + self.device.dump_hierarchy() + self.appear('//*[@resource-id="..."]') + ``` + """ + if isinstance(button, (HierarchyButton, str)): + return self.xpath_appear(button, interval=interval) + else: + return self.match_template(button, interval=interval, similarity=similarity) def appear_then_click(self, button, interval=5, similarity=0.85): + button = self.xpath(button) appear = self.appear(button, interval=interval, similarity=similarity) if appear: self.device.click(button) @@ -309,7 +357,7 @@ class ModuleBase: """ if not self.config.Error_SaveError: return - + logger.info('screenshot_tracking_add') data = self.device.screenshot_deque[-1] image = data['image'] diff --git a/module/device/app_control.py b/module/device/app_control.py index 390c505..cf6c3a5 100644 --- a/module/device/app_control.py +++ b/module/device/app_control.py @@ -49,11 +49,14 @@ class AppControl(Adb, WSA, Uiautomator2): Returns: etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example. """ - method = self.config.Emulator_ControlMethod - if method in AppControl._app_u2_family: - self.hierarchy = self.dump_hierarchy_uiautomator2() - else: - self.hierarchy = self.dump_hierarchy_adb() + # method = self.config.Emulator_ControlMethod + # if method in AppControl._app_u2_family: + # self.hierarchy = self.dump_hierarchy_uiautomator2() + # else: + # self.hierarchy = self.dump_hierarchy_adb() + + # Using uiautomator2 + self.hierarchy = self.dump_hierarchy_uiautomator2() return self.hierarchy def xpath_to_button(self, xpath: str) -> HierarchyButton: diff --git a/module/device/method/utils.py b/module/device/method/utils.py index 67bd654..9f81352 100644 --- a/module/device/method/utils.py +++ b/module/device/method/utils.py @@ -18,9 +18,11 @@ except ImportError: # We expect `screencap | nc 192.168.0.1 20298` instead of `screencap '|' nc 192.168.80.1 20298` import adbutils import subprocess + adbutils._utils.list2cmdline = subprocess.list2cmdline adbutils._device.list2cmdline = subprocess.list2cmdline + # BaseDevice.shell() is missing a check_okay() call before reading output, # resulting in an `OKAY` prefix in output. def shell(self, @@ -40,6 +42,7 @@ except ImportError: output = c.read_until_close() return output.rstrip() if rstrip else output + adbutils._device.BaseDevice.shell = shell from module.base.decorator import cached_property @@ -323,7 +326,7 @@ class HierarchyButton: if res: return res[0] else: - return 'HierarchyButton' + return self.xpath @cached_property def count(self): @@ -333,10 +336,17 @@ class HierarchyButton: def exist(self): return self.count == 1 + @cached_property + def attrib(self): + if self.exist: + return self.nodes[0].attrib + else: + return {} + @cached_property def area(self): if self.exist: - bounds = self.nodes[0].attrib.get("bounds") + bounds = self.attrib.get("bounds") lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds)) return lx, ly, rx, ry else: @@ -355,6 +365,28 @@ class HierarchyButton: @cached_property def focused(self): if self.exist: - return self.nodes[0].attrib.get("focused").lower() == 'true' + return self.attrib.get("focused").lower() == 'true' else: return False + + @cached_property + def text(self): + if self.exist: + return self.attrib.get("text").strip() + else: + return "" + + +class AreaButton: + def __init__(self, area, name='AREA_BUTTON'): + self.area = area + self.color = () + self.name = name + self.button = area + + def __str__(self): + return self.name + + def __bool__(self): + # Cannot appear + return False From 11f5f1dd642fef1be81ac90365342d598e3789d8 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:11:40 +0800 Subject: [PATCH 31/55] fix: copy raw attributes of first frame to all --- dev_tools/button_extract.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/dev_tools/button_extract.py b/dev_tools/button_extract.py index d08a5a2..6011c73 100644 --- a/dev_tools/button_extract.py +++ b/dev_tools/button_extract.py @@ -108,6 +108,11 @@ class DataAssets: color: t.Tuple[int, int, int] = () button: t.Tuple[int, int, int, int] = () + has_raw_area = False + has_raw_search = False + has_raw_color = False + has_raw_button = False + @staticmethod def area_to_search(area): area = area_pad(area, pad=-20) @@ -131,12 +136,16 @@ class DataAssets: self.button = image.bbox elif image.attr == 'AREA': self.area = image.bbox + self.has_raw_area = True elif image.attr == 'SEARCH': self.search = image.bbox + self.has_raw_search = True elif image.attr == 'COLOR': self.color = image.mean + self.has_raw_color = True elif image.attr == 'BUTTON': self.button = image.bbox + self.has_raw_button = True else: logger.warning(f'Trying to load an image with unknown attribute: {image}') @@ -182,19 +191,22 @@ def iter_assets(): # Set `search` for path, frames in deep_iter(data, depth=3): print(path, frames) - # If `search` attribute is set in the first frame, apply to all - first = frames[1] - if first.search: - for frame in frames.values(): + for frame in frames.values(): + # Generate `search` from `area` + if not frame.has_raw_search: + frame.search = DataAssets.area_to_search(frame.area) + # If an attribute is set in the first frame, apply to all + first: DataAssets = frames[1] + for frame in frames.values(): + # frame: DataAssets = frame + if not frame.has_raw_area and first.has_raw_area: + frame.area = first.area + if not frame.has_raw_search and first.has_raw_search: frame.search = first.search - else: - for frame in frames.values(): - if frame.search: - # Follow frame specific `search` - pass - else: - # Generate `search` from `area` - frame.search = DataAssets.area_to_search(frame.area) + if not frame.has_raw_color and first.has_raw_color: + frame.color = first.color + if not frame.has_raw_button and first.has_raw_button: + frame.button = first.button return data From 0590eebd44ec4ce243e9bd55cb6b08b69785b9f6 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:15:19 +0800 Subject: [PATCH 32/55] fix: handle FileNotFoundError when accessing Windows registry keys --- module/device/platform/emulator_windows.py | 55 +++++++++++++--------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/module/device/platform/emulator_windows.py b/module/device/platform/emulator_windows.py index a317ad4..2a0d131 100644 --- a/module/device/platform/emulator_windows.py +++ b/module/device/platform/emulator_windows.py @@ -5,11 +5,11 @@ import typing as t import winreg from dataclasses import dataclass +from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase # module/device/platform/emulator_base.py # module/device/platform/emulator_windows.py # Will be used in Alas Easy Install, they shouldn't import any Alas modules. from module.device.platform.utils import cached_property, iter_folder -from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase @dataclass @@ -330,8 +330,12 @@ class EmulatorManager(EmulatorManagerBase): path = r'Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist' # {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}\xxx.exe regex_hash = re.compile(r'{.*}') - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: - folders = list_key(reg) + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: + folders = list_key(reg) + except FileNotFoundError: + return + for folder in folders: try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, f'{path}\\{folder}\\Count') as reg: @@ -358,8 +362,11 @@ class EmulatorManager(EmulatorManagerBase): str: Path to emulator executable, may contains duplicate values """ path = r'Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache' - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: - rows = list_reg(reg) + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: + rows = list_reg(reg) + except FileNotFoundError: + return regex = re.compile(r'(^.*\.exe)\.') for row in rows: @@ -425,24 +432,28 @@ class EmulatorManager(EmulatorManagerBase): 'MEmu', ] for path in known_uninstall_registry_path: - with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg: - for software in list_key(reg): - if software not in known_emulator_registry_name: - continue + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg: + software_list = list_key(reg) + except FileNotFoundError: + continue + for software in software_list: + if software not in known_emulator_registry_name: + continue + try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg: - try: - uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0] - except FileNotFoundError: - continue - if not uninstall: - continue - # UninstallString is like: - # C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp - # "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u - # Extract path in "" - res = re.search('"(.*?)"', uninstall) - uninstall = res.group(1) if res else uninstall - yield uninstall + uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0] + except FileNotFoundError: + continue + if not uninstall: + continue + # UninstallString is like: + # C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp + # "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u + # Extract path in "" + res = re.search('"(.*?)"', uninstall) + uninstall = res.group(1) if res else uninstall + yield uninstall @cached_property def all_emulators(self) -> t.List[Emulator]: From be44b23d702f1841bf7e7460c053a5777da98f6c Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:19:44 +0800 Subject: [PATCH 33/55] fix: support emulator index parsing from MuMu ArkNights version --- module/device/platform/emulator_base.py | 12 ++++++++---- module/device/platform/platform_windows.py | 6 +++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/module/device/platform/emulator_base.py b/module/device/platform/emulator_base.py index cdb4759..19262b0 100644 --- a/module/device/platform/emulator_base.py +++ b/module/device/platform/emulator_base.py @@ -83,8 +83,9 @@ class EmulatorInstanceBase: def MuMuPlayer12_id(self): """ Convert MuMu 12 instance name to instance id. - Example name: MuMuPlayer-12.0-3 - Example ID : 3 + Example names: + MuMuPlayer-12.0-3 + YXArkNights-12.0-1 Returns: int: Instance ID, or None if this is not a MuMu 12 instance @@ -92,8 +93,11 @@ class EmulatorInstanceBase: res = re.search(r'MuMuPlayer-12.0-(\d+)', self.name) if res: return int(res.group(1)) - else: - return None + res = re.search(r'YXArkNights-12.0-(\d+)', self.name) + if res: + return int(res.group(1)) + + return None class EmulatorBase: diff --git a/module/device/platform/platform_windows.py b/module/device/platform/platform_windows.py index 0490156..229f011 100644 --- a/module/device/platform/platform_windows.py +++ b/module/device/platform/platform_windows.py @@ -8,8 +8,8 @@ from deploy.Windows.utils import DataProcessInfo from module.base.decorator import run_once from module.base.timer import Timer from module.device.connection import AdbDeviceWithStatus -from module.device.platform.platform_base import PlatformBase from module.device.platform.emulator_windows import Emulator, EmulatorInstance, EmulatorManager +from module.device.platform.platform_base import PlatformBase from module.logger import logger @@ -91,6 +91,8 @@ class PlatformWindows(PlatformBase, EmulatorManager): self.execute(f'"{exe}" -m {instance.name}') elif instance == Emulator.MuMuPlayer12: # MuMuPlayer.exe -v 0 + if instance.MuMuPlayer12_id is None: + logger.warning(f'Cannot get MuMu instance index from name {instance.name}') self.execute(f'"{exe}" -v {instance.MuMuPlayer12_id}') elif instance == Emulator.NoxPlayerFamily: # Nox.exe -clone:Nox_1 @@ -142,6 +144,8 @@ class PlatformWindows(PlatformBase, EmulatorManager): # MuMu 12 has 2 processes: # E:\ProgramFiles\Netease\MuMuPlayer-12.0\shell\MuMuPlayer.exe -v 0 # "C:\Program Files\MuMuVMMVbox\Hypervisor\MuMuVMMHeadless.exe" --comment MuMuPlayer-12.0-0 --startvm xxx + if instance.MuMuPlayer12_id is None: + logger.warning(f'Cannot get MuMu instance index from name {instance.name}') self.kill_process_by_regex( rf'(' rf'MuMuVMMHeadless.exe.*--comment {instance.name}' From 52964c2ff023086c316d26d35be3ddedbbbcfbe4 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:21:29 +0800 Subject: [PATCH 34/55] feat: add TaskError exception for task error handling --- module/exception.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/module/exception.py b/module/exception.py index e9bf713..0212f8a 100644 --- a/module/exception.py +++ b/module/exception.py @@ -29,6 +29,12 @@ class GamePageUnknownError(Exception): pass +class TaskError(Exception): + # An error occurred in task, + # task itself should have error handled before raising TaskError, + # then task will be re-scheduled + pass + class RequestHumanTakeover(Exception): # Request human takeover # Alas is unable to handle such error, probably because of wrong settings. From 97c3ed30e9116639299f11c299852a7020554d26 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:25:31 +0800 Subject: [PATCH 35/55] feat: add `set_search_offset()` to be compatible with Alas --- module/base/button.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/module/base/button.py b/module/base/button.py index d1d14ca..1f69cda 100644 --- a/module/base/button.py +++ b/module/base/button.py @@ -306,6 +306,34 @@ class ButtonWrapper(Resource): for b in self.iter_buttons(): b.search = area + def set_search_offset(self, offset): + """ + Compatible with Alas’ `offset` attribute + In ALAS: + if self.appear(BUTTON, offset=(20, 20)): + pass + In SRC: + BUTTON.set_search_offset((20, 20)) + if self.appear(BUTTON): + pass + Note that `search` attribute will be set, and it's irreversible. + + Args: + offset (tuple): (x, y) or (left, up, right, bottom) + """ + if len(offset) == 2: + left, up, right, bottom = -offset[0], -offset[1], offset[0], offset[1] + else: + left, up, right, bottom = offset + for b in self.iter_buttons(): + upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = b.area + b.search = ( + upper_left_x + left, + upper_left_y + up, + bottom_right_x + right, + bottom_right_y + bottom, + ) + class ClickButton: def __init__(self, area, button=None, name='CLICK_BUTTON'): From d017d9eefd502b07039210c5e97cbd92220453df Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:26:01 +0800 Subject: [PATCH 36/55] fix: update REGEX_PUNCTUATION to include additional symbols --- module/ocr/keyword.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index af1db53..d7562ae 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -7,7 +7,7 @@ import module.config.server as server from module.exception import ScriptError # ord('.') = 65294 -REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。…::;;!!??·•\-—-/\\\n\t()\[\]()「」『』【】《》[]]') +REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。…::;;!!??·・•●〇°*※\-—-/\\\n\t()\[\]()「」『』【】《》[]]') def parse_name(n): From d7db56eb5a867311882be0aa57bd0cfe7fcf6111 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:27:58 +0800 Subject: [PATCH 37/55] fix: update input prompts in screenshot.py --- dev_tools/screenshot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev_tools/screenshot.py b/dev_tools/screenshot.py index 90ce49b..7038754 100644 --- a/dev_tools/screenshot.py +++ b/dev_tools/screenshot.py @@ -2,8 +2,8 @@ import os from datetime import datetime from PIL import Image - from pynput import keyboard + from module.config.config import AzurLaneConfig from module.config.utils import alas_instance from module.device.connection import Connection, ConnectionAttr @@ -50,12 +50,12 @@ def handle_sensitive_info(image): _ = EmptyConnection() name = input( - '输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n' - '例如:"alas", "127.0.0.1:16384", "7555"\n' + '输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n' + '例如:"aas", "127.0.0.1:16384", "7555"\n' ) name = name.strip().strip('"').strip() if not name: - name = 'alas' + name = 'aas' if name.isdigit(): name = f'127.0.0.1:{name}' if name in alas_instance(): From 6a4b1998fc8a11f499bf20931b5d88412eaa2ec7 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:29:59 +0800 Subject: [PATCH 38/55] fix: ignore files that are added by deployer --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 5a75e60..6ac43b5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,14 @@ config/reloadalas test.py test/ note.md +resources/ +locales/ +*.pak +*.dll +*.dat +v8_context_snapshot.bin +snapshot_blob.bin +vk_swiftshader_icd.json # Created by .ignore support plugin (hsz.mobi) From 87975760ae95eca4923ebe9a038487ad421a1c61 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:33:25 +0800 Subject: [PATCH 39/55] feat: proxy element props --- module/device/method/utils.py | 82 ++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/module/device/method/utils.py b/module/device/method/utils.py index 9f81352..6e89839 100644 --- a/module/device/method/utils.py +++ b/module/device/method/utils.py @@ -352,6 +352,14 @@ class HierarchyButton: else: return None + @cached_property + def size(self): + if self.area is not None: + lx, ly, rx, ry = self.area + return rx - lx, ry - ly + else: + return None + @cached_property def button(self): return self.area @@ -362,19 +370,71 @@ class HierarchyButton: def __str__(self): return self.name - @cached_property - def focused(self): - if self.exist: - return self.attrib.get("focused").lower() == 'true' - else: - return False + """ + Element props + """ + + def _get_bool_prop(self, prop: str) -> bool: + return self.attrib.get(prop, "").lower() == 'true' @cached_property - def text(self): - if self.exist: - return self.attrib.get("text").strip() - else: - return "" + def index(self) -> int: + try: + return int(self.attrib.get("index", 0)) + except IndexError: + return 0 + + @cached_property + def text(self) -> str: + return self.attrib.get("text", "").strip() + + @cached_property + def resourceId(self) -> str: + return self.attrib.get("resourceId", "").strip() + + @cached_property + def package(self) -> str: + return self.attrib.get("resourceId", "").strip() + + @cached_property + def description(self) -> str: + return self.attrib.get("resourceId", "").strip() + + @cached_property + def checkable(self) -> bool: + return self._get_bool_prop('checkable') + + @cached_property + def clickable(self) -> bool: + return self._get_bool_prop('clickable') + + @cached_property + def enabled(self) -> bool: + return self._get_bool_prop('enabled') + + @cached_property + def fucusable(self) -> bool: + return self._get_bool_prop('fucusable') + + @cached_property + def focused(self) -> bool: + return self._get_bool_prop('focused') + + @cached_property + def scrollable(self) -> bool: + return self._get_bool_prop('scrollable') + + @cached_property + def longClickable(self) -> bool: + return self._get_bool_prop('longClickable') + + @cached_property + def password(self) -> bool: + return self._get_bool_prop('password') + + @cached_property + def selected(self) -> bool: + return self._get_bool_prop('selected') class AreaButton: From d806c5e9e71dee76b51c77b7e4beae20741645d0 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:35:33 +0800 Subject: [PATCH 40/55] fix: exclude hidden files from asset extraction --- dev_tools/button_extract.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dev_tools/button_extract.py b/dev_tools/button_extract.py index 6011c73..14a3b96 100644 --- a/dev_tools/button_extract.py +++ b/dev_tools/button_extract.py @@ -92,8 +92,9 @@ def iter_images(): for server in ASSET_SERVER: for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)): for file in files: - file = os.path.join(path, file).replace('\\', '/') - yield AssetsImage(file) + if not file.startswith('.'): + file = os.path.join(path, file).replace('\\', '/') + yield AssetsImage(file) @dataclass From cac19c10c31fd1f37145fcc61bed409752dc294a Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:38:33 +0800 Subject: [PATCH 41/55] fix: enhance auto device detection, and ignore 127.0.0.1:7555 from MuMu12 --- module/device/connection.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/module/device/connection.py b/module/device/connection.py index 87705a6..844786c 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -84,6 +84,11 @@ class AdbDeviceWithStatus(AdbDevice): def __bool__(self): return True + @cached_property + def may_mumu12_family(self): + # 127.0.0.1:16XXX + return len(self.serial) == 15 and self.serial.startswith('127.0.0.1:16') + class Connection(ConnectionAttr): def __init__(self, config): @@ -778,7 +783,16 @@ class Connection(ConnectionAttr): raise RequestHumanTakeover elif available.count == 1: logger.info(f'Auto device detection found only one device, using it') - self.serial = devices[0].serial + self.serial = available[0].serial + del_cached_property(self, 'adb') + elif available.count == 2 \ + and available.select(serial='127.0.0.1:7555') \ + and available.select(may_mumu12_family=True): + logger.info(f'Auto device detection found MuMu12 device, using it') + # For MuMu12 serials like 127.0.0.1:7555 and 127.0.0.1:16384 + # ignore 7555 use 16384 + remain = available.select(may_mumu12_family=True).first_or_none() + self.serial = remain.serial del_cached_property(self, 'adb') else: logger.critical('Multiple devices found, auto device detection cannot decide which to choose, ' From 75b8b78b245a122716e9a9d150782790232eb3e1 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:39:45 +0800 Subject: [PATCH 42/55] refactor: rename list_azurlane_packages to list_known_packages for clarity --- module/device/connection.py | 4 ++-- module/device/platform/platform_windows.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/module/device/connection.py b/module/device/connection.py index 844786c..e72b687 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -848,7 +848,7 @@ class Connection(ConnectionAttr): packages = re.findall(r'package:([^\s]+)', output) return packages - def list_azurlane_packages(self, show_log=True): + def list_known_packages(self, show_log=True): """ Args: show_log: @@ -865,7 +865,7 @@ class Connection(ConnectionAttr): Show all possible packages with the given keyword on this device. """ logger.hr('Detect package') - packages = self.list_azurlane_packages() + packages = self.list_known_packages() # Show packages logger.info(f'Here are the available packages in device "{self.serial}", ' diff --git a/module/device/platform/platform_windows.py b/module/device/platform/platform_windows.py index 229f011..5b492e8 100644 --- a/module/device/platform/platform_windows.py +++ b/module/device/platform/platform_windows.py @@ -267,7 +267,7 @@ class PlatformWindows(PlatformBase, EmulatorManager): show_ping(pong) # Check azuelane package - packages = self.list_azurlane_packages(show_log=False) + packages = self.list_known_packages(show_log=False) if len(packages): pass else: From d0e6c78cc11ebc84e025af38e67c49490f7fa558 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:43:46 +0800 Subject: [PATCH 43/55] feat: autofill emulator info even not used --- module/device/device.py | 4 ++++ module/device/platform/emulator_windows.py | 2 +- module/device/platform/platform_base.py | 5 ++--- module/device/platform/platform_windows.py | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/module/device/device.py b/module/device/device.py index a9d92c7..a764607 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -82,6 +82,10 @@ class Device(Screenshot, Control, AppControl, Platform): ) raise + # Auto-fill emulator info + if self.config.EmulatorInfo_Emulator == 'auto': + _ = self.emulator_instance + self.screenshot_interval_set() # Auto-select the fastest screenshot method diff --git a/module/device/platform/emulator_windows.py b/module/device/platform/emulator_windows.py index 2a0d131..f941833 100644 --- a/module/device/platform/emulator_windows.py +++ b/module/device/platform/emulator_windows.py @@ -5,10 +5,10 @@ import typing as t import winreg from dataclasses import dataclass -from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase # module/device/platform/emulator_base.py # module/device/platform/emulator_windows.py # Will be used in Alas Easy Install, they shouldn't import any Alas modules. +from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase from module.device.platform.utils import cached_property, iter_folder diff --git a/module/device/platform/platform_base.py b/module/device/platform/platform_base.py index 9f47cea..e48da23 100644 --- a/module/device/platform/platform_base.py +++ b/module/device/platform/platform_base.py @@ -1,14 +1,13 @@ import sys import typing as t -import yaml -from pydantic import BaseModel, SecretStr +from pydantic import BaseModel from module.base.decorator import cached_property, del_cached_property +from module.base.utils import SelectedGrids from module.device.connection import Connection from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase from module.logger import logger -from module.base.utils import SelectedGrids class EmulatorInfo(BaseModel): diff --git a/module/device/platform/platform_windows.py b/module/device/platform/platform_windows.py index 5b492e8..e18fb98 100644 --- a/module/device/platform/platform_windows.py +++ b/module/device/platform/platform_windows.py @@ -317,4 +317,5 @@ class PlatformWindows(PlatformBase, EmulatorManager): if __name__ == '__main__': self = PlatformWindows('alas') - self.emulator_start() + d = self.emulator_instance + print(d) From ce8eb1c45223171858cedc661fcff1fa50505fc5 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:47:10 +0800 Subject: [PATCH 44/55] refactor: streamline platform imports and remove redundant type method --- module/device/device.py | 9 ++------- module/device/platform/__init__.py | 6 ++++++ module/device/platform/emulator_base.py | 2 +- module/device/platform/emulator_windows.py | 8 -------- 4 files changed, 9 insertions(+), 16 deletions(-) create mode 100644 module/device/platform/__init__.py diff --git a/module/device/device.py b/module/device/device.py index a764607..455960f 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -1,9 +1,9 @@ import collections -import sys from module.base.timer import Timer from module.device.app_control import AppControl from module.device.control import Control +from module.device.platform import Platform from module.device.screenshot import Screenshot from module.exception import ( EmulatorNotRunningError, @@ -14,11 +14,6 @@ from module.exception import ( ) from module.logger import logger -if sys.platform == 'win32': - from module.device.platform.platform_windows import PlatformWindows as Platform -else: - from module.device.platform.platform_base import PlatformBase as Platform - def show_function_call(): """ @@ -85,7 +80,7 @@ class Device(Screenshot, Control, AppControl, Platform): # Auto-fill emulator info if self.config.EmulatorInfo_Emulator == 'auto': _ = self.emulator_instance - + self.screenshot_interval_set() # Auto-select the fastest screenshot method diff --git a/module/device/platform/__init__.py b/module/device/platform/__init__.py new file mode 100644 index 0000000..0dbc4b9 --- /dev/null +++ b/module/device/platform/__init__.py @@ -0,0 +1,6 @@ +import sys + +if sys.platform == 'win32': + from module.device.platform.platform_windows import PlatformWindows as Platform +else: + from module.device.platform.platform_base import PlatformBase as Platform diff --git a/module/device/platform/emulator_base.py b/module/device/platform/emulator_base.py index 19262b0..ecd026b 100644 --- a/module/device/platform/emulator_base.py +++ b/module/device/platform/emulator_base.py @@ -54,7 +54,7 @@ class EmulatorInstanceBase: Returns: str: Emulator type, such as Emulator.NoxPlayer """ - return EmulatorBase.path_to_type(self.path) + return self.emulator.type @cached_property def emulator(self): diff --git a/module/device/platform/emulator_windows.py b/module/device/platform/emulator_windows.py index f941833..ff69127 100644 --- a/module/device/platform/emulator_windows.py +++ b/module/device/platform/emulator_windows.py @@ -56,14 +56,6 @@ def abspath(path): class EmulatorInstance(EmulatorInstanceBase): - @cached_property - def type(self) -> str: - """ - Returns: - str: Emulator type, such as Emulator.NoxPlayer - """ - return Emulator.path_to_type(self.path) - @cached_property def emulator(self): """ From d48427abb21825c651d0abc8bced21b2fc1cfe34 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:49:35 +0800 Subject: [PATCH 45/55] refactor: cache `nemud.app_keep_alive` --- module/device/connection.py | 9 +++++++-- module/device/connection_attr.py | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/module/device/connection.py b/module/device/connection.py index e72b687..241c0aa 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -271,15 +271,20 @@ class Connection(ConnectionAttr): return True return False + @cached_property + def nemud_app_keep_alive(self) -> str: + res = self.adb_getprop('nemud.app_keep_alive') + return res + @retry def check_mumu_app_keep_alive(self): if not self.is_mumu_family: return False - res = self.adb_getprop('nemud.app_keep_alive') + res = self.nemud_app_keep_alive logger.attr('nemud.app_keep_alive', res) if res == '': - # Empry property, might not be a mumu emulator or might be an old mumu + # Empty property, probably MuMu6 or MuMu12 version < 3.5.6 return True elif res == 'false': # Disabled diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py index cfa78a9..6bf50f6 100644 --- a/module/device/connection_attr.py +++ b/module/device/connection_attr.py @@ -7,7 +7,6 @@ from adbutils import AdbClient, AdbDevice from module.base.decorator import cached_property from module.config.config import AzurLaneConfig -from module.config.utils import deep_iter from module.exception import RequestHumanTakeover from module.logger import logger @@ -127,7 +126,12 @@ class ConnectionAttr: def is_mumu_family(self): # 127.0.0.1:7555 # 127.0.0.1:16384 + 32*n - return self.serial == '127.0.0.1:7555' or self.serial.startswith('127.0.0.1:16') + return self.serial == '127.0.0.1:7555' or self.is_mumu12_family + + @cached_property + def is_mumu12_family(self): + # 127.0.0.1:16384 + 32*n + return len(self.serial) == 15 and self.serial.startswith('127.0.0.1:16') @cached_property def is_emulator(self): From d34c082ef376f4d4aa2341d135074f7232c8b500 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:51:41 +0800 Subject: [PATCH 46/55] feat: add `AdaptiveScroll` to handle transparent scroll on complex background --- module/ui/scroll.py | 51 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/module/ui/scroll.py b/module/ui/scroll.py index 7500e9c..358e6a0 100644 --- a/module/ui/scroll.py +++ b/module/ui/scroll.py @@ -1,9 +1,10 @@ import numpy as np +from scipy import signal from module.base.base import ModuleBase from module.base.button import Button, ButtonWrapper from module.base.timer import Timer -from module.base.utils import color_similarity_2d, random_rectangle_point +from module.base.utils import color_similarity_2d, random_rectangle_point, rgb2gray from module.logger import logger @@ -46,7 +47,7 @@ class Scroll: Returns: np.ndarray: Shape (n,), dtype bool. """ - image = main.image_crop(self.area) + image = main.image_crop(self.area, copy=False) image = color_similarity_2d(image, color=self.color) mask = np.max(image, axis=1 if self.is_vertical else 0) > self.color_threshold self.length = np.sum(mask) @@ -210,3 +211,49 @@ class Scroll: def prev_page(self, main, page=0.8, random_range=(-0.01, 0.01), skip_first_screenshot=True): return self.drag_page(-page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot) + + +class AdaptiveScroll(Scroll): + def __init__(self, area, parameters: dict = None, background=5, is_vertical=True, name='Scroll'): + """ + Args: + area (Button, tuple): A button or area of the whole scroll. + prominence (dict): Parameters passing to scipy.find_peaks + background (int): + is_vertical (bool): True if vertical, false if horizontal. + name (str): + """ + if parameters is None: + parameters = {} + self.parameters = parameters + self.background = background + super().__init__(area, color=(255, 255, 255), is_vertical=is_vertical, name=name) + + def match_color(self, main): + if self.is_vertical: + area = (self.area[0] - self.background, self.area[1], self.area[2] + self.background, self.area[3]) + image = main.image_crop(area, copy=False) + image = rgb2gray(image) + image = image.flatten() + wlen = area[2] - area[0] + else: + area = (self.area[0], self.area[1] - self.background, self.area[2], self.area[3] + self.background) + image = main.image_crop(area, copy=False) + image = rgb2gray(image) + image = image.flatten('F') + wlen = area[3] - area[1] + + parameters = { + 'height': 128, + 'prominence': 30, + 'wlen': wlen, + 'width': 2, + } + parameters.update(self.parameters) + peaks, _ = signal.find_peaks(image, **parameters) + peaks //= wlen + + self.length = len(peaks) + mask = np.zeros((self.total,), dtype=np.bool_) + mask[peaks] = 1 + return mask From aa4afbf6a265f6dbd4f012550e2a9c765fee63f9 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:55:37 +0800 Subject: [PATCH 47/55] perf: reuse image array to improve memory performance --- module/device/method/adb.py | 8 +- module/device/method/ascreencap.py | 7 +- module/device/method/droidcast.py | 132 +++++++++++++------------- module/device/method/uiautomator_2.py | 7 +- 4 files changed, 74 insertions(+), 80 deletions(-) diff --git a/module/device/method/adb.py b/module/device/method/adb.py index 282447c..9a24b2d 100644 --- a/module/device/method/adb.py +++ b/module/device/method/adb.py @@ -1,16 +1,16 @@ import re +import time from functools import wraps import cv2 import numpy as np -import time from adbutils.errors import AdbError from lxml import etree from module.base.decorator import Config from module.device.connection import Connection -from module.device.method.utils import (RETRY_TRIES, retry_sleep, remove_prefix, handle_adb_error, - ImageTruncated, PackageNotInstalled) +from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, + remove_prefix, retry_sleep) from module.exception import RequestHumanTakeover, ScriptError from module.logger import logger @@ -128,7 +128,7 @@ class Adb(Connection): if image is None: raise ImageTruncated('Empty image after cv2.imdecode') - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image) if image is None: raise ImageTruncated('Empty image after cv2.cvtColor') diff --git a/module/device/method/ascreencap.py b/module/device/method/ascreencap.py index 10ac111..165e452 100644 --- a/module/device/method/ascreencap.py +++ b/module/device/method/ascreencap.py @@ -6,8 +6,7 @@ from adbutils.errors import AdbError from module.base.utils import * from module.device.connection import Connection -from module.device.method.utils import (RETRY_TRIES, retry_sleep, - handle_adb_error, ImageTruncated) +from module.device.method.utils import (ImageTruncated, RETRY_TRIES, handle_adb_error, retry_sleep) from module.exception import RequestHumanTakeover, ScriptError from module.logger import logger @@ -165,11 +164,11 @@ class AScreenCap(Connection): # ValueError: cannot reshape array of size 0 into shape (720,1280,4) raise ImageTruncated(str(e)) - image = cv2.flip(image, 0) + image = cv2.flip(image, 0, dst=image) if image is None: raise ImageTruncated('Empty image after cv2.flip') - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image) if image is None: raise ImageTruncated('Empty image after cv2.cvtColor') diff --git a/module/device/method/droidcast.py b/module/device/method/droidcast.py index 39baeb4..a0adb37 100644 --- a/module/device/method/droidcast.py +++ b/module/device/method/droidcast.py @@ -6,11 +6,11 @@ import numpy as np import requests from adbutils.errors import AdbError -from module.base.decorator import Config, cached_property, del_cached_property +from module.base.decorator import cached_property, del_cached_property from module.base.timer import Timer -from module.device.method.uiautomator_2 import Uiautomator2, ProcessInfo -from module.device.method.utils import (retry_sleep, RETRY_TRIES, handle_adb_error, - ImageTruncated, PackageNotInstalled) +from module.device.method.uiautomator_2 import ProcessInfo, Uiautomator2 +from module.device.method.utils import ( + ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, retry_sleep) from module.exception import RequestHumanTakeover from module.logger import logger @@ -91,7 +91,7 @@ def retry(func): class DroidCast(Uiautomator2): """ DroidCast, another screenshot method, https://github.com/rayworks/DroidCast - DroidCast_raw, a modified version of DroidCast sending raw bitmap https://github.com/Torther/DroidCastS + DroidCast_raw, a modified version of DroidCast sending raw bitmap and png, https://github.com/Torther/DroidCastS """ _droidcast_port: int = 0 @@ -103,92 +103,74 @@ class DroidCast(Uiautomator2): self._droidcast_port = self.adb_forward('tcp:53516') return session - def droidcast_url(self, url='/screenshot?format=png'): - """ - Check APIs from source code: - https://github.com/rayworks/DroidCast/blob/master/app/src/main/java/com/rayworks/droidcast/Main.java + """ + Check APIs from source code: + https://github.com/Torther/DroidCast_raw/blob/DroidCast_raw/app/src/main/java/ink/mol/droidcast_raw/KtMain.kt + Available APIs: + - /screenshot + To get a RGB565 bitmap + - /preview + To get PNG screenshots. + """ - Available APIs: - - /screenshot - To get JPG screenshots. - - /screenshot?format=png - To get PNG screenshots. - - /screenshot?format=webp - To get WEBP screenshots. - - /src - Websocket to get JPG screenshots. + def droidcast_url(self, url='/preview'): + return f'http://127.0.0.1:{self._droidcast_port}{url}' - Note that /screenshot?format=jpg is unavailable. - """ + def droidcast_raw_url(self, url='/screenshot'): return f'http://127.0.0.1:{self._droidcast_port}{url}' - @Config.when(DROIDCAST_VERSION='DroidCast') def droidcast_init(self): - logger.hr('Droidcast init') + logger.hr('DroidCast init') self.droidcast_stop() logger.info('Pushing DroidCast apk') self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE) logger.info('Starting DroidCast apk') - # CLASSPATH=/data/local/tmp/DroidCast.apk app_process / com.rayworks.droidcast.Main > /dev/null + # DroidCast_raw-release-1.0.apk + # CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main > /dev/null + # adb shell CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main resp = self.u2_shell_background([ - 'CLASSPATH=/data/local/tmp/DroidCast.apk', + 'CLASSPATH=/data/local/tmp/DroidCast_raw.apk', 'app_process', '/', - 'com.rayworks.droidcast.Main', + 'ink.mol.droidcast_raw.Main', '>', '/dev/null' ]) logger.info(resp) - del_cached_property(self, 'droidcast_session') _ = self.droidcast_session - logger.attr('DroidCast', self.droidcast_url()) - self.droidcast_wait_startup() - @Config.when(DROIDCAST_VERSION='DroidCast_raw') - def droidcast_init(self): - logger.hr('Droidcast init') - self.resolution_check_uiautomator2() - self.droidcast_stop() - - logger.info('Pushing DroidCast apk') - self.adb_push(self.config.DROIDCAST_RAW_FILEPATH_LOCAL, self.config.DROIDCAST_RAW_FILEPATH_REMOTE) - - logger.info('Starting DroidCast apk') - # DroidCastS-release-1.1.5.apk - # CLASSPATH=/data/local/tmp/DroidCastS-release-1.1.5.apk app_process / com.torther.droidcasts.Main > /dev/null - resp = self.u2_shell_background([ - 'CLASSPATH=/data/local/tmp/DroidCastS.apk', - 'app_process', - '/', - 'com.torther.droidcasts.Main', - '>', - '/dev/null' - ]) - logger.info(resp) - - del_cached_property(self, 'droidcast_session') - _ = self.droidcast_session - logger.attr('DroidCast', self.droidcast_url()) - self.droidcast_wait_startup() + if self.config.DROIDCAST_VERSION == 'DroidCast': + logger.attr('DroidCast', self.droidcast_url()) + self.droidcast_wait_startup() + elif self.config.DROIDCAST_VERSION == 'DroidCast_raw': + logger.attr('DroidCast_raw', self.droidcast_raw_url()) + self.droidcast_wait_startup() + else: + logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}') @retry def screenshot_droidcast(self): self.config.DROIDCAST_VERSION = 'DroidCast' - image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content + resp = self.droidcast_session.get(self.droidcast_url(), timeout=3) + if resp.status_code == 404: + raise DroidCastVersionIncompatible('DroidCast server does not have /preview') + image = resp.content image = np.frombuffer(image, np.uint8) if image is None: raise ImageTruncated('Empty image after reading from buffer') if image.shape == (1843200,): raise DroidCastVersionIncompatible('Requesting screenshots from `DroidCast` but server is `DroidCast_raw`') + if image.size < 500: + logger.warning(f'Unexpected screenshot: {resp.content}') image = cv2.imdecode(image, cv2.IMREAD_COLOR) if image is None: raise ImageTruncated('Empty image after cv2.imdecode') - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image) if image is None: raise ImageTruncated('Empty image after cv2.cvtColor') @@ -197,12 +179,14 @@ class DroidCast(Uiautomator2): @retry def screenshot_droidcast_raw(self): self.config.DROIDCAST_VERSION = 'DroidCast_raw' - image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content + image = self.droidcast_session.get(self.droidcast_raw_url(), timeout=3).content # DroidCast_raw returns a RGB565 bitmap try: arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280)) except ValueError as e: + if len(image) < 500: + logger.warning(f'Unexpected screenshot: {image}') # Try to load as `DroidCast` image = np.frombuffer(image, np.uint8) if image is not None: @@ -228,14 +212,25 @@ class DroidCast(Uiautomator2): # image = cv2.merge([r, g, b]) # The same as the code above but costs about 5ms instead of 10ms. - r = cv2.multiply(arr & 0b1111100000000000, 0.00390625).astype(np.uint8) - g = cv2.multiply(arr & 0b0000011111100000, 0.125).astype(np.uint8) - b = cv2.multiply(arr & 0b0000000000011111, 8).astype(np.uint8) - r = cv2.add(r, cv2.multiply(r, 0.03125)) - g = cv2.add(g, cv2.multiply(g, 0.015625)) - b = cv2.add(b, cv2.multiply(b, 0.03125)) - image = cv2.merge([r, g, b]) + r = cv2.bitwise_and(arr, 0b1111100000000000) + cv2.multiply(r, 0.00390625, dst=r) + r = np.uint8(r) + m = cv2.multiply(r, 0.03125) + cv2.add(r, m, dst=r) + g = cv2.bitwise_and(arr, 0b0000011111100000) + cv2.multiply(g, 0.125, dst=g) + g = np.uint8(g) + m = cv2.multiply(g, 0.015625) + cv2.add(g, m, dst=g) + + b = cv2.bitwise_and(arr, 0b0000000000011111) + cv2.multiply(b, 8, dst=b) + b = np.uint8(b) + m = cv2.multiply(b, 0.03125) + cv2.add(b, m, dst=b) + + image = cv2.merge([r, g, b]) return image def droidcast_wait_startup(self): @@ -262,13 +257,12 @@ class DroidCast(Uiautomator2): def droidcast_uninstall(self): """ - Stop all DroidCast processes and remove DroidCast APK. - DroidCast has't been installed but a JAVA class call, uninstall is a file delete. + Stop DroidCast processes and remove DroidCast APK. + DroidCast hasn't been installed but a JAVA class call, uninstall is a file delete. """ self.droidcast_stop() logger.info('Removing DroidCast') self.adb_shell(["rm", self.config.DROIDCAST_FILEPATH_REMOTE]) - self.adb_shell(["rm", self.config.DROIDCAST_RAW_FILEPATH_REMOTE]) def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]: """ @@ -280,10 +274,12 @@ class DroidCast(Uiautomator2): yield proc if 'com.torther.droidcasts.Main' in proc.cmdline: yield proc + if 'ink.mol.droidcast_raw.Main' in proc.cmdline: + yield proc def droidcast_stop(self): """ - Stop all DroidCast processes. + Stop DroidCast processes. """ logger.info('Stopping DroidCast') for proc in self._iter_droidcast_proc(): diff --git a/module/device/method/uiautomator_2.py b/module/device/method/uiautomator_2.py index dea534a..469cf11 100644 --- a/module/device/method/uiautomator_2.py +++ b/module/device/method/uiautomator_2.py @@ -1,4 +1,3 @@ -import typing as t from dataclasses import dataclass from functools import wraps from json.decoder import JSONDecodeError @@ -10,8 +9,8 @@ from lxml import etree from module.base.utils import * from module.device.connection import Connection -from module.device.method.utils import (RETRY_TRIES, retry_sleep, handle_adb_error, - ImageTruncated, PackageNotInstalled, possible_reasons) +from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, + possible_reasons, retry_sleep) from module.exception import RequestHumanTakeover from module.logger import logger @@ -122,7 +121,7 @@ class Uiautomator2(Connection): if image is None: raise ImageTruncated('Empty image after cv2.imdecode') - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image) if image is None: raise ImageTruncated('Empty image after cv2.cvtColor') From 108f64851771309e39c5ecb4838718a75c919446 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 18:57:05 +0800 Subject: [PATCH 48/55] fix: set Windows event loop policy conditionally based on platform --- deploy/Windows/emulator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/Windows/emulator.py b/deploy/Windows/emulator.py index e01d59c..a7d9111 100644 --- a/deploy/Windows/emulator.py +++ b/deploy/Windows/emulator.py @@ -2,6 +2,7 @@ import asyncio import filecmp import os import shutil +import sys import typing as t from dataclasses import dataclass @@ -9,7 +10,8 @@ from deploy.Windows.alas import AlasManager from deploy.Windows.logger import logger from deploy.Windows.utils import cached_property -asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) +if sys.platform.startswith('win'): + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @dataclass From 49149413d5555a29c553f2524ecddd87194c0cf2 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 19:27:22 +0800 Subject: [PATCH 49/55] fix: add timeout check to prevent infinite loop in draggable list --- module/ui/draggable_list.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/module/ui/draggable_list.py b/module/ui/draggable_list.py index 7f8f46c..b35616e 100644 --- a/module/ui/draggable_list.py +++ b/module/ui/draggable_list.py @@ -160,6 +160,7 @@ class DraggableList: logger.info(f'Insight row: {row}, index={row_index}') last_buttons: set[OcrResultButton] = None + bottom_check = Timer(3, count=5).start() while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -183,8 +184,11 @@ class DraggableList: 0, count=0), timeout=Timer(1.5, count=5)) skip_first_screenshot = True if self.cur_buttons and last_buttons == set(self.cur_buttons): - logger.warning(f'No more rows in {self}') - return False + if bottom_check.reached(): + logger.warning(f'No more rows in {self}') + return False + else: + bottom_check.reset() last_buttons = set(self.cur_buttons) return True From 0bd294582baad0d6fb4d0fb6b615349833aced6b Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 19:27:47 +0800 Subject: [PATCH 50/55] refactor: move ThreadPoolExecutor import inside worker method for better encapsulation --- module/base/base.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/module/base/base.py b/module/base/base.py index 06ba45f..dda2e69 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -1,5 +1,3 @@ -from concurrent.futures import ThreadPoolExecutor - import module.config.server as server_ from module.base.button import Button, ButtonWrapper, ClickButton, match_template from module.base.timer import Timer @@ -50,11 +48,22 @@ class ModuleBase: self.interval_timer = {} @cached_class_property - def worker(self) -> ThreadPoolExecutor: + def worker(self): """ A thread pool to run things at background + + Examples: + ``` + def func(image): + logger.info('Update thread start') + with self.config.multi_set(): + self.dungeon_get_simuni_point(image) + self.dungeon_update_stamina(image) + ModuleBase.worker.submit(func, self.device.image) + ``` """ logger.hr('Creating worker') + from concurrent.futures import ThreadPoolExecutor pool = ThreadPoolExecutor(1) return pool From 3cef2ee01b34df7d0d523d7b80d0a2d9ae56ae97 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 19:40:17 +0800 Subject: [PATCH 51/55] feat: improve emulator management --- module/device/platform/emulator_base.py | 23 ++++++ module/device/platform/emulator_windows.py | 94 +++++++++++++++------- module/device/platform/platform_base.py | 72 ++++++++++++----- 3 files changed, 140 insertions(+), 49 deletions(-) diff --git a/module/device/platform/emulator_base.py b/module/device/platform/emulator_base.py index ecd026b..94483e9 100644 --- a/module/device/platform/emulator_base.py +++ b/module/device/platform/emulator_base.py @@ -36,6 +36,21 @@ def get_serial_pair(serial): return None, None +def remove_duplicated_path(paths): + """ + Args: + paths (list[str]): + + Returns: + list[str]: + """ + paths = sorted(set(paths)) + dic = {} + for path in paths: + dic.setdefault(path.lower(), path) + return list(dic.values()) + + @dataclass class EmulatorInstanceBase: # Serial for adb connection @@ -205,6 +220,14 @@ class EmulatorBase: class EmulatorManagerBase: + @staticmethod + def iter_running_emulator(): + """ + Yields: + str: Path to emulator executables, may contains duplicate values + """ + return + @cached_property def all_emulators(self) -> t.List[EmulatorBase]: """ diff --git a/module/device/platform/emulator_windows.py b/module/device/platform/emulator_windows.py index ff69127..f7a5e54 100644 --- a/module/device/platform/emulator_windows.py +++ b/module/device/platform/emulator_windows.py @@ -8,7 +8,8 @@ from dataclasses import dataclass # module/device/platform/emulator_base.py # module/device/platform/emulator_windows.py # Will be used in Alas Easy Install, they shouldn't import any Alas modules. -from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase +from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase, \ + remove_duplicated_path from module.device.platform.utils import cached_property, iter_folder @@ -70,7 +71,7 @@ class Emulator(EmulatorBase): def path_to_type(cls, path: str) -> str: """ Args: - path: Path to .exe file + path: Path to .exe file, case insensitive Returns: str: Emulator type, such as Emulator.NoxPlayer @@ -78,46 +79,49 @@ class Emulator(EmulatorBase): folder, exe = os.path.split(path) folder, dir1 = os.path.split(folder) folder, dir2 = os.path.split(folder) - if exe == 'Nox.exe': - if dir2 == 'Nox': + exe = exe.lower() + dir1 = dir1.lower() + dir2 = dir2.lower() + if exe == 'nox.exe': + if dir2 == 'nox': return cls.NoxPlayer - elif dir2 == 'Nox64': + elif dir2 == 'nox64': return cls.NoxPlayer64 else: return cls.NoxPlayer - if exe == 'Bluestacks.exe': - if dir1 in ['BlueStacks', 'BlueStacks_cn']: + if exe == 'bluestacks.exe': + if dir1 in ['bluestacks', 'bluestacks_cn']: return cls.BlueStacks4 - elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']: + elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']: return cls.BlueStacks5 else: return cls.BlueStacks4 - if exe == 'HD-Player.exe': - if dir1 in ['BlueStacks', 'BlueStacks_cn']: + if exe == 'hd-player.exe': + if dir1 in ['bluestacks', 'bluestacks_cn']: return cls.BlueStacks4 - elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']: + elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']: return cls.BlueStacks5 else: return cls.BlueStacks5 if exe == 'dnplayer.exe': - if dir1 == 'LDPlayer': + if dir1 == 'ldplayer': return cls.LDPlayer3 - elif dir1 == 'LDPlayer4': + elif dir1 == 'ldplayer4': return cls.LDPlayer4 - elif dir1 == 'LDPlayer9': + elif dir1 == 'ldplayer9': return cls.LDPlayer9 else: return cls.LDPlayer3 - if exe == 'NemuPlayer.exe': + if exe == 'nemuplayer.exe': if dir2 == 'nemu': return cls.MuMuPlayer elif dir2 == 'nemu9': return cls.MuMuPlayerX else: return cls.MuMuPlayer - if exe == 'MuMuPlayer.exe': + if exe == 'mumuplayer.exe': return cls.MuMuPlayer12 - if exe == 'MEmu.exe': + if exe == 'memu.exe': return cls.MEmuPlayer return '' @@ -143,7 +147,9 @@ class Emulator(EmulatorBase): elif 'NemuMultiPlayer.exe' in exe: yield exe.replace('NemuMultiPlayer.exe', 'NemuPlayer.exe') elif 'MuMuMultiPlayer.exe' in exe: - yield exe.replace('MuMuMultiPlayer.exe', 'MuMuManager.exe') + yield exe.replace('MuMuMultiPlayer.exe', 'MuMuPlayer.exe') + elif 'MuMuManager.exe' in exe: + yield exe.replace('MuMuManager.exe', 'MuMuPlayer.exe') elif 'MEmuConsole.exe' in exe: yield exe.replace('MEmuConsole.exe', 'MEmu.exe') else: @@ -316,7 +322,7 @@ class EmulatorManager(EmulatorManagerBase): Get recently executed programs in UserAssist https://github.com/forensicmatt/MonitorUserAssist - Returns: + Yields: str: Path to emulator executables, may contains duplicate values """ path = r'Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist' @@ -439,13 +445,38 @@ class EmulatorManager(EmulatorManagerBase): continue if not uninstall: continue - # UninstallString is like: - # C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp - # "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u - # Extract path in "" - res = re.search('"(.*?)"', uninstall) - uninstall = res.group(1) if res else uninstall - yield uninstall + # UninstallString is like: + # C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp + # "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u + # Extract path in "" + res = re.search('"(.*?)"', uninstall) + uninstall = res.group(1) if res else uninstall + yield uninstall + + @staticmethod + def iter_running_emulator(): + """ + Yields: + str: Path to emulator executables, may contains duplicate values + """ + try: + import psutil + except ModuleNotFoundError: + return + # Since this is a one-time-usage, we access psutil._psplatform.Process directly + # to bypass the call of psutil.Process.is_running(). + # This only costs about 0.017s. + for pid in psutil.pids(): + proc = psutil._psplatform.Process(pid) + try: + exe = proc.cmdline() + exe = exe[0].replace(r'\\', '/').replace('\\', '/') + except (psutil.AccessDenied, IndexError): + # psutil.AccessDenied + continue + + if Emulator.is_emulator(exe): + yield exe @cached_property def all_emulators(self) -> t.List[Emulator]: @@ -474,7 +505,7 @@ class EmulatorManager(EmulatorManagerBase): exe.add(ld) # Uninstall registry - for uninstall in self.iter_uninstall_registry(): + for uninstall in EmulatorManager.iter_uninstall_registry(): # Find emulator executable from uninstaller for file in iter_folder(abspath(os.path.dirname(uninstall)), ext='.exe'): if Emulator.is_emulator(file) and os.path.exists(file): @@ -488,9 +519,14 @@ class EmulatorManager(EmulatorManagerBase): if Emulator.is_emulator(file) and os.path.exists(file): exe.add(file) + # Running + for file in EmulatorManager.iter_running_emulator(): + if os.path.exists(file): + exe.add(file) + + # De-redundancy exe = [Emulator(path).path for path in exe if Emulator.is_emulator(path)] - exe = sorted(set(exe)) - exe = [Emulator(path) for path in exe] + exe = [Emulator(path) for path in remove_duplicated_path(exe)] return exe @cached_property diff --git a/module/device/platform/platform_base.py b/module/device/platform/platform_base.py index e48da23..02aba66 100644 --- a/module/device/platform/platform_base.py +++ b/module/device/platform/platform_base.py @@ -6,7 +6,8 @@ from pydantic import BaseModel from module.base.decorator import cached_property, del_cached_property from module.base.utils import SelectedGrids from module.device.connection import Connection -from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase +from module.device.method.utils import get_serial_pair +from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase, remove_duplicated_path from module.logger import logger @@ -47,8 +48,20 @@ class PlatformBase(Connection, EmulatorManagerBase): @cached_property def emulator_info(self) -> EmulatorInfo: emulator = self.config.EmulatorInfo_Emulator - name = str(self.config.EmulatorInfo_name).strip().replace('\n', '') - path = str(self.config.EmulatorInfo_path).strip().replace('\n', '') + if emulator == 'auto': + emulator = '' + + def parse_info(value): + if isinstance(value, str): + value = value.strip().replace('\n', '') + if value in ['None', 'False', 'True']: + value = '' + return value + else: + return '' + + name = parse_info(self.config.EmulatorInfo_name) + path = parse_info(self.config.EmulatorInfo_path) return EmulatorInfo( emulator=emulator, @@ -68,8 +81,14 @@ class PlatformBase(Connection, EmulatorManagerBase): path=data.path, name=data.name, ) + # Redirect emulator-5554 to 127.0.0.1:5555 + serial = self.serial + port_serial, _ = get_serial_pair(self.serial) + if port_serial is not None: + serial = port_serial + instance = self.find_emulator_instance( - serial=str(self.config.Emulator_Serial).strip(), + serial=serial, name=data.name, path=data.path, emulator=data.emulator, @@ -117,7 +136,7 @@ class PlatformBase(Connection, EmulatorManagerBase): # Search by serial select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instance with {search_args}') + logger.warning(f'No emulator instance with {search_args}, serial invalid') return None if select.count == 1: instance = select[0] @@ -130,9 +149,9 @@ class PlatformBase(Connection, EmulatorManagerBase): search_args['name'] = name select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}') - return None - if select.count == 1: + logger.warning(f'No emulator instances with {search_args}, name invalid') + search_args.pop('name') + elif select.count == 1: instance = select[0] logger.hr('Emulator instance', level=2) logger.info(f'Found emulator instance: {instance}') @@ -143,9 +162,9 @@ class PlatformBase(Connection, EmulatorManagerBase): search_args['path'] = path select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}') - return None - if select.count == 1: + logger.warning(f'No emulator instances with {search_args}, path invalid') + search_args.pop('path') + elif select.count == 1: instance = select[0] logger.hr('Emulator instance', level=2) logger.info(f'Found emulator instance: {instance}') @@ -156,9 +175,28 @@ class PlatformBase(Connection, EmulatorManagerBase): search_args['type'] = emulator select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}') - return None - if select.count == 1: + logger.warning(f'No emulator instances with {search_args}, type invalid') + search_args.pop('type') + elif select.count == 1: + instance = select[0] + logger.hr('Emulator instance', level=2) + logger.info(f'Found emulator instance: {instance}') + return instance + + # Still too many instances, search from running emulators + running = remove_duplicated_path(list(self.iter_running_emulator())) + logger.info('Running emulators') + for exe in running: + logger.info(exe) + if len(running) == 1: + logger.info('Only one running emulator') + # Same as searching path + search_args['path'] = running[0] + select = instances.select(**search_args) + if select.count == 0: + logger.warning(f'No emulator instances with {search_args}, path invalid') + search_args.pop('path') + elif select.count == 1: instance = select[0] logger.hr('Emulator instance', level=2) logger.info(f'Found emulator instance: {instance}') @@ -167,9 +205,3 @@ class PlatformBase(Connection, EmulatorManagerBase): # Still too many instances logger.warning(f'Found multiple emulator instances with {search_args}') return None - - -if __name__ == '__main__': - self = PlatformBase('alas') - d = self.emulator_instance - print(d) From 50c4bc5bba2cd7dbae0284a64d98a301401fd888 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 20:06:53 +0800 Subject: [PATCH 52/55] feat: enhance device connection handling and droidcast resolution management --- module/device/connection.py | 139 +++++++++++++++++++++----- module/device/connection_attr.py | 20 +++- module/device/device.py | 3 +- module/device/method/adb.py | 2 +- module/device/method/droidcast.py | 56 ++++++++++- module/device/method/uiautomator_2.py | 9 +- module/device/screenshot.py | 4 + 7 files changed, 197 insertions(+), 36 deletions(-) diff --git a/module/device/connection.py b/module/device/connection.py index 241c0aa..b0f93b7 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -3,6 +3,7 @@ import logging import re import socket import subprocess +import sys import time from functools import wraps @@ -12,7 +13,7 @@ from adbutils.errors import AdbError import module.config.server as server_ import platform -from module.base.decorator import Config, cached_property, del_cached_property +from module.base.decorator import Config, cached_property, del_cached_property, run_once from module.base.utils import SelectedGrids, ensure_time from module.device.connection_attr import ConnectionAttr from module.device.method.utils import ( @@ -84,10 +85,17 @@ class AdbDeviceWithStatus(AdbDevice): def __bool__(self): return True + @cached_property + def port(self) -> int: + try: + return int(self.serial.split(':')[1]) + except (IndexError, ValueError): + return 0 + @cached_property def may_mumu12_family(self): # 127.0.0.1:16XXX - return len(self.serial) == 15 and self.serial.startswith('127.0.0.1:16') + return 16384 <= self.port <= 17408 class Connection(ConnectionAttr): @@ -274,6 +282,7 @@ class Connection(ConnectionAttr): @cached_property def nemud_app_keep_alive(self) -> str: res = self.adb_getprop('nemud.app_keep_alive') + logger.attr('nemud.app_keep_alive', res) return res @retry @@ -282,7 +291,6 @@ class Connection(ConnectionAttr): return False res = self.nemud_app_keep_alive - logger.attr('nemud.app_keep_alive', res) if res == '': # Empty property, probably MuMu6 or MuMu12 version < 3.5.6 return True @@ -297,6 +305,15 @@ class Connection(ConnectionAttr): logger.warning(f'Invalid nemud.app_keep_alive value: {res}') return False + @cached_property + def is_mumu_over_version_356(self) -> bool: + """ + Returns: + bool: If MuMu12 version >= 3.5.6, + which has nemud.app_keep_alive and always be a vertical device + """ + return self.nemud_app_keep_alive != '' + @cached_property def _nc_server_host_port(self): """ @@ -547,14 +564,14 @@ class Connection(ConnectionAttr): # Disconnect offline device before connecting for device in self.list_device(): if device.status == 'offline': - logger.warning(f'Device {serial} is offline, disconnect it before connecting') - self.adb_disconnect(serial) + logger.warning(f'Device {device.serial} is offline, disconnect it before connecting') + self.adb_disconnect(device.serial) elif device.status == 'unauthorized': - logger.error(f'Device {serial} is unauthorized, please accept ADB debugging on your device') + logger.error(f'Device {device.serial} is unauthorized, please accept ADB debugging on your device') elif device.status == 'device': pass else: - logger.warning(f'Device {serial} is is having a unknown status: {device.status}') + logger.warning(f'Device {device.serial} is is having a unknown status: {device.status}') # Skip for emulator-5554 if 'emulator-' in serial: @@ -762,23 +779,45 @@ class Connection(ConnectionAttr): If serial=='auto' and only 1 device detected, use it """ logger.hr('Detect device') - logger.info('Here are the available devices, ' - 'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"') - devices = self.list_device() + available = SelectedGrids([]) + devices = SelectedGrids([]) - # Show available devices - available = devices.select(status='device') - for device in available: - logger.info(device.serial) - if not len(available): - logger.info('No available devices') + @run_once + def brute_force_connect(): + logger.info('Brute force connect') + from deploy.Windows.emulator import EmulatorManager + manager = EmulatorManager() + manager.brute_force_connect() - # Show unavailable devices if having any - unavailable = devices.delete(available) - if len(unavailable): - logger.info('Here are the devices detected but unavailable') - for device in unavailable: - logger.info(f'{device.serial} ({device.status})') + for _ in range(2): + logger.info('Here are the available devices, ' + 'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"') + devices = self.list_device() + + # Show available devices + available = devices.select(status='device') + for device in available: + logger.info(device.serial) + if not len(available): + logger.info('No available devices') + + # Show unavailable devices if having any + unavailable = devices.delete(available) + if len(unavailable): + logger.info('Here are the devices detected but unavailable') + for device in unavailable: + logger.info(f'{device.serial} ({device.status})') + + # brute_force_connect + if self.config.Emulator_Serial == 'auto' and available.count == 0: + logger.warning(f'No available device found') + if sys.platform == 'win32': + brute_force_connect() + continue + else: + break + else: + break # Auto device detection if self.config.Emulator_Serial == 'auto': @@ -788,7 +827,7 @@ class Connection(ConnectionAttr): raise RequestHumanTakeover elif available.count == 1: logger.info(f'Auto device detection found only one device, using it') - self.serial = available[0].serial + self.config.Emulator_Serial = self.serial = available[0].serial del_cached_property(self, 'adb') elif available.count == 2 \ and available.select(serial='127.0.0.1:7555') \ @@ -797,7 +836,7 @@ class Connection(ConnectionAttr): # For MuMu12 serials like 127.0.0.1:7555 and 127.0.0.1:16384 # ignore 7555 use 16384 remain = available.select(may_mumu12_family=True).first_or_none() - self.serial = remain.serial + self.config.Emulator_Serial = self.serial = remain.serial del_cached_property(self, 'adb') else: logger.critical('Multiple devices found, auto device detection cannot decide which to choose, ' @@ -806,6 +845,7 @@ class Connection(ConnectionAttr): # Handle LDPlayer # LDPlayer serial jumps between `127.0.0.1:5555+{X}` and `emulator-5554+{X}` + # No config write since it's dynamic port_serial, emu_serial = get_serial_pair(self.serial) if port_serial and emu_serial: # Might be LDPlayer, check connected devices @@ -832,6 +872,57 @@ class Connection(ConnectionAttr): f'Using serial: {emu_serial}') self.serial = emu_serial + # Redirect MuMu12 from 127.0.0.1:7555 to 127.0.0.1:16xxx + if self.serial == '127.0.0.1:7555': + for _ in range(2): + mumu12 = available.select(may_mumu12_family=True) + if mumu12.count == 1: + emu_serial = mumu12.first_or_none().serial + logger.warning(f'Redirect MuMu12 {self.serial} to {emu_serial}') + self.config.Emulator_Serial = self.serial = emu_serial + break + elif mumu12.count >= 2: + logger.warning(f'Multiple MuMu12 serial found, cannot redirect') + break + else: + # Only 127.0.0.1:7555 + if self.is_mumu_over_version_356: + # is_mumu_over_version_356 and nemud_app_keep_alive was cached + # Acceptable since it's the same device + logger.warning(f'Device {self.serial} is MuMu12 but corresponding port not found') + brute_force_connect() + devices = self.list_device() + # Show available devices + available = devices.select(status='device') + for device in available: + logger.info(device.serial) + if not len(available): + logger.info('No available devices') + continue + else: + # MuMu6 + break + + # MuMu12 uses 127.0.0.1:16385 if port 16384 is occupied, auto redirect + # No config write since it's dynamic + if self.is_mumu12_family: + matched = False + for device in available.select(may_mumu12_family=True): + if device.port == self.port: + # Exact match + matched = True + break + if not matched: + for device in available.select(may_mumu12_family=True): + if -2 <= device.port - self.port <= 2: + # Port switched + logger.info(f'MuMu12 port switches from {self.serial} to {device.serial}') + del_cached_property(self, 'port') + del_cached_property(self, 'is_mumu12_family') + del_cached_property(self, 'is_mumu_family') + self.serial = device.serial + break + @retry def list_package(self, show_log=True): """ diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py index 6bf50f6..8fddeff 100644 --- a/module/device/connection_attr.py +++ b/module/device/connection_attr.py @@ -122,6 +122,18 @@ class ConnectionAttr: def is_wsa(self): return bool(re.match(r'^wsa', self.serial)) + @cached_property + def port(self) -> int: + try: + return int(self.serial.split(':')[1]) + except (IndexError, ValueError): + return 0 + + @cached_property + def is_mumu12_family(self): + # 127.0.0.1:16XXX + return 16384 <= self.port <= 17408 + @cached_property def is_mumu_family(self): # 127.0.0.1:7555 @@ -129,9 +141,8 @@ class ConnectionAttr: return self.serial == '127.0.0.1:7555' or self.is_mumu12_family @cached_property - def is_mumu12_family(self): - # 127.0.0.1:16384 + 32*n - return len(self.serial) == 15 and self.serial.startswith('127.0.0.1:16') + def is_nox_family(self): + return 62001 <= self.port <= 63025 @cached_property def is_emulator(self): @@ -177,7 +188,8 @@ class ConnectionAttr: rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key: port = QueryValueEx(key, "BstAdbPort")[0] except FileNotFoundError: - logger.error(rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config') + logger.error( + rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config') logger.error('Please confirm that your are using BlueStack 4 hyper-v and not regular BlueStacks 4') logger.error(r'Please check if there is any other emulator instances under ' r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests') diff --git a/module/device/device.py b/module/device/device.py index 455960f..3df3434 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -3,7 +3,6 @@ import collections from module.base.timer import Timer from module.device.app_control import AppControl from module.device.control import Control -from module.device.platform import Platform from module.device.screenshot import Screenshot from module.exception import ( EmulatorNotRunningError, @@ -55,7 +54,7 @@ def show_function_call(): logger.info('Function calls:' + ''.join(func_list)) -class Device(Screenshot, Control, AppControl, Platform): +class Device(Screenshot, Control, AppControl): _screen_size_checked = False detect_record = set() click_record = collections.deque(maxlen=15) diff --git a/module/device/method/adb.py b/module/device/method/adb.py index 9a24b2d..a1d72d6 100644 --- a/module/device/method/adb.py +++ b/module/device/method/adb.py @@ -128,7 +128,7 @@ class Adb(Connection): if image is None: raise ImageTruncated('Empty image after cv2.imdecode') - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image) + cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image) if image is None: raise ImageTruncated('Empty image after cv2.cvtColor') diff --git a/module/device/method/droidcast.py b/module/device/method/droidcast.py index a0adb37..3c7a977 100644 --- a/module/device/method/droidcast.py +++ b/module/device/method/droidcast.py @@ -95,6 +95,8 @@ class DroidCast(Uiautomator2): """ _droidcast_port: int = 0 + droidcast_width: int = 0 + droidcast_height: int = 0 @cached_property def droidcast_session(self): @@ -114,14 +116,35 @@ class DroidCast(Uiautomator2): """ def droidcast_url(self, url='/preview'): + if self.is_mumu_over_version_356: + w, h = self.droidcast_width, self.droidcast_height + if self.orientation == 0: + return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}' + elif self.orientation == 1: + return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}' + else: + # logger.warning('DroidCast receives invalid device orientation') + pass + return f'http://127.0.0.1:{self._droidcast_port}{url}' def droidcast_raw_url(self, url='/screenshot'): + if self.is_mumu_over_version_356: + w, h = self.droidcast_width, self.droidcast_height + if self.orientation == 0: + return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}' + elif self.orientation == 1: + return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}' + else: + # logger.warning('DroidCast receives invalid device orientation') + pass + return f'http://127.0.0.1:{self._droidcast_port}{url}' def droidcast_init(self): logger.hr('DroidCast init') self.droidcast_stop() + self._droidcast_update_resolution() logger.info('Pushing DroidCast apk') self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE) @@ -151,10 +174,25 @@ class DroidCast(Uiautomator2): else: logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}') + def _droidcast_update_resolution(self): + if self.is_mumu_over_version_356: + logger.info('Update droidcast resolution') + w, h = self.resolution_uiautomator2(cal_rotation=False) + self.get_orientation() + # 720, 1280 + # mumu12 > 3.5.6 is always a vertical device + self.droidcast_width, self.droidcast_height = w, h + logger.info(f'Droicast resolution: {(w, h)}') + @retry def screenshot_droidcast(self): self.config.DROIDCAST_VERSION = 'DroidCast' + if self.is_mumu_over_version_356: + if not self.droidcast_width or not self.droidcast_height: + self._droidcast_update_resolution() + resp = self.droidcast_session.get(self.droidcast_url(), timeout=3) + if resp.status_code == 404: raise DroidCastVersionIncompatible('DroidCast server does not have /preview') image = resp.content @@ -174,16 +212,27 @@ class DroidCast(Uiautomator2): if image is None: raise ImageTruncated('Empty image after cv2.cvtColor') + if self.is_mumu_over_version_356: + if self.orientation == 1: + image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) + return image @retry def screenshot_droidcast_raw(self): self.config.DROIDCAST_VERSION = 'DroidCast_raw' + shape = (720, 1280) + if self.is_mumu_over_version_356: + if not self.droidcast_width or not self.droidcast_height: + self._droidcast_update_resolution() + if self.droidcast_height and self.droidcast_width: + shape = (self.droidcast_height, self.droidcast_width) + image = self.droidcast_session.get(self.droidcast_raw_url(), timeout=3).content # DroidCast_raw returns a RGB565 bitmap try: - arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280)) + arr = np.frombuffer(image, dtype=np.uint16).reshape(shape) except ValueError as e: if len(image) < 500: logger.warning(f'Unexpected screenshot: {image}') @@ -231,6 +280,11 @@ class DroidCast(Uiautomator2): cv2.add(b, m, dst=b) image = cv2.merge([r, g, b]) + + if self.is_mumu_over_version_356: + if self.orientation == 1: + image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) + return image def droidcast_wait_startup(self): diff --git a/module/device/method/uiautomator_2.py b/module/device/method/uiautomator_2.py index 469cf11..c15ac10 100644 --- a/module/device/method/uiautomator_2.py +++ b/module/device/method/uiautomator_2.py @@ -242,7 +242,7 @@ class Uiautomator2(Connection): return hierarchy @retry - def resolution_uiautomator2(self) -> t.Tuple[int, int]: + def resolution_uiautomator2(self, cal_rotation=True) -> t.Tuple[int, int]: """ Faster u2.window_size(), cause that calls `dumpsys display` twice. @@ -251,9 +251,10 @@ class Uiautomator2(Connection): """ info = self.u2.http.get('/info').json() w, h = info['display']['width'], info['display']['height'] - rotation = self.get_orientation() - if (w > h) != (rotation % 2 == 1): - w, h = h, w + if cal_rotation: + rotation = self.get_orientation() + if (w > h) != (rotation % 2 == 1): + w, h = h, w return w, h def resolution_check_uiautomator2(self): diff --git a/module/device/screenshot.py b/module/device/screenshot.py index 9101a1d..80895f3 100644 --- a/module/device/screenshot.py +++ b/module/device/screenshot.py @@ -70,6 +70,10 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): return self.image + @property + def has_cached_image(self): + return hasattr(self, 'image') and self.image is not None + def _handle_orientated_image(self, image): """ Args: From a2bd36f5706765b41a8e60bef15a1c051aa97936 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 20:16:51 +0800 Subject: [PATCH 53/55] feat: add nemu_ipc --- module/config/argument/args.json | 3 +- module/config/argument/argument.yaml | 2 +- module/config/config_generated.py | 2 +- module/config/i18n/en-US.json | 3 +- module/config/i18n/zh-CN.json | 3 +- module/device/control.py | 20 +- module/device/device.py | 21 +- module/device/method/nemu_ipc.py | 541 +++++++++++++++++++++++++++ module/device/screenshot.py | 7 +- 9 files changed, 586 insertions(+), 16 deletions(-) create mode 100644 module/device/method/nemu_ipc.py diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 5f04a3f..fa1d829 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -36,7 +36,8 @@ "aScreenCap_nc", "DroidCast", "DroidCast_raw", - "scrcpy" + "scrcpy", + "nemu_ipc" ], "display": "hide" }, diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 562573c..ff57ef0 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -26,7 +26,7 @@ Emulator: option: [ auto, jp, en ] ScreenshotMethod: value: auto - option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ] + option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc ] ControlMethod: value: MaaTouch option: [ minitouch, MaaTouch ] diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 33a304a..ecbf4da 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -19,7 +19,7 @@ class GeneratedConfig: Emulator_Serial = 'auto' Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-Global Emulator_GameLanguage = 'auto' # auto, jp, en - Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy + Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch Emulator_AdbRestart = False diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index adf9439..05f6713 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -105,7 +105,8 @@ "aScreenCap_nc": "aScreenCap_nc", "DroidCast": "DroidCast", "DroidCast_raw": "DroidCast_raw", - "scrcpy": "scrcpy" + "scrcpy": "scrcpy", + "nemu_ipc": "nemu_ipc" }, "ControlMethod": { "name": "Control Method", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index b6fec8a..6ac651a 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -105,7 +105,8 @@ "aScreenCap_nc": "aScreenCap_nc", "DroidCast": "DroidCast", "DroidCast_raw": "DroidCast_raw", - "scrcpy": "scrcpy" + "scrcpy": "scrcpy", + "nemu_ipc": "nemu_ipc" }, "ControlMethod": { "name": "模拟器控制方案", diff --git a/module/device/control.py b/module/device/control.py index 010caca..6b39bcd 100644 --- a/module/device/control.py +++ b/module/device/control.py @@ -1,5 +1,3 @@ -import numpy as np - from module.base.button import ClickButton from module.base.decorator import cached_property from module.base.timer import Timer @@ -7,11 +5,12 @@ from module.base.utils import * from module.device.method.hermit import Hermit from module.device.method.maatouch import MaaTouch from module.device.method.minitouch import Minitouch +from module.device.method.nemu_ipc import NemuIpc from module.device.method.scrcpy import Scrcpy from module.logger import logger -class Control(Hermit, Minitouch, Scrcpy, MaaTouch): +class Control(Hermit, Minitouch, Scrcpy, MaaTouch, NemuIpc): def handle_control_check(self, button): # Will be overridden in Device pass @@ -24,6 +23,7 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch): 'minitouch': self.click_minitouch, 'Hermit': self.click_hermit, 'MaaTouch': self.click_maatouch, + 'nemu_ipc': self.click_nemu_ipc, } def click(self, button, control_check=True): @@ -80,6 +80,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch): self.long_click_scrcpy(x, y, duration) elif method == 'MaaTouch': self.long_click_maatouch(x, y, duration) + elif method == 'nemu_ipc': + self.long_click_nemu_ipc(x, y, duration) else: self.swipe_adb((x, y), (x, y), duration) @@ -88,13 +90,9 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch): p1, p2 = ensure_int(p1, p2) duration = ensure_time(duration) method = self.config.Emulator_ControlMethod - if method == 'minitouch': - logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) - elif method == 'uiautomator2': + if method == 'uiautomator2': logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) - elif method == 'scrcpy': - logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) - elif method == 'MaaTouch': + elif method in ['minitouch', 'MaaTouch', 'scrcpy', 'nemu_ipc']: logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) else: # ADB needs to be slow, or swipe doesn't work @@ -116,6 +114,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch): self.swipe_scrcpy(p1, p2) elif method == 'MaaTouch': self.swipe_maatouch(p1, p2) + elif method == 'nemu_ipc': + self.swipe_nemu_ipc(p1, p2) else: self.swipe_adb(p1, p2, duration=duration) @@ -165,6 +165,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch): self.drag_scrcpy(p1, p2, point_random=point_random) elif method == 'MaaTouch': self.drag_maatouch(p1, p2, point_random=point_random) + elif method == 'nemu_ipc': + self.drag_nemu_ipc(p1, p2, point_random=point_random) else: logger.warning(f'Control method {method} does not support drag well, ' f'falling back to ADB swipe may cause unexpected behaviour') diff --git a/module/device/device.py b/module/device/device.py index 3df3434..895f7ad 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -81,6 +81,7 @@ class Device(Screenshot, Control, AppControl): _ = self.emulator_instance self.screenshot_interval_set() + self.method_check() # Auto-select the fastest screenshot method if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto': @@ -99,7 +100,23 @@ class Device(Screenshot, Control, AppControl): bench = Benchmark(config=self.config, device=self) method = bench.run_simple_screenshot_benchmark() # Set - self.config.Emulator_ScreenshotMethod = method + with self.config.multi_set(): + self.config.Emulator_ScreenshotMethod = method + # if method == 'nemu_ipc': + # self.config.Emulator_ControlMethod = 'nemu_ipc' + + def method_check(self): + """ + Check combinations of screenshot method and control methods + """ + # nemu_ipc should be together + # if self.config.Emulator_ScreenshotMethod == 'nemu_ipc' and self.config.Emulator_ControlMethod != 'nemu_ipc': + # logger.warning('When using nemu_ipc, both screenshot and control should use nemu_ipc') + # self.config.Emulator_ControlMethod = 'nemu_ipc' + # if self.config.Emulator_ScreenshotMethod != 'nemu_ipc' and self.config.Emulator_ControlMethod == 'nemu_ipc': + # logger.warning('When not using nemu_ipc, both screenshot and control should not use nemu_ipc') + # self.config.Emulator_ControlMethod = 'minitouch' + pass def screenshot(self): """ @@ -125,6 +142,8 @@ class Device(Screenshot, Control, AppControl): # stop it during wait if self.config.Emulator_ScreenshotMethod == 'scrcpy': self._scrcpy_server_stop() + if self.config.Emulator_ScreenshotMethod == 'nemu_ipc': + self.nemu_ipc_release() def stuck_record_add(self, button): self.detect_record.add(str(button)) diff --git a/module/device/method/nemu_ipc.py b/module/device/method/nemu_ipc.py new file mode 100644 index 0000000..b79e1c3 --- /dev/null +++ b/module/device/method/nemu_ipc.py @@ -0,0 +1,541 @@ +import asyncio +import ctypes +import os +import sys +from functools import partial, wraps + +import cv2 +import numpy as np + +from module.base.decorator import cached_property, del_cached_property, has_cached_property +from module.base.utils import ensure_time +from module.device.method.minitouch import insert_swipe, random_rectangle_point +from module.device.method.utils import RETRY_TRIES, retry_sleep +from module.device.platform import Platform +from module.exception import RequestHumanTakeover +from module.logger import logger + + +class NemuIpcIncompatible(Exception): + pass + + +class NemuIpcError(Exception): + pass + + +class CaptureStd: + """ + Capture stdout and stderr from both python and C library + https://stackoverflow.com/questions/5081657/how-do-i-prevent-a-c-shared-library-to-print-on-stdout-in-python/17954769 + + ``` + with CaptureStd() as capture: + # String wasn't printed + print('whatever') + # But captured in ``capture.stdout`` + print(f'Got stdout: "{capture.stdout}"') + print(f'Got stderr: "{capture.stderr}"') + ``` + """ + + def __init__(self): + self.stdout = b'' + self.stderr = b'' + + def _redirect_stdout(self, to): + sys.stdout.close() + os.dup2(to, self.fdout) + sys.stdout = os.fdopen(self.fdout, 'w') + + def _redirect_stderr(self, to): + sys.stderr.close() + os.dup2(to, self.fderr) + sys.stderr = os.fdopen(self.fderr, 'w') + + def __enter__(self): + self.fdout = sys.stdout.fileno() + self.fderr = sys.stderr.fileno() + self.reader_out, self.writer_out = os.pipe() + self.reader_err, self.writer_err = os.pipe() + self.old_stdout = os.dup(self.fdout) + self.old_stderr = os.dup(self.fderr) + + file_out = os.fdopen(self.writer_out, 'w') + file_err = os.fdopen(self.writer_err, 'w') + self._redirect_stdout(to=file_out.fileno()) + self._redirect_stderr(to=file_err.fileno()) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._redirect_stdout(to=self.old_stdout) + self._redirect_stderr(to=self.old_stderr) + os.close(self.old_stdout) + os.close(self.old_stderr) + + self.stdout = self.recvall(self.reader_out) + self.stderr = self.recvall(self.reader_err) + os.close(self.reader_out) + os.close(self.reader_err) + + @staticmethod + def recvall(reader, length=1024) -> bytes: + fragments = [] + while 1: + chunk = os.read(reader, length) + if chunk: + fragments.append(chunk) + else: + break + output = b''.join(fragments) + return output + + +class CaptureNemuIpc(CaptureStd): + instance = None + + def is_capturing(self): + """ + Only capture at the topmost wrapper to avoid nested capturing + If a capture is ongoing, this instance does nothing + """ + cls = self.__class__ + return isinstance(cls.instance, cls) and cls.instance != self + + def __enter__(self): + if self.is_capturing(): + return self + + super().__enter__() + CaptureNemuIpc.instance = self + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.is_capturing(): + return + + CaptureNemuIpc.instance = None + super().__exit__(exc_type, exc_val, exc_tb) + + self.check_stdout() + self.check_stderr() + + def check_stdout(self): + if not self.stdout: + return + logger.info(f'NemuIpc stdout: {self.stdout}') + + def check_stderr(self): + if not self.stderr: + return + logger.error(f'NemuIpc stderr: {self.stderr}') + + # Calling an old MuMu12 player + # Tested on 3.4.0 + # b'nemu_capture_display rpc error: 1783\r\n' + # Tested on 3.7.3 + # b'nemu_capture_display rpc error: 1745\r\n' + if b'error: 1783' in self.stderr or b'error: 1745' in self.stderr: + raise NemuIpcIncompatible( + f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version') + # contact_id incorrect + # b'nemu_capture_display cannot find rpc connection\r\n' + if b'cannot find rpc connection' in self.stderr: + raise NemuIpcError(self.stderr) + # Emulator died + # b'nemu_capture_display rpc error: 1722\r\n' + # MuMuVMMSVC.exe died + # b'nemu_capture_display rpc error: 1726\r\n' + # No idea how to handle yet + if b'error: 1722' in self.stderr or b'error: 1726' in self.stderr: + raise NemuIpcError('Emulator instance is probably dead') + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (NemuIpcImpl): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # Can't handle + except NemuIpcIncompatible as e: + logger.error(e) + break + # Function call timeout + except asyncio.TimeoutError: + logger.warning(f'Func {func.__name__}() call timeout, retrying: {_}') + + def init(): + self.reconnect() + # NemuIpcError + except NemuIpcError as e: + logger.error(e) + + def init(): + self.reconnect() + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class NemuIpcImpl: + def __init__(self, nemu_folder: str, instance_id: int, display_id: int = 0): + """ + Args: + nemu_folder: Installation path of MuMu12, e.g. E:/ProgramFiles/MuMuPlayer-12.0 + instance_id: Emulator instance ID, starting from 0 + display_id: Always 0 if keep app alive was disabled + """ + self.nemu_folder: str = nemu_folder + self.instance_id: int = instance_id + self.display_id: int = display_id + + ipc_dll = os.path.abspath(os.path.join(nemu_folder, './shell/sdk/external_renderer_ipc.dll')) + logger.info( + f'NemuIpcImpl init, ' + f'nemu_folder={nemu_folder}, ' + f'ipc_dll={ipc_dll}, ' + f'instance_id={instance_id}, ' + f'display_id={display_id}' + ) + + try: + self.lib = ctypes.CDLL(ipc_dll) + except OSError as e: + logger.error(e) + # OSError: [WinError 126] 找不到指定的模块。 + if not os.path.exists(ipc_dll): + raise NemuIpcIncompatible( + f'ipc_dll={ipc_dll} does not exist, ' + f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version') + else: + raise NemuIpcIncompatible( + f'ipc_dll={ipc_dll} exists, but cannot be loaded') + self.connect_id: int = 0 + self.width = 0 + self.height = 0 + + def connect(self): + if self.connect_id > 0: + return + + connect_id = self.ev_run_sync( + self.lib.nemu_connect, + self.nemu_folder, self.instance_id + ) + if connect_id == 0: + raise NemuIpcError( + 'Connection failed, please check if nemu_folder is correct and emulator is running' + ) + + self.connect_id = connect_id + # logger.info(f'NemuIpc connected: {self.connect_id}') + + def disconnect(self): + if self.connect_id == 0: + return + + self.ev_run_sync( + self.lib.nemu_disconnect, + self.connect_id + ) + + # logger.info(f'NemuIpc disconnected: {self.connect_id}') + self.connect_id = 0 + + def reconnect(self): + self.disconnect() + self.connect() + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.disconnect() + + @cached_property + def _ev(self): + return asyncio.new_event_loop() + + async def ev_run_async(self, func, *args, **kwargs): + """ + Args: + func: Sync function to call + *args: + **kwargs: + + Raises: + asyncio.TimeoutError: If function call timeout + """ + func_wrapped = partial(func, *args, **kwargs) + # Increased timeout for slow PCs + # Default screenshot interval is 0.2s, so a 0.15s timeout would have a fast retry without extra time costs + result = await asyncio.wait_for(self._ev.run_in_executor(None, func_wrapped), timeout=0.15) + return result + + def ev_run_sync(self, func, *args, **kwargs): + """ + Args: + func: Sync function to call + *args: + **kwargs: + + Raises: + asyncio.TimeoutError: If function call timeout + NemuIpcIncompatible: + NemuIpcError + """ + result = self._ev.run_until_complete(self.ev_run_async(func, *args, **kwargs)) + + err = False + if func.__name__ == 'nemu_connect': + if result == 0: + err = True + else: + if result > 0: + err = True + # Get to actual error message printed in std + if err: + logger.warning(f'Failed to call {func.__name__}, result={result}') + with CaptureNemuIpc(): + result = self._ev.run_until_complete(self.ev_run_async(func, *args, **kwargs)) + + return result + + def get_resolution(self): + """ + Get emulator resolution, `self.width` and `self.height` will be set + """ + if self.connect_id == 0: + self.connect() + + width_ptr = ctypes.pointer(ctypes.c_int(0)) + height_ptr = ctypes.pointer(ctypes.c_int(0)) + nullptr = ctypes.POINTER(ctypes.c_int)() + + ret = self.ev_run_sync( + self.lib.nemu_capture_display, + self.connect_id, self.display_id, 0, width_ptr, height_ptr, nullptr + ) + if ret > 0: + raise NemuIpcError('nemu_capture_display failed during get_resolution()') + self.width = width_ptr.contents.value + self.height = height_ptr.contents.value + + @retry + def screenshot(self): + """ + Returns: + np.ndarray: Image array in RGBA color space + Note that image is upside down + """ + if self.connect_id == 0: + self.connect() + + self.get_resolution() + + width_ptr = ctypes.pointer(ctypes.c_int(self.width)) + height_ptr = ctypes.pointer(ctypes.c_int(self.height)) + length = self.width * self.height * 4 + pixels_pointer = ctypes.pointer((ctypes.c_ubyte * length)()) + + ret = self.ev_run_sync( + self.lib.nemu_capture_display, + self.connect_id, self.display_id, length, width_ptr, height_ptr, pixels_pointer + ) + if ret > 0: + raise NemuIpcError('nemu_capture_display failed during screenshot()') + + # image = np.ctypeslib.as_array(pixels_pointer, shape=(self.height, self.width, 4)) + image = np.ctypeslib.as_array(pixels_pointer.contents).reshape((self.height, self.width, 4)) + return image + + def convert_xy(self, x, y): + """ + Convert classic ADB coordinates to Nemu's + `self.height` must be updated before calling this method + + Returns: + int, int + """ + x, y = int(x), int(y) + x, y = self.height - y, x + return x, y + + @retry + def down(self, x, y): + """ + Contact down, continuous contact down will be considered as swipe + """ + if self.connect_id == 0: + self.connect() + if self.height == 0: + self.get_resolution() + + x, y = self.convert_xy(x, y) + + ret = self.ev_run_sync( + self.lib.nemu_input_event_touch_down, + self.connect_id, self.display_id, x, y + ) + if ret > 0: + raise NemuIpcError('nemu_input_event_touch_down failed') + + @retry + def up(self): + """ + Contact up + """ + if self.connect_id == 0: + self.connect() + + ret = self.ev_run_sync( + self.lib.nemu_input_event_touch_up, + self.connect_id, self.display_id + ) + if ret > 0: + raise NemuIpcError('nemu_input_event_touch_up failed') + + +def serial_to_id(serial: str): + """ + Predict instance ID from serial + E.g. + "127.0.0.1:16384" -> 0 + "127.0.0.1:16416" -> 1 + + Returns: + int: instance_id, or None if failed to predict + """ + try: + port = int(serial.split(':')[1]) + except (IndexError, ValueError): + return None + index, offset = divmod(port - 16384, 32) + if 0 <= index < 32 and offset in [0, 1, 2]: + return index + else: + return None + + +class NemuIpc(Platform): + @cached_property + def nemu_ipc(self) -> NemuIpcImpl: + """ + Initialize a nemu ipc implementation + """ + # Try existing settings first + if self.config.EmulatorInfo_path: + folder = os.path.abspath(os.path.join(self.config.EmulatorInfo_path, '../../')) + index = serial_to_id(self.serial) + if index is not None: + try: + return NemuIpcImpl( + nemu_folder=folder, + instance_id=index, + display_id=0 + ).__enter__() + except (NemuIpcIncompatible, NemuIpcError) as e: + logger.error(e) + logger.error('Emulator info incorrect') + + # Search emulator instance + # with E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe + # installation path is E:\ProgramFiles\MuMuPlayer-12.0 + if self.emulator_instance is None: + logger.error('Unable to use NemuIpc because emulator instance not found') + raise RequestHumanTakeover + try: + return NemuIpcImpl( + nemu_folder=self.emulator_instance.emulator.abspath('../'), + instance_id=self.emulator_instance.MuMuPlayer12_id, + display_id=0 + ).__enter__() + except (NemuIpcIncompatible, NemuIpcError) as e: + logger.error(e) + logger.error('Unable to initialize NemuIpc') + raise RequestHumanTakeover + + def nemu_ipc_available(self) -> bool: + if not self.is_mumu_family: + return False + if self.nemud_app_keep_alive == '': + return False + try: + _ = self.nemu_ipc + except RequestHumanTakeover: + return False + return True + + def nemu_ipc_release(self): + if has_cached_property(self, 'nemu_ipc'): + self.nemu_ipc.disconnect() + del_cached_property(self, 'nemu_ipc') + logger.info('nemu_ipc released') + + def screenshot_nemu_ipc(self): + image = self.nemu_ipc.screenshot() + + image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + cv2.flip(image, 0, dst=image) + return image + + def click_nemu_ipc(self, x, y): + down = ensure_time((0.010, 0.020)) + self.nemu_ipc.down(x, y) + self.sleep(down) + self.nemu_ipc.up() + self.sleep(0.050 - down) + + def long_click_nemu_ipc(self, x, y, duration=1.0): + self.nemu_ipc.down(x, y) + self.sleep(duration) + self.nemu_ipc.up() + self.sleep(0.050) + + def swipe_nemu_ipc(self, p1, p2): + points = insert_swipe(p0=p1, p3=p2) + + for point in points: + self.nemu_ipc.down(*point) + self.sleep(0.010) + + self.nemu_ipc.up() + self.sleep(0.050) + + def drag_nemu_ipc(self, p1, p2, point_random=(-10, -10, 10, 10)): + p1 = np.array(p1) - random_rectangle_point(point_random) + p2 = np.array(p2) - random_rectangle_point(point_random) + points = insert_swipe(p0=p1, p3=p2, speed=20) + + for point in points: + self.nemu_ipc.down(*point) + self.sleep(0.010) + + self.nemu_ipc.down(*p2) + self.sleep(0.140) + self.nemu_ipc.down(*p2) + self.sleep(0.140) + + self.nemu_ipc.up() + self.sleep(0.050) diff --git a/module/device/screenshot.py b/module/device/screenshot.py index 80895f3..93bc612 100644 --- a/module/device/screenshot.py +++ b/module/device/screenshot.py @@ -13,13 +13,14 @@ from module.base.utils import get_color, image_size, limit_in, save_image from module.device.method.adb import Adb from module.device.method.ascreencap import AScreenCap from module.device.method.droidcast import DroidCast +from module.device.method.nemu_ipc import NemuIpc from module.device.method.scrcpy import Scrcpy from module.device.method.wsa import WSA from module.exception import RequestHumanTakeover, ScriptError from module.logger import logger -class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): +class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy, NemuIpc): _screen_size_checked = False _screen_black_checked = False _minicap_uninstalled = False @@ -38,6 +39,7 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): 'DroidCast': self.screenshot_droidcast, 'DroidCast_raw': self.screenshot_droidcast_raw, 'scrcpy': self.screenshot_scrcpy, + 'nemu_ipc': self.screenshot_nemu_ipc, } def screenshot(self): @@ -163,6 +165,9 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): if interval != origin: logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}') self.config.Optimization_ScreenshotInterval = interval + # Allow nemu_ipc to have a lower default + if self.config.Emulator_ScreenshotMethod == 'nemu_ipc': + interval = limit_in(origin, 0.1, 0.2) elif interval == 'combat': origin = self.config.Optimization_CombatScreenshotInterval interval = limit_in(origin, 0.3, 1.0) From d54ff7256503cbfbccbca9a151a185d7de4b7ff5 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 20:18:18 +0800 Subject: [PATCH 54/55] feat: use nemu_ipc if avaliable --- module/device/device.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/module/device/device.py b/module/device/device.py index 895f7ad..e99c572 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -87,6 +87,12 @@ class Device(Screenshot, Control, AppControl): if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto': self.run_simple_screenshot_benchmark() + # AAS only, use nemu_ipc if available + available = self.nemu_ipc_available() + logger.attr('nemu_ipc_available', available) + if available: + self.config.override(Emulator_ScreenshotMethod='nemu_ipc') + def run_simple_screenshot_benchmark(self): """ Perform a screenshot method benchmark, test 3 times on each method. From 0b28ed6f898d212ac9ef41153d0d9b073a024b27 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Sun, 5 Jul 2026 20:21:50 +0800 Subject: [PATCH 55/55] feat: migrate notify.py from ALAS and upgrade dep `onepush` 1.3.0 --- module/notify.py | 75 +++++++++++++++++++++++++++++++++++++++++++-- requirements-in.txt | 1 + requirements.txt | 4 ++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/module/notify.py b/module/notify.py index 4f8b470..9f66778 100644 --- a/module/notify.py +++ b/module/notify.py @@ -1,4 +1,75 @@ +import onepush.core +import yaml +from onepush import get_notifier +from onepush.core import Provider +from onepush.exceptions import OnePushException +from onepush.providers.custom import Custom +from requests import Response + from module.logger import logger -def handle_notify(*args, **kwargs): - logger.error('Error notify is not supported yet') +onepush.core.log = logger + + +def handle_notify(_config: str, **kwargs) -> bool: + try: + config = {} + for item in yaml.safe_load_all(_config): + config.update(item) + except Exception: + logger.error("Fail to load onepush config, skip sending") + return False + try: + provider_name: str = config.pop("provider", None) + if provider_name is None: + logger.info("No provider specified, skip sending") + return False + notifier: Provider = get_notifier(provider_name) + required: list[str] = notifier.params["required"] + config.update(kwargs) + + # pre check + for key in required: + if key not in config: + logger.warning( + f"Notifier {notifier.name} require param '{key}' but not provided" + ) + + if isinstance(notifier, Custom): + if "method" not in config or config["method"] == "post": + config["datatype"] = "json" + if not ("data" in config or isinstance(config["data"], dict)): + config["data"] = {} + if "title" in kwargs: + config["data"]["title"] = kwargs["title"] + if "content" in kwargs: + config["data"]["content"] = kwargs["content"] + + if provider_name.lower() == "gocqhttp": + access_token = config.get("access_token") + if access_token: + config["token"] = access_token + + resp = notifier.notify(**config) + if isinstance(resp, Response): + if resp.status_code != 200: + logger.warning("Push notify failed!") + logger.warning(f"HTTP Code:{resp.status_code}") + return False + else: + if provider_name.lower() == "gocqhttp": + return_data: dict = resp.json() + if return_data["status"] == "failed": + logger.warning("Push notify failed!") + logger.warning( + f"Return message:{return_data['wording']}") + return False + except OnePushException: + logger.exception("Push notify failed") + return False + except Exception as e: + logger.exception(e) + return False + + logger.info("Push notify success") + return True diff --git a/requirements-in.txt b/requirements-in.txt index c34c5d5..df82239 100644 --- a/requirements-in.txt +++ b/requirements-in.txt @@ -21,6 +21,7 @@ pyyaml inflection prettytable==2.2.1 pydantic>=2.4 +onepush==1.3.0 # OCR pponnxcr==2.0 diff --git a/requirements.txt b/requirements.txt index 311038d..30895d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,6 +38,7 @@ markdown-it-py==2.2.0 # via rich mdurl==0.1.2 # via markdown-it-py mpmath==1.3.0 # via sympy numpy==1.24.3 # via -r requirements-in.txt, onnxruntime, opencv-python, pponnxcr, scipy, shapely +onepush==1.3.0 # via -r requirements-in.txt onnxruntime==1.14.1 # via pponnxcr opencv-python==4.7.0.72 # via -r requirements-in.txt, pponnxcr packaging==20.9 # via deprecation, onnxruntime, uiautomator2 @@ -49,6 +50,7 @@ protobuf==4.23.0 # via onnxruntime psutil==5.9.3 # via -r requirements-in.txt py==1.11.0 # via retry pyclipper==1.3.0.post4 # via pponnxcr +pycryptodome==3.20.0 # via onepush pydantic==2.4.2 # via -r requirements-in.txt pydantic-core==2.10.1 # via pydantic pyelftools==0.29 # via apkutils2 @@ -59,7 +61,7 @@ pyreadline3==3.4.1 # via humanfriendly python-dotenv==1.0.0 # via uvicorn pywebio==1.8.3 # via -r requirements-in.txt pyyaml==6.0 # via -r requirements-in.txt, uvicorn -requests==2.30.0 # via adbutils, uiautomator2 +requests==2.30.0 # via adbutils, onepush, uiautomator2 retry==0.9.2 # via adbutils, uiautomator2 rich==13.3.5 # via -r requirements-in.txt scipy==1.10.1 # via -r requirements-in.txt