From d0bc419a419cf9a5d089aff5dcc81d7a110d3247 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 18:40:54 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(DesktopClient):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88=E5=90=8E=E5=8F=B0=E6=88=AA=E5=9B=BE?= =?UTF-8?q?/=E7=AA=97=E5=8F=A3=E6=B6=88=E6=81=AF=E8=BE=93=E5=85=A5/PID=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DesktopWindow mixin(module/device/method/desktop_impl.py),集中承载桌面 客户端的窗口枚举/绑定/截图/输入实现,Handle 与 Window 各继承一份 - 桌面分支按 PID 定位窗口;后台截图走 GetWindowDC+BitBlt,输入走 PostMessage/SendMessage - 新增 dev_tools/bind_desktop_window.py,一键绑定或按序号选择客户端窗口 PID - connection_attr 新增 is_desktop(serial == 'desktop'),桌面模式跳过 adb server 拉起 - cBezier 抽出 human_track:桌面与模拟器两条滑动路径共用同一套拟人轨迹, 顺带修掉纯垂直/零长度滑动原本会抛的 ValueError 与 ZeroDivisionError --- dev_tools/bind_desktop_window.py | 118 ++++ module/base/cBezier.py | 31 + module/device/connection_attr.py | 15 +- module/device/handle.py | 22 +- module/device/method/desktop_impl.py | 990 +++++++++++++++++++++++++++ module/device/method/windows_impl.py | 166 ++++- 6 files changed, 1323 insertions(+), 19 deletions(-) create mode 100644 dev_tools/bind_desktop_window.py create mode 100644 module/device/method/desktop_impl.py diff --git a/dev_tools/bind_desktop_window.py b/dev_tools/bind_desktop_window.py new file mode 100644 index 0000000000..dcf700d126 --- /dev/null +++ b/dev_tools/bind_desktop_window.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" +桌面客户端窗口 PID 绑定工具 +============================ +把"阴阳师-网易游戏"桌面客户端窗口的 PID 写入指定 OAS 配置,并切到桌面模式 +(serial='desktop'、handle=PID、screenshot_method='window_background'、control_method='window_message')。 + +用法: + ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --pick + ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --list + ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --pid 4242 +""" +import argparse +import ctypes +import sys +from ctypes import wintypes + +import win32gui +import win32process + +from module.config.config import Config +from module.logger import logger + +DESKTOP_WINDOW_TITLES = ('阴阳师-网易游戏', '阴阳师-MuMu模拟器专版') + + +def list_desktop_windows() -> list: + """枚举所有桌面客户端窗口,返回 [(title, hwnd, pid)]。""" + def enum_cb(hwnd, param): + param.append(hwnd) + return True + + windows = [] + win32gui.EnumWindows(enum_cb, windows) + result = [] + for hwnd in windows: + title = win32gui.GetWindowText(hwnd) + if title in DESKTOP_WINDOW_TITLES: + pid = win32process.GetWindowThreadProcessId(hwnd)[1] + result.append((title, hwnd, pid)) + return result + + +def pick_window_under_cursor() -> int: + """返回鼠标光标所指窗口的句柄;无窗口返回 0。""" + user32 = ctypes.WinDLL('user32', use_last_error=True) + + class POINT(ctypes.Structure): + _fields_ = [('x', ctypes.c_long), ('y', ctypes.c_long)] + + point = POINT() + user32.GetCursorPos.argtypes = [ctypes.POINTER(POINT)] + user32.GetCursorPos.restype = wintypes.BOOL + if not user32.GetCursorPos(ctypes.byref(point)): + return 0 + user32.WindowFromPoint.argtypes = [POINT] + user32.WindowFromPoint.restype = wintypes.HWND + return int(user32.WindowFromPoint(point)) or 0 + + +def bind_pid_to_config(config_name: str, pid: int) -> None: + """把 PID 写入指定配置,并切到桌面模式。""" + config = Config(config_name) + config.script.device.serial = 'desktop' + config.script.device.handle = str(pid) + # PrintWindow 对客户端的 DirectX 渲染窗口返回纯黑,桌面模式统一用 BitBlt + config.script.device.screenshot_method = 'window_background' + config.script.device.control_method = 'window_message' + config.save() + logger.info(f'Config [{config_name}] bound to desktop client PID={pid}') + # 绑定后检测并调整窗口客户区到 1280x720,保证识别 1:1 + from module.device.handle import Handle + h = Handle(config) + h.desktop_window_set_size() + + +def main(): + parser = argparse.ArgumentParser(description='绑定桌面客户端窗口 PID 到 OAS 配置') + parser.add_argument('--config', required=True, help='配置实例名,如 oas1') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--pick', action='store_true', help='鼠标点选窗口(光标所指)') + group.add_argument('--list', action='store_true', help='从窗口列表按序号选择') + group.add_argument('--pid', type=int, help='直接指定 PID') + args = parser.parse_args() + + if args.pid: + bind_pid_to_config(args.config, args.pid) + return + + if args.pick: + input('请把鼠标移到目标游戏窗口上,然后按回车…') + hwnd = pick_window_under_cursor() + if not hwnd: + logger.error('未获取到光标下的窗口') + sys.exit(1) + title = win32gui.GetWindowText(hwnd) + if title not in DESKTOP_WINDOW_TITLES: + logger.error(f'光标下的窗口不是桌面客户端(标题: "{title}"),' + f'请把鼠标移到游戏画面实体区域上重试') + sys.exit(1) + pid = win32process.GetWindowThreadProcessId(hwnd)[1] + bind_pid_to_config(args.config, pid) + return + + windows = list_desktop_windows() + if not windows: + logger.error('未找到桌面客户端窗口,请先启动游戏') + sys.exit(1) + for i, (title, hwnd, pid) in enumerate(windows): + print(f'[{i}] {title} PID={pid} HWND=0x{hwnd:X}') + choice = input('请输入要绑定的序号: ') + idx = int(choice) + _, _, pid = windows[idx] + bind_pid_to_config(args.config, pid) + + +if __name__ == '__main__': + main() diff --git a/module/base/cBezier.py b/module/base/cBezier.py index a4c9e9aa91..b65f81bedd 100644 --- a/module/base/cBezier.py +++ b/module/base/cBezier.py @@ -156,5 +156,36 @@ def trackArray(cls, start, end, numberList, le=1, deviation=0, bias=0.5, type=0, # return {"trackArray": np.array(s), "P": w} return [[int(s[0]), int(s[1])] for s in s] + +def human_track(start, end, interval=10): + """ + 生成 start → end 的拟人贝塞尔轨迹点,相邻两点间隔约 interval 像素。 + 轨迹阶数、波动幅度、速度分布随机,供模拟器滑动与桌面客户端滑动共用。 + + 两种退化输入单独处理:起点终点 x 相同时贝塞尔以 x 做参数化会除零, + 纯垂直移动退化为直线插值;距离不足以生成一个点时直接返回终点, + 避免调用方拿到空轨迹再去取 [0]。 + """ + number_list = int(math.dist(start, end) / (1 * interval)) + if number_list < 1: + return [tuple(end)] + if start[0] == end[0]: + dy = end[1] - start[1] + return [[int(start[0]), int(start[1] + dy * i / number_list)] + for i in range(1, number_list + 1)] + le = random.randint(2, 4) + deviation = random.randint(20, 40) + obbs_type = random.random() + # 0.8 概率先快中间慢后面快,0.1 先快后慢,0.1 先慢后快 + if 0 < obbs_type <= 0.8: + b_type = 3 + elif obbs_type < 0.9: + b_type = 2 + else: + b_type = 1 + return BezierTrajectory.trackArray(start=list(start), end=list(end), numberList=number_list, + le=le, deviation=deviation, bias=0.5, type=b_type, + cbb=0, yhh=20) + # print(BezierTrajectory.trackArray(start=[0, 0], end=[100, 100], numberList=50, le=2, # deviation=0, bias=0.5, type=0, cbb=0, yhh=10)) \ No newline at end of file diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py index cee77d2e9f..ff53e7f6f9 100644 --- a/module/device/connection_attr.py +++ b/module/device/connection_attr.py @@ -60,8 +60,10 @@ def __init__(self, config): if 'eri' in k[0].split('_')[-1]: print(k, v) su.__setattr__(k[0], chr(8) + v) - # Cache adb_client - _ = self.adb_client + # 桌面客户端模式不需要 adb,跳过 adb server 拉起 + if not self.is_desktop: + # Cache adb_client + _ = self.adb_client # Parse custom serial # self.serial = str(self.config.Emulator_Serial) @@ -127,6 +129,15 @@ def is_mumu_family(self): def is_emulator(self): return self.serial.startswith('emulator-') or self.serial.startswith('127.0.0.1:') + @cached_property + def is_desktop(self): + """桌面客户端模式:serial 配置为 'desktop'。 + + 不做异常兜底:读不到配置说明 Device 构造链没走到 ConnectionAttr, + 此时静默返回 False 会把桌面模式当成模拟器模式继续跑,报错指向完全无关的地方。 + """ + return str(self.config.script.device.serial) == 'desktop' + @cached_property def is_network_device(self): return bool(re.match(r'\d+\.\d+\.\d+\.\d+:\d+', self.serial)) diff --git a/module/device/handle.py b/module/device/handle.py index b0b5818db8..547a4bdadc 100644 --- a/module/device/handle.py +++ b/module/device/handle.py @@ -18,6 +18,7 @@ WM_LBUTTONDOWN, WM_ACTIVATE, WA_ACTIVE, MK_LBUTTON, WM_NCHITTEST, WM_SETCURSOR, HTCLIENT, WM_MOUSEMOVE) from module.config.config import Config +from module.device.method.desktop_impl import DesktopWindow, DESKTOP_WINDOW_TITLES from module.logger import logger @@ -127,7 +128,7 @@ class EmulatorFamily(Enum): """"" # ********************************************************************************************************************** -class Handle: +class Handle(DesktopWindow): emulator_list = ['MuMu12', 'MuMu', '雷电', @@ -157,6 +158,8 @@ class Handle: 'bluestacks_family': ['root_handle_title'] } config: Config = None + is_desktop_window: bool = False + """是否为桌面客户端模式(serial='desktop'),桌面分支统一用此标志隔离""" def __init__(self, config) -> None: """ @@ -173,6 +176,18 @@ def __init__(self, config) -> None: logger.info('Handle is empty, oas not use handle') return + # 桌面客户端模式:按 PID 定位窗口,跳过模拟器窗口树逻辑 + if self.config.script.device.serial == 'desktop': + self.root_handle_title = '' + self.root_handle_num = 0 + self.root_handle = self.config.script.device.handle + logger.info(f'Desktop handle PID is {self.root_handle}') + self.root_handle_num = self.find_desktop_window_by_pid(self.root_handle) + self.root_handle_title = DESKTOP_WINDOW_TITLES[0] + self.is_desktop_window = True + logger.info(f'Desktop client window found: title={self.root_handle_title}, hwnd={self.root_handle_num}') + return + # 获取根的句柄 self.root_handle_title = '' self.root_handle_num = 0 @@ -347,6 +362,8 @@ def screenshot_handle_num(self) -> int: 截屏的句柄其实并不是根句柄 :return: 出错返回None """ + if getattr(self, 'is_desktop_window', False): + return self.root_handle_num if self.emulator_family == EmulatorFamily.FAMILY_MUMU: # 使用正则匹配12 来判定是不是mumu12这并不是一个好的方法 name = self.root_node.children[0].name @@ -390,6 +407,9 @@ def screenshot_size(self) -> tuple or None: 2023.7.1 在高缩放的设备上应该输出1280X720 :return: """ + if getattr(self, 'is_desktop_window', False): + # 桌面模式固定输出 1280x720(物理目标,与资产 1:1),位图由截图方法缩放得到 + return 1280, 720 winRect = GetWindowRect(self.screenshot_handle_num) scale_rate = window_scale_rate() width_before: int = winRect[2] - winRect[0] # 右x-左x diff --git a/module/device/method/desktop_impl.py b/module/device/method/desktop_impl.py new file mode 100644 index 0000000000..55bb2ce00b --- /dev/null +++ b/module/device/method/desktop_impl.py @@ -0,0 +1,990 @@ +# This Python file uses the following encoding: utf-8 +"""桌面客户端(阴阳师 PC 版)窗口控制实现。 + +从 module/device/handle.py 拆出:桌面模式与模拟器模式除"窗口句柄怎么拿到"外没有 +任何共享逻辑,两者同处一个类会把 handle.py 撑到 1300+ 行,也让"移植到底动没动 +模拟器基线"难以一眼看清。这里用纯 mixin ``DesktopWindow`` 承载全部桌面分支 +(``Handle`` 继承它),handle.py 只保留模拟器逻辑与少量桌面内联判断。 + +边界约定: +- ``DesktopWindow`` 不定义 ``__init__``,全部实例状态由 ``Handle.__init__`` 的 + 桌面分支设置;类内方法名与 Handle 模拟器方法无重名,MRO 无歧义; +- 本模块不反向依赖 handle.py,是叶子模块,无循环导入; +- 模块级常量与 helper(``list_desktop_windows`` / ``desktop_window_option`` / + ``desktop_option2pid`` / ``dpi_awareness`` 等)一并住在这里,外部调用方直接 + 从本模块导入,不经 handle.py 转出——避免"补丁打在转出别名上静默失效"。 +""" + +import os +import re +import subprocess +import time +import ctypes +from ctypes import c_long, byref, POINTER, Structure, wintypes + +from contextlib import contextmanager +from pathlib import Path + +from win32api import SendMessage +from win32process import GetWindowThreadProcessId +from win32gui import (GetWindowText, EnumWindows, GetWindowRect, IsWindow, + GetClientRect, ClientToScreen, GetWindowLong, + SetWindowPos, GetClassName, IsIconic, ShowWindow) +from win32con import (WM_KEYDOWN, WM_KEYUP, VK_RETURN, GWL_STYLE, GWL_EXSTYLE, + HWND_TOP, SWP_NOMOVE, SWP_SHOWWINDOW, SW_RESTORE) + +from module.base.decorator import del_cached_property +from module.exception import EmulatorNotRunningError +from module.logger import logger + + +# 桌面客户端窗口标题(官方桌面版,多开共用同一标题) +DESKTOP_WINDOW_TITLES = ('阴阳师-网易游戏', '阴阳师-MuMu模拟器专版') +# 「进程活着但窗口还没就绪」时的等待上限(秒)与轮询间隔。空窗期有两种:客户端 +# 启动期还没出窗口、确认 MPay 弹窗后销毁登录界面重建主窗口的那几秒。这段等待只 +# 轮询窗口,绝不重新启动进程——把「窗口暂时不在」当成「客户端没在运行」会拉起 +# 多余实例(单实例语义下用户正在用的那个会被顶掉),并把配置里的 handle 覆盖成新实例 +DESKTOP_WINDOW_READY_WAIT = 15 +DESKTOP_WINDOW_READY_POLL_INTERVAL = 0.5 + +# 网易 MPay 账号登录弹窗的窗口类名。它是与游戏主窗口同 PID 的独立顶层窗口 +# (DirectUI 绘制,无子控件),不在游戏渲染面内,因此主窗口 BitBlt 截不到它, +# 也无法用图像识别处理,只能按类名单独定位并注入消息。 +DESKTOP_LOGIN_POPUP_CLASS = 'MPAY_LOGIN' + +# 调整窗口尺寸时撞上客户端重建窗口的重试轮数与间隔(秒)。客户端确认登录弹窗后销毁 +# 登录界面、重建游戏主窗口,实测这段空窗期在几百毫秒到数秒之间,6 轮 × 1s 足够覆盖 +DESKTOP_RESIZE_ATTEMPTS = 6 +DESKTOP_RESIZE_RETRY_INTERVAL = 1.0 +# 关闭桌面客户端的强杀轮数。TerminateProcess 可能因权限被拒(实测本机出现过 +# (5, '拒绝访问。')),也可能进程正在退出但还没消失,因此杀完必须验证进程真的没了, +# 没死就再杀一轮,而不是发完指令就当成功 +DESKTOP_KILL_ATTEMPTS = 3 +DESKTOP_KILL_POLL_INTERVAL = 0.5 + +# DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,SetThreadDpiAwarenessContext 的入参 +_PER_MONITOR_AWARE_V2 = ctypes.c_void_p(-4) + +# 桌面客户端运行期间强制保持显示器常亮:桌面模式截图走 BitBlt,依赖 DWM 合成, +# 屏幕一旦因电源超时关闭,DWM 停止合成,BitBlt 只能返回静止黑帧,登录循环会因此 +# 死等 300s 超时。在客户端启动时开启、关闭时清除,只锁客户端生命周期,不影响 +# OAS 空闲时用户正常让屏幕休眠。 +ES_CONTINUOUS = 0x80000000 +ES_DISPLAY_REQUIRED = 0x00000002 + + +@contextmanager +def dpi_awareness(): + """临时把当前线程切到 Per-Monitor V2 DPI 感知,退出时恢复原上下文。 + + OAS 进程自身未声明 DPI 感知,GDI 调用默认被系统虚拟化成逻辑像素:125% 缩放下 + 物理 1280x720 的客户区只报 1024x576,BitBlt 也只能取到画面左上角那一块(右侧 + 侧边栏与底部菜单栏直接丢失)。桌面客户端进程是 Per-Monitor DPI 感知、按物理 + 像素原生渲染,因此桌面分支的窗口测量与截图必须在感知上下文里做才能拿到完整的 + 物理像素画面。用线程级而非进程级,是为了不影响模拟器直控路径既有的逻辑像素假设。 + """ + user32 = ctypes.windll.user32 + previous = None + try: + previous = user32.SetThreadDpiAwarenessContext(_PER_MONITOR_AWARE_V2) + except AttributeError: + # Windows 10 1607 以前没有该 API,退化为原有逻辑像素行为 + pass + try: + yield + finally: + if previous: + user32.SetThreadDpiAwarenessContext(previous) + + +def desktop_keep_screen_on(enable: bool) -> None: + """按桌面客户端生命周期开关「强制保持显示器常亮」。 + + SetThreadExecutionState 是线程级状态:ES_CONTINUOUS 保持该线程的请求,直到 + 再次调用清除。OAS worker 的任务在同一长驻线程内启停客户端,因此这里设置即可。 + + 注意 ES_DISPLAY_REQUIRED 只「阻止未来关闭」,屏幕若已因电源超时熄灭,必须先用 + SC_MONITORPOWER(-1) 广播把显示器物理点亮,否则 BitBlt 仍返回黑帧。 + """ + if enable: + # 物理点亮:SC_MONITORPOWER(-1) 单独发消息对部分显示器/驱动不触发硬件唤醒 + # (实测只发它屏幕仍黑),必须再模拟一次无害键盘输入与鼠标移动,产生真实 + # 输入事件才能真正点亮。VK_F15(0x7E) 无副作用,不会干扰游戏。 + try: + # 必须用 SendMessageTimeoutW 而不是 SendMessageW:目标是 + # HWND_BROADCAST(0xFFFF),SendMessageW 会同步等系统里每一个顶层窗口 + # 处理完才返回,只要有一个窗口消息循环繁忙或无响应就永久阻塞调用线程 + # (实测 pytest 里这一行挂死 26 分钟不返回,生产上等于 worker 线程卡住)。 + # SMTO_ABORTIFHUNG(0x0002) 遇到无响应窗口直接放弃,1000ms 上限兜底。 + ctypes.windll.user32.SendMessageTimeoutW( + 0xFFFF, 0x0111, 0xF170, -1, 0x0002, 1000, None) + except Exception as e: + logger.warning(f'SC_MONITORPOWER wake failed: {e}') + try: + user32 = ctypes.windll.user32 + user32.keybd_event(0x7E, 0, 0, 0) + user32.keybd_event(0x7E, 0, 2, 0) + user32.SetCursorPos(400, 300) + except Exception as e: + logger.warning(f'wake input failed: {e}') + state = ES_CONTINUOUS | (ES_DISPLAY_REQUIRED if enable else 0) + try: + ctypes.windll.kernel32.SetThreadExecutionState(state) + logger.info(f'Desktop screen keep-awake {"ON" if enable else "OFF"}') + except Exception as e: + logger.warning(f'SetThreadExecutionState({enable}) failed: {e}') + + +def _window_total_size(width: int, height: int, style: int, ex_style: int) -> tuple: + """用 user32.AdjustWindowRectEx 计算包含标题栏/边框的窗口总尺寸 (w, h)。 + + pywin32 未导出 AdjustWindowRectEx,故用 ctypes 调用。 + """ + user32 = ctypes.windll.user32 + + class RECT(Structure): + _fields_ = [('left', c_long), ('top', c_long), ('right', c_long), ('bottom', c_long)] + + user32.AdjustWindowRectEx.argtypes = [POINTER(RECT), c_long, c_long, c_long] + user32.AdjustWindowRectEx.restype = wintypes.BOOL + rect = RECT(0, 0, width, height) + user32.AdjustWindowRectEx(byref(rect), style, False, ex_style) + return rect.right - rect.left, rect.bottom - rect.top + + +def _desktop_process_image(pid) -> str: + """返回进程 exe 完整路径;打开/查询失败(已退出、权限)返回空串。""" + kernel32 = ctypes.windll.kernel32 + # PROCESS_QUERY_LIMITED_INFORMATION + handle = kernel32.OpenProcess(0x1000, False, int(pid)) + if not handle: + return '' + try: + buf = ctypes.create_unicode_buffer(520) + size = ctypes.c_ulong(520) + if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)): + return buf.value + return '' + finally: + kernel32.CloseHandle(handle) + + +def _desktop_pid_under_root(pid, root) -> bool: + """窗口归属进程的 exe 是否位于客户端安装目录之下。 + + 自动启动绑定时用于排除同标题的模拟器窗口(MuMu 专版窗口标题也在 + DESKTOP_WINDOW_TITLES 内),保证绑到的一定是安装目录拉起的客户端进程。 + """ + exe = _desktop_process_image(pid) + if not exe: + return False + root = os.path.normcase(os.path.normpath(root)) + exe = os.path.normcase(os.path.normpath(exe)) + return exe.startswith(root + os.sep) + + +def list_desktop_windows() -> list: + """枚举当前所有桌面客户端窗口。 + + 返回 [{'pid': int, 'title': str, 'x': int, 'y': int}],按屏幕位置排序。 + 桌面客户端多开时窗口标题完全相同,只能靠 PID 区分,附带左上角坐标是为了 + 让用户在界面上按"窗口摆在哪"对号入座。按坐标排序保证同一组窗口每次枚举 + 顺序稳定(EnumWindows 的返回顺序随 Z 序变化)。 + """ + def enum_cb(hwnd, param): + param.append(hwnd) + return True + + handles = [] + EnumWindows(enum_cb, handles) + result = [] + for hwnd in handles: + if GetWindowText(hwnd) not in DESKTOP_WINDOW_TITLES: + continue + try: + rect = GetWindowRect(hwnd) + except Exception: + # 窗口在枚举与取矩形之间被关闭,跳过即可 + continue + result.append({ + 'pid': GetWindowThreadProcessId(hwnd)[1], + 'title': GetWindowText(hwnd), + 'hwnd': hwnd, + 'x': rect[0], + 'y': rect[1], + }) + result.sort(key=lambda w: (w['x'], w['y'], w['pid'])) + return result + + +def desktop_window_option(window: dict) -> str: + """把窗口信息格式化成界面下拉项文本,形如 `27272 (154,38)`。 + + 不显示窗口标题:桌面客户端多开时标题完全相同,且中文字样在英文界面里冗余。 + PID 放在最前,desktop_option2pid 只认第一段数字,坐标纯属给用户辨识用, + 改动展示格式不会影响解析。 + """ + return f"{window['pid']} ({window['x']},{window['y']})" + + +def desktop_option2pid(option: str) -> str: + """从下拉项文本中取回纯 PID;取不到时返回空串。 + + 界面回传的是展示文本,但落盘的 handle 必须是纯 PID(Handle 按数字消费), + 因此写入配置前统一在这里剥掉标题与坐标。用户手工填的纯数字原样通过。 + """ + if not option: + return '' + matched = re.match(r'\s*(\d+)', str(option)) + return matched.group(1) if matched else '' + + +class DesktopWindow: + def find_desktop_window_by_pid(self, pid) -> int: + """按 PID 查找桌面客户端窗口,返回窗口句柄;未找到抛 EmulatorNotRunningError。 + + 「找不到窗口」记 warning 而非 error:本方法的调用方多为探测存在性 + (desktop_window_exists 判断是否需要重拉、_desktop_wait_closed 确认已关闭), + 找不到是正常答案。真正的故障由上层在拿到 EmulatorNotRunningError 后判定, + 这样真故障不会被淹没在每轮任务收尾都出现的噪音里。 + + 同一 PID 下可能同时存在多个标题命中的顶层窗口(客户端多开共用同一标题; + 新版 MuMu 内核客户端启动期还带着启动器残留窗口),取客户区最大者即可。 + + 这里刻意**不设客户区面积下限**:窗口被最小化时客户区是 0x0,但客户端仍在 + 运行,还原交给 desktop_window_restore_if_minimized。用面积下限去宣布「未运行」 + 会把这个正常挂着的客户端判成掉线 → 触发误重拉并把配置 handle 覆盖成新实例 + (真机实测的循环就是这么起来的),而 BitBlt 截图前本来就会先把窗口还原。 + """ + try: + pid_int = int(str(pid)) + except (TypeError, ValueError): + logger.error(f'Invalid desktop PID: {pid}') + raise EmulatorNotRunningError(f'Invalid desktop client PID: {pid}') + + def enum_cb(hwnd, param): + param.append(hwnd) + return True + + windows = [] + EnumWindows(enum_cb, windows) + matches = [] + for hwnd in windows: + if GetWindowText(hwnd) in DESKTOP_WINDOW_TITLES: + _, win_pid = GetWindowThreadProcessId(hwnd) + if win_pid == pid_int: + matches.append(hwnd) + # 同 PID 的多个候选里取客户区最大者:正常渲染窗口面积最大,启动器残留/ + # 最小化窗口(客户区 0x0)会排在后面。读不到客户区的(枚举与取值之间被销毁) + # 直接跳过,全部读不到才算未找到 + best, best_area = 0, -1 + for hwnd in matches: + try: + cl, ct, cr, cb = GetClientRect(hwnd) + area = (cr - cl) * (cb - ct) + except Exception: + continue + if area > best_area: + best, best_area = hwnd, area + if best: + return best + logger.warning(f'Desktop client window not found, PID={pid}') + raise EmulatorNotRunningError(f'Desktop client window not found, PID={pid}') + + def _desktop_best_window(self, pid, fallback: int = 0) -> int: + """取指定 PID 下客户区最大的候选窗口;查不到时回退 fallback。 + + 用于自动启动完成时的句柄确定:同一 PID 可能既带启动器残留窗口又带真渲染窗口, + 取客户区最大者才不会把截图绑到残壳上。 + """ + try: + return self.find_desktop_window_by_pid(pid) + except EmulatorNotRunningError: + return fallback + + def desktop_pid(self): + """返回当前桌面客户端 PID(int);取不到返回 None。 + + 优先用实例 root_handle(运行时自动启动绑定后的新 PID),未设置时回退配置里的 + handle。本会话的 PID 判断必须以实例状态为准,否则重新拉起后会误判。 + """ + pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + try: + return int(str(pid)) + except (TypeError, ValueError): + return None + + def find_desktop_login_popup(self) -> int: + """按 PID + 类名查找 MPay 账号登录弹窗,返回句柄;没有则返回 0。 + + 弹窗与游戏主窗口同 PID 但是独立顶层窗口(owner 为主窗口,非子窗口), + 没有子控件可枚举,只能按窗口类名定位。 + 按 PID 过滤保证多开时各实例只处理自己客户端的弹窗。 + + 主窗口 BitBlt 是否包含这个 owner 窗口受桌面合成状态影响,同一个弹窗可能在 + 不同错误截图中出现或消失,所以登录状态不能依赖截图判断。 + 改按「HWND 是否仍存活」判断而不再过滤 IsWindowVisible:真机验证过已登录的 + 客户端全系统枚举不到任何 MPAY_LOGIN 顶层窗口,说明客户端确认弹窗后是销毁它、 + 而非隐藏。因此 HWND 存活即代表尚未登录。 + """ + pid_int = self.desktop_pid() + if pid_int is None: + return 0 + + def enum_cb(hwnd, param): + param.append(hwnd) + return True + + windows = [] + EnumWindows(enum_cb, windows) + for hwnd in windows: + try: + if GetClassName(hwnd) != DESKTOP_LOGIN_POPUP_CLASS: + continue + if GetWindowThreadProcessId(hwnd)[1] == pid_int: + return hwnd + except Exception: + # 窗口在枚举与取属性之间被关闭,跳过即可 + continue + return 0 + + def desktop_confirm_login_popup(self, wait: float = 15.0) -> bool: + """向 MPay 登录弹窗发送回车,并确认对应 HWND 已真实消失。 + + 弹窗是 DirectUI 独立顶层窗口,"进入游戏"只是绘制出来的像素、没有真实控件, + 实测后台鼠标消息(WM_MOUSEMOVE/LBUTTONDOWN/LBUTTONUP,Post 与 Send 都试过) + 完全无响应;同步与异步回车在无前台焦点时均可触发默认按钮。因此这里只发回车, + 并在 wait 秒内轮询确认弹窗 HWND 真的消失。 + + 返回 True 表示弹窗确实已关闭;False 表示没有弹窗,或超时后弹窗仍然存活。 + """ + if not self.find_desktop_login_popup(): + return False + logger.info('Desktop MPay login popup found, press Enter to enter game') + deadline = time.time() + wait + while time.time() < deadline: + hwnd = self.find_desktop_login_popup() + if not hwnd: + logger.info('Desktop MPay login popup confirmed closed') + return True + self._desktop_send_enter(hwnd) + time.sleep(1) + logger.warning(f'Desktop MPay login popup still present after {wait}s, Enter had no effect') + return False + + def desktop_window_exists(self) -> bool: + """桌面模式:目标 PID 对应窗口仍存在即视为客户端运行中。 + + 只判「标题命中的顶层窗口在不在」,不看客户区尺寸——窗口被最小化或被遮挡时 + 客户端仍在运行,截图方法会先把窗口还原回来再取帧。 + + 优先用实例 root_handle(运行时自动启动绑定后的新 PID),未设置时回退配置里的 + handle。本会话的窗口存在性判断必须以实例状态为准,否则重新拉起后会误判为未运行。 + """ + pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + try: + self.find_desktop_window_by_pid(pid) + return True + except EmulatorNotRunningError: + return False + + def _desktop_wait_window_ready(self, pid, wait: float = DESKTOP_WINDOW_READY_WAIT) -> bool: + """在 wait 秒内等待指定 PID 的客户端窗口出现;超时返回 False。 + + 用于「进程还活着、窗口暂时不可见」的空窗期。这段时间只能等,不能重新启动 + 客户端:真正在跑的那个实例会变成多开或被顶掉,配置里的 handle 也会被新实例 + 覆盖,从此再也绑不回原来的客户端。 + """ + deadline = time.time() + wait + while True: + try: + self.find_desktop_window_by_pid(pid) + return True + except EmulatorNotRunningError: + pass + if time.time() >= deadline: + logger.error(f'Desktop client process alive but window not ready in {wait}s, PID={pid}') + return False + time.sleep(DESKTOP_WINDOW_READY_POLL_INTERVAL) + + def desktop_client_offset(self) -> tuple: + """返回客户区在窗口 DC 内的偏移 (x, y),用于 BitBlt 扣除标题栏/边框。""" + with dpi_awareness(): + window_rect = GetWindowRect(self.screenshot_handle_num) + client_origin = ClientToScreen(self.screenshot_handle_num, (0, 0)) + return client_origin[0] - window_rect[0], client_origin[1] - window_rect[1] + + def desktop_client_size(self) -> tuple: + """返回桌面客户端窗口客户区的物理像素尺寸 (width, height)。""" + with dpi_awareness(): + rect = GetClientRect(self.screenshot_handle_num) + return rect[2] - rect[0], rect[3] - rect[1] + + def desktop_client_size_virtual(self) -> tuple: + """返回客户区在 DPI 虚拟化空间下的尺寸 (width, height)。 + + OAS 进程未声明 DPI 感知,PostMessage 的 lParam 会被系统按该空间解释, + 因此后台输入的坐标必须换算到这里,而不是截图所用的物理空间。 + """ + rect = GetClientRect(self.screenshot_handle_num) + return rect[2] - rect[0], rect[3] - rect[1] + + def _desktop_client_size(self, hwnd): + """读窗口客户区物理尺寸,句柄已失效返回 None。 + + 客户端从登录界面切到游戏主窗口时会销毁重建渲染窗口,因此调整窗口期间的任何 + 一次 Win32 调用都可能撞上失效句柄。GetClientRect 对废句柄抛 + (1400, '无效的窗口句柄'),不接住会直接崩掉整个脚本进程。 + """ + if not IsWindow(hwnd): + return None + try: + rect = GetClientRect(hwnd) + except Exception as e: + logger.warning(f'GetClientRect failed (hwnd={hwnd}): {e}') + return None + return rect[2] - rect[0], rect[3] - rect[1] + + def _desktop_clear_handle_cache(self) -> None: + """失效截图相关 cached_property,使其按当前 root_handle_num 重新求值。 + + 桌面模式下 screenshot_handle_num 直接返回 root_handle_num,但它是 + cached_property:客户端重开后 root_handle_num 已换成新 hwnd,缓存仍指向 + 旧句柄,截图时 GetClientRect 会拿废句柄抛 (1400)。 + """ + for prop in ('screenshot_handle_num', 'screenshot_size'): + if prop in self.__dict__: + del_cached_property(self, prop) + + def _desktop_rebind_window(self) -> bool: + """按 PID 重新查找客户端窗口并绑定新 hwnd,返回是否绑定成功。 + + 用于两类句柄刷新:窗口重建(登录界面切游戏主窗口)后更换句柄,以及同一 PID + 下换到客户区更大的那个候选窗口。进程还活着,不需要重拉客户端。 + """ + try: + hwnd = self.find_desktop_window_by_pid(self.root_handle) + except EmulatorNotRunningError: + return False + if hwnd != self.root_handle_num: + logger.info(f'Desktop window handle changed, rebind hwnd {self.root_handle_num} -> {hwnd}') + self.root_handle_num = hwnd + self._desktop_clear_handle_cache() + return True + + def desktop_window_set_size(self, width: int = 1280, height: int = 720) -> bool: + """桌面模式:检测窗口客户区尺寸,非目标大小时用 SetWindowPos 调整到 width×height。 + + 窗口位置保持不变;返回是否执行了调整。全过程在 DPI 感知上下文内完成, + GetClientRect/SetWindowPos 处理的都是物理像素,因此目标尺寸无需按缩放比换算, + 调整后客户区物理尺寸恰为 width×height,游戏画面与资产 1:1 对应。 + + 客户端确认登录弹窗后会销毁登录界面、重建游戏主窗口,调整过程中旧 hwnd 随时可能 + 失效。此时不抛异常也不重拉客户端(进程还活着),而是按 PID 重新绑定重建后的 + 窗口再试,最多 DESKTOP_RESIZE_ATTEMPTS 轮。 + """ + if not getattr(self, 'is_desktop_window', False): + return False + for attempt in range(1, DESKTOP_RESIZE_ATTEMPTS + 1): + result = self._desktop_try_set_size(width, height) + if result is not None: + return result + if attempt >= DESKTOP_RESIZE_ATTEMPTS: + break + logger.info(f'Desktop window invalid, rebind and retry resize ' + f'({attempt}/{DESKTOP_RESIZE_ATTEMPTS})') + time.sleep(DESKTOP_RESIZE_RETRY_INTERVAL) + self._desktop_rebind_window() + logger.warning(f'Desktop window resize gave up after {DESKTOP_RESIZE_ATTEMPTS} attempts, ' + f'window kept invalid') + return False + + def _desktop_try_set_size(self, width: int, height: int): + """单轮尝试调整窗口尺寸。 + + 返回 True/False 表示本轮已得出结论(是否执行了调整);返回 None 表示句柄失效, + 需由调用方重新绑定窗口后再试。三态的意义在于把「尺寸本来就对」(False)和 + 「窗口正在重建」(None)区分开,否则前者会白等重试。 + """ + hwnd = self.root_handle_num + if not hwnd or not IsWindow(hwnd): + logger.warning(f'Desktop window handle invalid (hwnd={hwnd})') + return None + with dpi_awareness(): + size = self._desktop_client_size(hwnd) + if size is None: + logger.warning(f'Desktop window vanished before resize (hwnd={hwnd})') + return None + client_w, client_h = size + logger.info(f'Desktop client size: {client_w}x{client_h} (physical), target {width}x{height}') + if client_w == width and client_h == height: + logger.info('Desktop client size already matches target') + return False + # 用 AdjustWindowRectEx 精确计算含标题栏/边框的窗口总尺寸 + style = GetWindowLong(hwnd, GWL_STYLE) + ex_style = GetWindowLong(hwnd, GWL_EXSTYLE) + total_w, total_h = _window_total_size(width, height, style, ex_style) + logger.info(f'Resize desktop window to {total_w}x{total_h} to get client {width}x{height}') + try: + SetWindowPos(hwnd, HWND_TOP, 0, 0, total_w, total_h, SWP_NOMOVE | SWP_SHOWWINDOW) + except Exception as e: + # 拒绝访问通常是目标窗口权限更高(游戏以管理员运行)或客户端锁定窗口大小 + logger.error(f'SetWindowPos failed: {e}. ' + f'请以管理员身份运行 OAS,或手动把游戏窗口设为 1280x720') + return False + # 校准:SetWindowPos 后的实际客户区可能与目标差几像素,按差值持续修正。 + # SetWindowPos 本身可能正好撞上客户端重建窗口,因此每轮都要重新确认句柄有效 + for _ in range(5): + size = self._desktop_client_size(hwnd) + if size is None: + logger.warning(f'Desktop window vanished during resize (hwnd={hwnd}), ' + f'client is rebuilding its window') + return None + cw, ch = size + if cw == width and ch == height: + return True + total_w += width - cw + total_h += height - ch + logger.info(f'Calibrate desktop window to {total_w}x{total_h}, current client {cw}x{ch}') + try: + SetWindowPos(hwnd, HWND_TOP, 0, 0, total_w, total_h, SWP_NOMOVE | SWP_SHOWWINDOW) + except Exception as e: + logger.warning(f'SetWindowPos failed during calibration (hwnd={hwnd}): {e}') + return None + return True + + # ------------------------------------------------------------------ 桌面客户端自动生命周期 + + def desktop_resolve_install_root(self) -> str: + """解析桌面客户端安装目录:优先 desktop_game_path 配置,其次自动发现。 + + 只认含 bin\\onmyoji.exe(或根目录 Launch.exe)的安装目录,找不到返回空串。 + 仅桌面模式调用,不影响模拟器流程。 + """ + configured = getattr(self.config.script.device, 'desktop_game_path', '') or '' + if configured: + root = self._desktop_root_from_path(configured) + if root is not None: + return str(root) + logger.warning(f'desktop_game_path 配置无效: {configured},尝试自动发现') + root = self._desktop_discover_install_root() + return str(root) if root else '' + + @staticmethod + def _desktop_root_from_path(value: str): + """把用户填的路径归一化成安装目录;无效返回 None。""" + path = Path(value).expanduser() + if path.is_file(): + # 允许直接填 bin\\onmyoji.exe 或 Launch.exe 的完整路径 + if path.name.lower() == 'onmyoji.exe' and path.parent.name.lower() == 'bin': + path = path.parent.parent + elif path.name.lower() == 'launch.exe': + path = path.parent + else: + path = path.parent + candidate = path / 'bin' / 'onmyoji.exe' + if candidate.is_file(): + return path.resolve() + return None + + @staticmethod + def _desktop_discover_install_root(): + """按 %ProgramFiles%\\Onmyoji 与注册表 Uninstall 的 InstallLocation 自动发现安装目录。""" + for env in ('ProgramFiles', 'ProgramFiles(x86)'): + raw = os.environ.get(env) + if raw and (Path(raw) / 'Onmyoji' / 'bin' / 'onmyoji.exe').is_file(): + return (Path(raw) / 'Onmyoji').resolve() + try: + import winreg + roots = ( + (winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'), + (winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'), + (winreg.HKEY_CURRENT_USER, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'), + ) + for hive, key_path in roots: + try: + with winreg.OpenKey(hive, key_path) as parent: + for index in range(winreg.QueryInfoKey(parent)[0]): + try: + with winreg.OpenKey(parent, winreg.EnumKey(parent, index)) as child: + display, _ = winreg.QueryValueEx(child, 'DisplayName') + if '阴阳师' not in str(display) and 'Onmyoji' not in str(display): + continue + location, _ = winreg.QueryValueEx(child, 'InstallLocation') + if location and (Path(location) / 'bin' / 'onmyoji.exe').is_file(): + return Path(location).resolve() + except OSError: + continue + except OSError: + continue + except Exception: + pass + return None + + @staticmethod + def desktop_game_exe(root) -> str: + """返回游戏可执行文件路径;找不到返回空串。 + + 顺序即优先级:必须优先游戏本体 bin/onmyoji.exe。 + Launch.exe 是登录器,它会再拉起 bin/onmyoji.exe, + 导致双开且 OAS 绑定到登录器 PID 上找不到游戏窗口。 + """ + root = Path(root) + for name in ('bin/onmyoji.exe', 'Launch.exe'): + exe = root / name + if exe.is_file(): + return str(exe) + return '' + + def launch_desktop_client(self, timeout: int = 90) -> bool: + """自动启动桌面客户端并绑定新窗口的 PID/HWND,成功返回 True。 + + 单轮 = 启动 exe → 等新窗口出现并稳定 → 绑定其 PID/HWND。到此启动即完成; + MPay 登录弹窗与进游戏属登录流程,由 Restart 的 app_handle_login 负责。 + timeout 内没等到窗口说明客户端起歪了(卡加载、崩在启动期等),强杀本轮进程后 + 整轮重跑一次;第二轮仍失败则记 error 返回 False,由上层停下等人工,不无限重试。 + 安装目录/exe 找不到属配置问题,重试无意义,直接返回 False。 + """ + root = self.desktop_resolve_install_root() + if not root: + logger.error('未找到阴阳师桌面客户端安装目录,请在 设置-Script-设备 中填写 desktop_game_path') + return False + exe = self.desktop_game_exe(root) + if not exe: + logger.error(f'未找到游戏程序(bin\\onmyoji.exe 或 Launch.exe):{root}') + return False + + for attempt in (1, 2): + pids = self._desktop_launch_attempt(exe, root, timeout, attempt) + if pids is None: + # Popen 本身失败,重试也起不来 + return False + bound_pid, spawned_pid = pids + if bound_pid: + # 启动成功也要记住本轮拉起的全部 PID:onmyoji.exe 是启动器,它会派生出 + # 真正的游戏窗口进程后自己继续存活(实测启动器 8932 派生窗口进程 6816, + # 89 线程 42s CPU 却无窗口)。OAS 靠枚举窗口只绑到窗口进程,关闭时若只杀 + # 它,启动器就成了无主残留。这里留档,交给 desktop_stop_client 一并清理 + self._desktop_spawned_pids = set(spawned_pid) + # 客户端已拉起,强制保持显示器常亮,防止电源超时关屏导致截图黑帧卡死 + desktop_keep_screen_on(True) + return True + if attempt == 1: + # 只杀本轮确切启动的进程:绑定阶段就失败时 root_handle 仍是上一次的陈旧 + # PID,直接调 desktop_force_kill 会误杀(PID 可能已被系统复用) + logger.warning('第 1 轮启动未就绪,清理本轮客户端后重试') + self._desktop_kill_pids(spawned_pid) + logger.error('桌面客户端连续 2 轮启动均未就绪,请检查客户端状态与机器负载') + return False + + def _desktop_launch_attempt(self, exe: str, root: str, timeout: int, attempt: int): + """启动客户端并绑定其窗口的单轮尝试。 + + 返回 (bound_pid, spawned_pid):bound_pid 非 0 表示本轮成功;spawned_pid 是本轮 + Popen 出的进程与绑定到的窗口 PID 集合,供失败清理精确定位。Popen 失败返回 None。 + """ + before_pids = {w['pid'] for w in list_desktop_windows()} + logger.info(f'自动启动桌面客户端(第 {attempt} 轮): {exe}') + spawned = set() + try: + proc = subprocess.Popen([exe], cwd=root, + creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)) + spawned.add(proc.pid) + except Exception as e: + logger.error(f'启动桌面客户端失败: {e}') + return None + + deadline = time.time() + timeout + stable_hwnd = 0 + stable_count = 0 + while time.time() < deadline: + # 候选必须落在安装目录下的进程:客户端新旧版本窗口标题不同 + # ("阴阳师-网易游戏" / "阴阳师-MuMu模拟器专版"),启动等待期内任何 + # 匹配标题的新窗口都会成为候选,按 exe 归属收口,避免绑到别的进程 + candidates = [w for w in list_desktop_windows() + if w['pid'] not in before_pids + and _desktop_pid_under_root(w['pid'], root)] + if candidates: + candidate = candidates[0] + if candidate['hwnd'] == stable_hwnd: + stable_count += 1 + else: + stable_hwnd = candidate['hwnd'] + stable_count = 1 + if stable_count >= 2: + # 同一 PID 可能还带着启动器残留窗口(客户区极小),句柄统一取 + # 客户区最大者,否则截图会绑到残壳上(BitBlt 取不到画面) + hwnd = self._desktop_best_window(candidate['pid'], candidate['hwnd']) + self.desktop_bind_pid(candidate['pid'], hwnd) + spawned.add(candidate['pid']) + logger.info(f'桌面客户端已自动启动并绑定 PID={candidate["pid"]}') + # 到这里启动就完成了:进程在跑、窗口句柄已绑定。 + # MPay 登录弹窗与进游戏属于登录流程,由 Restart 的 app_handle_login + # 负责(登录循环每轮都复查弹窗,中途冒出来也能接住)。 + return candidate['pid'], spawned + time.sleep(1) + logger.warning(f'桌面客户端进程已启动,但 {timeout} 秒内未识别到游戏窗口') + return 0, spawned + + def _desktop_kill_pids(self, pids) -> None: + """强杀指定 PID 集合(本轮启动失败的清理),逐个容错并验证真的退出。""" + kernel32 = ctypes.windll.kernel32 + wait = self._desktop_close_wait_seconds() + for pid in pids: + try: + pid_int = int(pid) + except (TypeError, ValueError): + continue + for attempt in range(1, DESKTOP_KILL_ATTEMPTS + 1): + # PROCESS_TERMINATE(0x0001) + handle = kernel32.OpenProcess(0x0001, False, pid_int) + if not handle: + # 进程已自行退出 + break + try: + kernel32.TerminateProcess(handle, 0) + logger.info(f'清理桌面客户端 PID={pid_int}') + finally: + kernel32.CloseHandle(handle) + # 杀完必须确认进程真的没了,被拒或正在退出都会让下一轮启动撞上残留 + if self._desktop_wait_pid_gone(pid_int, wait): + break + logger.warning(f'桌面客户端 PID={pid_int} 强杀后仍存活,重试 ' + f'({attempt}/{DESKTOP_KILL_ATTEMPTS})') + else: + logger.error(f'桌面客户端 PID={pid_int} 无法清理,可能需要手动结束进程') + # 等窗口真的消失,避免残留窗口干扰下一轮的新窗口识别 + self._desktop_wait_closed(wait) + + def _desktop_wait_pid_gone(self, pid, wait: float) -> bool: + """在 wait 秒内等待指定进程退出,返回是否已退出。""" + deadline = time.time() + wait + while True: + if not self._desktop_pid_alive(pid): + return True + if time.time() >= deadline: + return False + time.sleep(DESKTOP_KILL_POLL_INTERVAL) + + def desktop_bind_pid(self, pid, hwnd=0) -> None: + """把新 PID/HWND 绑定到实例,并尽量持久化到配置。""" + self.root_handle = str(pid) + if hwnd: + self.root_handle_num = hwnd + self.root_handle_title = DESKTOP_WINDOW_TITLES[0] + self.is_desktop_window = True + # 换了新 hwnd,截图句柄缓存必须同步失效,否则截图仍走上一个客户端的废句柄 + self._desktop_clear_handle_cache() + # 新绑定的客户端刚启动,必然未登录,需先走 restart 登录流程 + self._desktop_login_done = False + logger.info(f'Desktop client bound: PID={pid}, hwnd={hwnd}') + try: + self.config.script.device.handle = str(pid) + self.config.save() + except Exception as e: + logger.warning(f'持久化桌面 PID 到配置失败: {e}') + + def _desktop_send_enter(self, hwnd) -> None: + """向窗口发送回车键,用于确认 MPay 账号登录弹窗的默认按钮"进入游戏"。 + + 窗口可能在两条消息之间被销毁(回车已生效),此时忽略异常即可。 + """ + try: + SendMessage(hwnd, WM_KEYDOWN, VK_RETURN, 0) + SendMessage(hwnd, WM_KEYUP, VK_RETURN, 0) + except Exception as e: + logger.info(f'Send Enter to window {hwnd} failed (window may be closed): {e}') + + def _desktop_pid_alive(self, pid) -> bool: + """进程是否还活着。无法判定时按「还活着」返回,宁可多等一轮也不误报已关闭。 + + 不能只看 OpenProcess 是否成功:进程已退出但仍有内核对象引用时 OpenProcess + 照样返回句柄,必须再用 GetExitCodeProcess 看退出码是否还是 STILL_ACTIVE(259)。 + 窗口枚举不能替代这里——强杀被拒时窗口可能已销毁而进程还在,只验窗口会误判。 + """ + try: + pid_int = int(str(pid)) + except (TypeError, ValueError): + return False + kernel32 = ctypes.windll.kernel32 + # PROCESS_QUERY_LIMITED_INFORMATION(0x1000),比 QUERY_INFORMATION 权限要求更低 + handle = kernel32.OpenProcess(0x1000, False, pid_int) + if not handle: + # 打不开通常就是进程已退出;权限不足时也走这里,交给窗口检查兜底 + return False + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return True + return code.value == 259 # STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + + def _desktop_wait_closed(self, wait: float) -> bool: + """在 wait 秒内轮询等待桌面客户端窗口消失,返回是否已关闭。""" + deadline = time.time() + wait + while True: + if not self.desktop_window_exists(): + return True + if time.time() >= deadline: + return False + time.sleep(0.5) + + def _desktop_wait_released(self, pid, wait: float) -> bool: + """在 wait 秒内等待客户端真正释放:窗口消失 **且** 进程退出。 + + 两个条件都要,缺一个都可能是假关闭: + - 只验窗口:强杀被拒时窗口已销毁但进程还在,会漏掉残留进程 + - 只验进程:进程刚退出时窗口可能还在被系统回收,下一轮新窗口识别会撞上 + """ + deadline = time.time() + wait + while True: + if not self.desktop_window_exists() and not self._desktop_pid_alive(pid): + return True + if time.time() >= deadline: + return False + time.sleep(DESKTOP_KILL_POLL_INTERVAL) + + def desktop_stop_client(self) -> bool: + """关闭桌面客户端:强杀进程并验证真的释放,返回是否确认关闭。 + + 客户端的退出确认框画在游戏窗口内部(不是独立顶层窗口),实测回车确认经常无效 + (进程不退出、主窗口只是被移到屏幕外),走 WM_CLOSE + 确认反而要多等几秒还未必 + 关得掉,因此这里不发任何窗口消息,直接强杀进程。 + + 强杀不等于已关闭:TerminateProcess 可能因权限被拒(本机出现过 (5, '拒绝访问。')), + 进程也可能正在退出还没消失。所以每轮杀完都验证「窗口消失 且 进程退出」,没释放 + 就再杀一轮,全部轮次用尽仍在则返回 False——调用方据此决定是重建还是交人工, + 绝不能发完 kill 指令就当成功返回。 + + 关闭后保留原 PID 在配置里;下次检测到该 PID 无效(客户端未运行)时, + 由启动/重拉链路重启客户端并把配置更新为重启后的新 PID。 + """ + # 客户端关闭后必然未登录,下次启动需重新走登录流程 + self._desktop_login_done = False + # PID 要在清理句柄前取,后面的验证全靠它 + pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + hwnd = self.root_handle_num + if (not hwnd or not IsWindow(hwnd)) and not self._desktop_pid_alive(pid): + logger.info('Desktop client not running, skip stop') + # 客户端已不在,解除强制亮屏,允许系统恢复正常的显示器电源管理 + desktop_keep_screen_on(False) + self._reset_desktop_handle_state() + # 窗口进程已没了也要收启动器:它无窗口,只看窗口永远发现不了 + self._desktop_kill_spawned_leftovers(pid) + return True + + wait = self._desktop_close_wait_seconds() + released = False + for attempt in range(1, DESKTOP_KILL_ATTEMPTS + 1): + logger.info(f'Stopping desktop client: force kill ' + f'({attempt}/{DESKTOP_KILL_ATTEMPTS}, PID={pid})') + self.desktop_force_kill() + if self._desktop_wait_released(pid, wait): + logger.info(f'Desktop client released (PID={pid})') + # 客户端已确认关闭,解除强制亮屏,允许系统恢复正常的显示器电源管理 + desktop_keep_screen_on(False) + released = True + break + logger.warning(f'Desktop client PID={pid} still present after {wait}s, retry kill') + if not released: + # 到这里客户端确实没关掉:句柄状态照常清零(它已不可信),但把失败如实报出去 + logger.error(f'Desktop client PID={pid} not released after ' + f'{DESKTOP_KILL_ATTEMPTS} force kill attempts, manual cleanup needed') + self._reset_desktop_handle_state() + # 无论窗口进程是否关掉,本轮自己拉起的启动器都要一并收掉 + leftovers_cleared = self._desktop_kill_spawned_leftovers(pid) + return released and leftovers_cleared + + def _desktop_kill_spawned_leftovers(self, bound_pid) -> bool: + """清理本次自己拉起、但没被绑定的客户端进程,返回是否已全部清掉。 + + onmyoji.exe 是启动器:Popen 起来后它派生真正的游戏窗口进程,自己继续存活且 + 没有窗口(实测启动器 89 线程、42s CPU、主窗口句柄为 0)。OAS 靠枚举游戏窗口 + 绑定,只会绑到窗口进程,关闭时若只杀它,启动器就成了无主残留——既占内存, + 也让下一轮启动的窗口识别多一个干扰源。这些 PID 由 launch_desktop_client + 在启动成功时留档到 _desktop_spawned_pids。 + """ + spawned = getattr(self, '_desktop_spawned_pids', None) + if not spawned: + return True + try: + bound = int(str(bound_pid)) + except (TypeError, ValueError): + bound = None + # 已绑定的那个由主流程负责,这里只收剩下的 + leftovers = {p for p in spawned if p != bound and self._desktop_pid_alive(p)} + self._desktop_spawned_pids = set() + if not leftovers: + return True + logger.info(f'清理本次启动残留的客户端进程(启动器): {sorted(leftovers)}') + self._desktop_kill_pids(leftovers) + still = {p for p in leftovers if self._desktop_pid_alive(p)} + if still: + logger.error(f'客户端启动器进程无法清理: {sorted(still)},可能需要手动结束') + return False + return True + + def _reset_desktop_handle_state(self) -> None: + """清零桌面窗口句柄与截图句柄缓存。 + + 进程已杀,hwnd 随窗口销毁立即失效。必须清零,否则后续在同一个 device 对象 + 生命周期内被唤醒的任务(如配置变更触发的即时调度)会跳过 Handle.__init__, + 直接拿这个废句柄去 GetClientRect,抛 (1400, '无效的窗口句柄') 搞崩整个进程。 + """ + self.root_handle_num = 0 + # 截图句柄缓存指向的也是刚被销毁的窗口,一并失效 + self._desktop_clear_handle_cache() + + def _desktop_close_wait_seconds(self) -> int: + """关闭游戏等待时长(秒),读 config.script.optimization.close_game_wait_duration。""" + try: + t = self.config.script.optimization.close_game_wait_duration + return t.hour * 3600 + t.minute * 60 + t.second + except Exception: + return 10 + + def desktop_force_kill(self) -> None: + """强杀桌面客户端进程(WM_CLOSE 超时未退出的兜底)。""" + pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + try: + pid_int = int(str(pid)) + except (TypeError, ValueError): + logger.error(f'Invalid desktop PID: {pid}, skip force kill') + return + kernel32 = ctypes.windll.kernel32 + # PROCESS_TERMINATE(0x0001) + handle = kernel32.OpenProcess(0x0001, False, pid_int) + if not handle: + # 进程已自行退出 + return + try: + kernel32.TerminateProcess(handle, 0) + logger.info(f'Desktop client PID={pid_int} force terminated') + finally: + kernel32.CloseHandle(handle) + + def desktop_window_restore_if_minimized(self, wait: float = 3.0) -> bool: + """桌面窗口被最小化(用户误操作)时还原并等待客户区恢复,返回是否发生过还原。 + + 还原成功后若客户区尺寸偏离目标(1280x720),一并重校准,保证识别 1:1。 + """ + if not getattr(self, 'is_desktop_window', False): + return False + hwnd = self.root_handle_num + if not hwnd or not IsIconic(hwnd): + return False + logger.warning('Desktop client window is minimized, restoring') + ShowWindow(hwnd, SW_RESTORE) + deadline = time.time() + wait + restored = False + while time.time() < deadline: + with dpi_awareness(): + rect = GetClientRect(hwnd) + if (rect[2] - rect[0]) > 0 and (rect[3] - rect[1]) > 0: + restored = True + break + time.sleep(0.2) + if restored: + self.desktop_window_set_size() + return restored diff --git a/module/device/method/windows_impl.py b/module/device/method/windows_impl.py index ee7665cb1f..1f4bb0ef47 100644 --- a/module/device/method/windows_impl.py +++ b/module/device/method/windows_impl.py @@ -11,29 +11,35 @@ from win32gui import (GetWindowText, EnumWindows, FindWindow, FindWindowEx, IsWindow, GetWindowRect, GetWindowDC, DeleteObject, SetForegroundWindow, IsWindowVisible, GetDC, GetParent, - EnumChildWindows, SetForegroundWindow) + EnumChildWindows, SetForegroundWindow, GetClientRect) from win32con import (SRCCOPY, DESKTOPHORZRES, DESKTOPVERTRES, WM_LBUTTONUP, WM_LBUTTONDOWN, WM_ACTIVATE, WA_ACTIVE, MK_LBUTTON, WM_NCHITTEST, WM_SETCURSOR, HTCLIENT, WM_MOUSEMOVE, WM_PARENTNOTIFY, WM_MOUSEACTIVATE, WM_MOUSEWHEEL, - WM_SETFOCUS) + WM_SETFOCUS, WM_CAPTURECHANGED) from win32ui import CreateDCFromHandle, CreateBitmap from win32api import GetSystemMetrics, SendMessage, MAKELONG, PostMessage from win32con import SRCCOPY -from module.base.cBezier import BezierTrajectory +from module.base.cBezier import human_track from module.exception import RequestHumanTakeover, ScriptError from module.base.decorator import Config from module.base.timer import timer from module.logger import logger from module.device.handle import Handle, window_scale_rate, EmulatorFamily +from module.device.method.desktop_impl import dpi_awareness class Window(Handle): + # 桌面模式鼠标移动:每 N 像素取一个轨迹点,并限制单次移动的点数上限, + # 使长距离移动的耗时与距离解耦(移动只为更新客户端悬停状态)。 + DESKTOP_MOVE_STEP = 60 + DESKTOP_MOVE_MAX_POINTS = 12 + def __init__(self, *args, **kwargs): logger.info("Window init") super().__init__(*args, **kwargs) @@ -43,6 +49,8 @@ def screenshot_window_background(self): 后台截屏 :return: """ + if getattr(self, 'is_desktop_window', False): + return self.screenshot_desktop_bitblt() widthScreen, heightScreen = self.screenshot_size # 返回句柄窗口的设备环境,覆盖整个窗口,包括非客户区,标题栏,菜单,边框 hwndDc = GetWindowDC(self.screenshot_handle_num) @@ -80,6 +88,43 @@ def screenshot_window_background(self): mfcDc.DeleteDC() return imgSrceen + def screenshot_desktop_bitblt(self): + """桌面客户端后台截图:在 DPI 感知上下文内对客户区 DC 做 BitBlt。 + + 客户端按物理像素原生渲染,感知上下文里取到的就是物理客户区(1280x720), + 与资产 1:1,客户区 DC 原点即画面左上角、不含标题栏,故无偏移、无缩放。 + """ + self.desktop_window_restore_if_minimized() + with dpi_awareness(): + client_rect = GetClientRect(self.screenshot_handle_num) + widthScreen = client_rect[2] - client_rect[0] + heightScreen = client_rect[3] - client_rect[1] + if widthScreen <= 0 or heightScreen <= 0: + # 最小化时客户区为 0x0,取不到内容(窗口被遮挡则可以) + raise RequestHumanTakeover( + 'Desktop client window is minimized, screenshot unavailable. ' + '请恢复游戏窗口(可被其他窗口遮挡,但不能最小化)' + ) + hwndDc = GetDC(self.screenshot_handle_num) + mfcDc = CreateDCFromHandle(hwndDc) + saveDc = mfcDc.CreateCompatibleDC() + saveBitMap = CreateBitmap() + saveBitMap.CreateCompatibleBitmap(mfcDc, widthScreen, heightScreen) + saveDc.SelectObject(saveBitMap) + saveDc.BitBlt((0, 0), (widthScreen, heightScreen), mfcDc, (0, 0), SRCCOPY) + signedIntsArray = saveBitMap.GetBitmapBits(True) + imgSrceen = frombuffer(signedIntsArray, dtype='uint8') + imgSrceen.shape = (heightScreen, widthScreen, 4) + imgSrceen = cv2.cvtColor(imgSrceen, cv2.COLOR_BGR2RGB) + DeleteObject(saveBitMap.GetHandle()) + saveDc.DeleteDC() + mfcDc.DeleteDC() + # 客户端锁定大小导致未校准到目标尺寸时兜底缩放,保证与资产同尺寸 + target_w, target_h = self.screenshot_size + if (widthScreen, heightScreen) != (target_w, target_h): + imgSrceen = cv2.resize(imgSrceen, (target_w, target_h)) + return imgSrceen + @cached_property def control_handle_list(self) -> list: """ @@ -90,6 +135,9 @@ def control_handle_list(self) -> list: :return: """ result = [] + if getattr(self, 'is_desktop_window', False): + # 桌面客户端无子渲染窗口,控制句柄即根窗口 + return [self.root_handle_num] if self.emulator_family == EmulatorFamily.FAMILY_MUMU: result.append(self.root_node.num) result.append(self.root_node.children[0].num) @@ -141,6 +189,8 @@ def click_window_message(self, x: int, y: int, fast: bool = False): :param fast: :return: """ + if getattr(self, 'is_desktop_window', False): + return self.click_desktop_window_message(x, y, fast) # 我不知道为什么的使用的pywin32==306的版本会导致获取的图片的是(1024, 576) # 所有我在点击的时候会除以这个缩放比例 # 但是后面发现又不是影响的很奇怪 @@ -173,6 +223,72 @@ def click_window_message(self, x: int, y: int, fast: bool = False): time.sleep(press_time) SendMessage(self.control_handle_list[0], WM_LBUTTONUP, 0, clickPos) + def desktop_message_coord(self, x, y) -> tuple: + """把资产坐标(截图空间 1280x720)换算成窗口消息坐标。 + + 截图在 DPI 感知上下文内取得,是物理像素;PostMessage 由 DPI-unaware 的 + OAS 进程发出,lParam 会被系统按虚拟化空间解释,两者相差系统缩放比。 + """ + target_w, target_h = self.screenshot_size + virtual_w, virtual_h = self.desktop_client_size_virtual() + return int(round(x * virtual_w / target_w)), int(round(y * virtual_h / target_h)) + + def _desktop_press(self, x: int, y: int, press_time: float) -> None: + """后台按下并保持 press_time 秒。 + + 客户端是鼠标语义(不是模拟器的触摸协议),控件依赖 hover 状态,必须先把 + 鼠标沿轨迹移到目标点再按下;抬起后补一次移动刷新悬停状态。 + """ + self.desktop_window_restore_if_minimized() + hwnd = self.root_handle_num + self.move_desktop_window_message(x, y) + lparam = MAKELONG(*self.desktop_message_coord(x, y)) + PostMessage(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam) + time.sleep(press_time) + PostMessage(hwnd, WM_LBUTTONUP, 0, lparam) + SendMessage(hwnd, WM_CAPTURECHANGED, 0, 0) + PostMessage(hwnd, WM_MOUSEMOVE, 0, lparam) + + def click_desktop_window_message(self, x: int, y: int, fast: bool = False): + """桌面客户端后台点击,入参为客户区(1280x720)坐标。 + + 按压时长只保留随机抖动:点击由客户端按 down/up 事件判定,不需要真人按压。 + """ + press_time: float = (random.randint(10, 25) if fast else random.randint(30, 60)) / 1000.0 + self._desktop_press(x, y, press_time) + + def long_click_desktop_window_message(self, x: int, y: int, duration: float): + """桌面客户端后台长按,duration 单位秒。""" + self._desktop_press(x, y, duration) + + def move_desktop_window_message(self, x: int, y: int) -> None: + """桌面客户端后台鼠标移动:从上一落点沿拟人轨迹移到 (x, y),入参为资产坐标。 + + _desktop_cursor 记录当前位置,使连续操作之间的移动连贯。移动只为让客户端 + 更新悬停状态,因此按 DESKTOP_MOVE_STEP 取点并抽稀到点数上限, + 长距离移动的耗时与距离解耦。 + """ + hwnd = self.root_handle_num + start = getattr(self, '_desktop_cursor', None) + target = (int(x), int(y)) + + def post_move(px, py): + PostMessage(hwnd, WM_MOUSEMOVE, 0, MAKELONG(*self.desktop_message_coord(px, py))) + + if start is None or start == target: + # 没有历史位置(首次操作)或原地不动:直接发一次移动 + post_move(target[0], target[1]) + self._desktop_cursor = target + return + trace = human_track(start, target, interval=self.DESKTOP_MOVE_STEP) + if len(trace) > self.DESKTOP_MOVE_MAX_POINTS: + step = len(trace) / self.DESKTOP_MOVE_MAX_POINTS + trace = [trace[int(i * step)] for i in range(self.DESKTOP_MOVE_MAX_POINTS)] + for px, py in trace: + post_move(px, py) + post_move(target[0], target[1]) + self._desktop_cursor = target + def long_click_window_message(self, x: int, y: int, duration: float): """ @@ -181,6 +297,8 @@ def long_click_window_message(self, x: int, y: int, duration: float): :param duration: 持续时间 单位秒 :return: """ + if getattr(self, 'is_desktop_window', False): + return self.long_click_desktop_window_message(x, y, duration) # 我不知道为什么的使用的pywin32==306的版本会导致获取的图片的是(1024, 576) # 所有我在点击的时候会除以这个缩放比例 x = int(x / self.window_scale_rate) @@ -214,21 +332,11 @@ def swipe_window_message(self, startPos: list, endPos: list) -> None: :param endPos: :return: """ + if getattr(self, 'is_desktop_window', False): + return self.swipe_desktop_window_message(startPos, endPos) # 生成的坐标点列表 interval: int = 10 # 每次移动的间隔时间 - numberList: int = int(dist(startPos, endPos) / (1 * interval)) # 表示每毫秒移动1.5个像素点, 总的时间除以每个点10ms就得到总的点的个数 - le = random.randint(2, 4) # - deviation = random.randint(20, 40) # 幅度 - _type: int = 3 - obbsType = random.random() # 0.8的概率是先快中间慢后面快, 0.1概率是先快后慢, 0.1概率先慢后快 - if 0 < obbsType <= 0.8: - _type = 3 - elif obbsType < 0.9: - _type = 2 - else: - _type = 1 - trace: list = BezierTrajectory.trackArray(start=startPos, end=endPos, numberList=numberList, le=le, - deviation=deviation, bias=0.5, type=_type, cbb=0, yhh=20) + trace: list = human_track(startPos, endPos, interval=interval) # 使用生成的点列表进行拖拽 handleNum = None @@ -266,6 +374,32 @@ def swipe_window_message(self, startPos: list, endPos: list) -> None: end_lparam = MAKELONG(trace[-1][0], trace[-1][1]) PostMessage(handleNum, WM_LBUTTONUP, 0, end_lparam) + def swipe_desktop_window_message(self, startPos: list, endPos: list) -> None: + """桌面客户端后台滑动:与模拟器同一套拟人轨迹,坐标换算到消息空间后逐点投递。""" + self.desktop_window_restore_if_minimized() + hwnd = self.root_handle_num + interval: int = 10 + trace = human_track(startPos, endPos, interval=interval) + # 先移到起点再按下:客户端靠 hover 状态识别拖拽 + self.move_desktop_window_message(startPos[0], startPos[1]) + start_lparam = MAKELONG(*self.desktop_message_coord(startPos[0], startPos[1])) + PostMessage(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, start_lparam) + # 最后几个点放慢,避免被判定为瞬移 + manual_control: int = 3 + total_len: int = len(trace) + for index, pos in enumerate(trace): + lparam = MAKELONG(*self.desktop_message_coord(pos[0], pos[1])) + PostMessage(hwnd, WM_MOUSEMOVE, MK_LBUTTON, lparam) + if manual_control >= total_len - index: + time.sleep(0.08) + else: + time.sleep((interval + random.randint(-2, 2)) / 1000.0) + time.sleep(0.05) + end_lparam = MAKELONG(*self.desktop_message_coord(endPos[0], endPos[1])) + PostMessage(hwnd, WM_LBUTTONUP, 0, end_lparam) + SendMessage(hwnd, WM_CAPTURECHANGED, 0, 0) + self._desktop_cursor = (int(endPos[0]), int(endPos[1])) + def swipe_vector_window_message2(self, startPos: list, endPos: list) -> None: """ 后台滑动, 直线滑动 From cafe832b500ac7c4d7b29b8bfc42caaad3dc0616 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 18:40:58 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(DesktopClient):=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=B1=82=E6=8E=A5=E5=85=A5=E6=A1=8C=E9=9D=A2=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=88=E5=85=8D=20adb=E3=80=81=E6=97=A0=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E5=99=A8=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - connection:跳过 detect_device(包名取配置),方向恒为 0,不走 adb dumpsys - screenshot:桌面模式放宽截图间隔;分支判断前移到夹取之前,避免按错误区间改写用户配置 - adb:swipe_adb 改走窗口消息 - platform_windows:emulator_stop 改关客户端 - app_control:app_is_alive / app_is_running 改判窗口存在性(pidof 在桌面模式必然失败) --- module/device/app_control.py | 3 +++ module/device/connection.py | 9 +++++++++ module/device/method/adb.py | 3 +++ module/device/platform2/platform_windows.py | 4 ++++ module/device/screenshot.py | 19 ++++++++++++++----- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/module/device/app_control.py b/module/device/app_control.py index 6e99279d78..544ccd2f39 100644 --- a/module/device/app_control.py +++ b/module/device/app_control.py @@ -12,6 +12,9 @@ class AppControl(Adb, Uiautomator2): _app_u2_family = ['uiautomator2', 'minitouch', 'scrcpy'] def app_is_running(self) -> bool: + # 桌面模式:窗口存在 且 已完成登录(自动启动的客户端停在登录页,需先走登录流程) + if self.is_desktop: + return self.desktop_window_exists() and getattr(self, '_desktop_login_done', True) method = self.config.script.device.control_method # if self.is_wsa: # package = self.app_current_wsa() diff --git a/module/device/connection.py b/module/device/connection.py index e0d4072b5c..45cc9e7732 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -95,6 +95,11 @@ def __init__(self, config): config (AzurLaneConfig, str): Name of the user config under ./config """ super().__init__(config) + # 桌面客户端模式:不经过 adb 设备/包检测,包名直接用配置值 + if self.is_desktop: + self.package = self.config.script.device.package_name.value + logger.attr('PackageName', self.package) + return if not self.is_over_http: self.detect_device() @@ -691,6 +696,10 @@ def get_orientation(self): 2: 'HOME key on the top' 3: 'HOME key on the left' """ + # 桌面模式:窗口即横屏,方向恒为 0(Normal),不经过 adb dumpsys + if self.is_desktop: + self.orientation = 0 + return 0 _DISPLAY_RE = re.compile( r'.*DisplayViewport{.*valid=true, .*orientation=(?P\d+), .*deviceWidth=(?P\d+), deviceHeight=(?P\d+).*' ) diff --git a/module/device/method/adb.py b/module/device/method/adb.py index e2fafe5edd..1486a9c412 100644 --- a/module/device/method/adb.py +++ b/module/device/method/adb.py @@ -192,6 +192,9 @@ def click_adb(self, x, y): @retry def swipe_adb(self, p1, p2, duration=0.1): + # 桌面客户端模式没有 adb 设备,滑动改走窗口消息路径(不影响模拟器流程) + if getattr(self, 'is_desktop', False): + return self.swipe_window_message(p1, p2) duration = int(duration * 1000) self.adb_shell(['input', 'swipe', *p1, *p2, duration]) diff --git a/module/device/platform2/platform_windows.py b/module/device/platform2/platform_windows.py index 6004a7f0ee..4e0f463f27 100644 --- a/module/device/platform2/platform_windows.py +++ b/module/device/platform2/platform_windows.py @@ -446,6 +446,10 @@ def emulator_start(self): return False def emulator_stop(self): + # 桌面客户端无模拟器生命周期,空闲关闭改走关闭客户端。 + # desktop_stop_client 内部已做「窗口消失 且 进程退出」验证并多轮重杀,直接用其结论 + if getattr(self, 'is_desktop', False): + return self.desktop_stop_client() logger.hr('Emulator stop', level=1) for _ in range(3): # Stop diff --git a/module/device/screenshot.py b/module/device/screenshot.py index f0a69a6c05..945eaa3024 100644 --- a/module/device/screenshot.py +++ b/module/device/screenshot.py @@ -157,16 +157,25 @@ def screenshot_interval_set(self, interval=None): """ if interval is None: origin = self.config.script.optimization.screenshot_interval - interval = limit_in(origin, 0.1, 0.3) + # 分支判断放在夹取之前:写回发生在各自的 clamp 之后,先夹取会按错误的 + # 区间改写用户配置。桌面模式 BitBlt 单帧约 5ms,允许更小间隔 + if getattr(self, 'is_desktop', False): + interval = limit_in(origin, 0.05, 0.3) + elif self.config.Emulator_ScreenshotMethod == 'nemu_ipc': + # nemu_ipc 截图便宜,允许更小默认 + interval = limit_in(origin, 0.1, 0.2) + else: + interval = limit_in(origin, 0.1, 0.3) if interval != origin: logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}') self.config.script.optimization.screenshot_interval = 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.script.optimization.combat_screenshot_interval - interval = limit_in(origin, 0.3, 1.0) + if getattr(self, 'is_desktop', False): + # 桌面模式战斗轮询不受 adb 成本约束,允许更小间隔 + interval = limit_in(origin, 0.1, 1.0) + else: + interval = limit_in(origin, 0.3, 1.0) if interval != origin: logger.warning(f'Optimization.CombatScreenshotInterval {origin} is revised to {interval}') self.config.script.optimization.combat_screenshot_interval = interval From 7c5f380beab1e4352dcc329c7763ed3263ef27cb Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 19:21:50 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(Image):=20=E6=A8=A1=E6=9D=BF=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E6=96=B0=E5=A2=9E=20roi=5Fpad=20=E5=AE=B9=E5=B7=AE?= =?UTF-8?q?=EF=BC=8C=E5=90=B8=E6=94=B6=E6=A1=8C=E9=9D=A2=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E7=9B=B8=E4=BD=8D=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - atom/image.py:ROI_MATCH_PAD / DESKTOP_ROI_MATCH_PAD 与 set_roi_match_padding() - 本仓库模板匹配在本地同步执行,外扩因此落在 RuleImage._pad_roi(corp 与 sift_match 共用同一条裁剪路径),不再经图像服务下发 - match / match_multi_scale / match_all / match_all_any 的命中回算改为以外扩后 的原点做基准;左上界夹 0,避免 numpy 负索引按“倒数”取值算错裁剪位置 - 模拟器模式保持 0,行为与改动前逐位一致 --- module/atom/image.py | 62 +++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/module/atom/image.py b/module/atom/image.py index 6961b0a61e..b4787fdf90 100644 --- a/module/atom/image.py +++ b/module/atom/image.py @@ -13,6 +13,20 @@ from module.base.utils import is_approx_rectangle from module.base.utils.utils import random_normal_distribution_int +# 模板匹配容差:把搜索区域(roi_back)四边各外扩该像素数,用于吸收非原生渲染 +# 环境相对模板截图环境的整体相位差(桌面客户端受 DPI 缩放重采样,约 1px)。 +# 仓库里约 1/3 的模板 roi_back 与模板等宽,此时 matchTemplate 结果只剩 1 列、 +# 横向一格都平移不了,1px 相位差就足以把「元素存在」判成「不存在」。 +# 桌面模式由 Device 置为 2,模拟器保持 0、行为逐位不变。 +ROI_MATCH_PAD = 0 +DESKTOP_ROI_MATCH_PAD = 2 + + +def set_roi_match_padding(pad: int) -> None: + """设置模板匹配的 roi_back 外扩容差(像素,四边同值)。0 表示不启用。""" + global ROI_MATCH_PAD + ROI_MATCH_PAD = max(0, int(pad)) + class RuleImage(RuleImageMallResourceMixin): debug_mode: bool = False @@ -140,6 +154,23 @@ def des(self): self.load_kp_des() return self._des + @staticmethod + def _pad_roi(roi) -> tuple: + """ + 把 ROI 四边各外扩 ROI_MATCH_PAD 像素,返回 (x0, y0, w, h)。 + + 左上侧必须夹到 0:numpy 对负索引按「倒数」语义取值,会把裁剪位置算错, + 并连带把回算出的 roi_front 算偏;右/下侧越界由切片自然截断。 + 返回的 x0/y0 是外扩后的原点,命中回算必须以它做基准。 + """ + x, y, w, h = [int(v) for v in roi] + pad = ROI_MATCH_PAD + if pad <= 0: + return x, y, w, h + x0 = max(0, x - pad) + y0 = max(0, y - pad) + return x0, y0, x + w + pad - x0, y + h + pad - y0 + @property def mask(self): """ @@ -209,11 +240,7 @@ def corp(self, image: np.array, roi: list = None) -> np.array: :param roi :return: """ - if roi is None: - x, y, w, h = self.roi_back - else: - x, y, w, h = roi - x, y, w, h = int(x), int(y), int(w), int(h) + x, y, w, h = self._pad_roi(self.roi_back if roi is None else roi) return image[y:y + h, x:x + w] def match(self, image: np.array, threshold: float = None) -> bool: @@ -258,8 +285,9 @@ def match(self, image: np.array, threshold: float = None) -> bool: logger.attr(self.name, f'matching score {max_val:.5f}') if max_val > threshold: - self.roi_front[0] = max_loc[0] + self.roi_back[0] - self.roi_front[1] = max_loc[1] + self.roi_back[1] + x0, y0, _, _ = self._pad_roi(self.roi_back) + self.roi_front[0] = max_loc[0] + x0 + self.roi_front[1] = max_loc[1] + y0 return True else: return False @@ -340,8 +368,9 @@ def match_multi_scale(self, image: np.array, threshold: float = None, self.last_scale = float(best_scale) if best_score > threshold and best_loc is not None and best_shape is not None: - self.roi_front[0] = best_loc[0] + self.roi_back[0] - self.roi_front[1] = best_loc[1] + self.roi_back[1] + x0, y0, _, _ = self._pad_roi(self.roi_back) + self.roi_front[0] = best_loc[0] + x0 + self.roi_front[1] = best_loc[1] + y0 self.roi_front[2] = best_shape[0] self.roi_front[3] = best_shape[1] return True @@ -377,11 +406,12 @@ def match_all(self, image: np.array, threshold: float = None, roi: list = None) self.last_score = float(results.max()) locations = np.where(results >= threshold) matches = [] + x0, y0, _, _ = self._pad_roi(self.roi_back) for pt in zip(*locations[::-1]): # (x, y) coordinates score = results[pt[1], pt[0]] # 得分, x, y, w, h - x = self.roi_back[0] + pt[0] - y = self.roi_back[1] + pt[1] + x = x0 + pt[0] + y = y0 + pt[1] matches.append((score, x, y, mat.shape[1], mat.shape[0])) return matches @@ -414,11 +444,12 @@ def match_all_any(self, image: np.array, threshold: float = None, roi: list = No self.last_score = float(results.max()) locations = np.where(results >= threshold) matches = [] + x0, y0, _, _ = self._pad_roi(self.roi_back) for pt in zip(*locations[::-1]): # (x, y) coordinates score = results[pt[1], pt[0]] # 得分, x, y, w, h - x = self.roi_back[0] + pt[0] - y = self.roi_back[1] + pt[1] + x = x0 + pt[0] + y = y0 + pt[1] matches.append((score, x, y, mat.shape[1], mat.shape[0])) if len(matches) > 0: scores = np.array([m[0] for m in matches]) @@ -501,8 +532,9 @@ def sift_match(self, image: np.array, show=False) -> bool: result = False else: dst = int32(cv2.perspectiveTransform(pts, m)) - self.roi_front[0] = dst[0, 0, 0] + self.roi_back[0] - self.roi_front[1] = dst[0, 0, 1] + self.roi_back[1] + x0, y0, _, _ = self._pad_roi(self.roi_back) + self.roi_front[0] = dst[0, 0, 0] + x0 + self.roi_front[1] = dst[0, 0, 1] + y0 if show: cv2.polylines(source, [dst], isClosed=True, color=(0, 0, 255), thickness=2) if not is_approx_rectangle(np.array([pos[0] for pos in dst])): From 0e7edcc8e586c7d52ee20ea48f671a00d998ef85 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 18:41:07 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(DesktopClient):=20Device=20=E5=B1=82?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=88?= =?UTF-8?q?DesktopDevice=20mixin=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DesktopDevice(与 DesktopWindow 对称):_desktop_ensure_launched 先看进程 再看窗口,避免把“窗口被最小化”当成“客户端未运行”而反复拉起并覆盖 handle - device.py 接入:__init__ 桌面分支、screenshot 窗口守卫、app_start/app_stop - 顺带修两处漂移:__init__ 重试次数还原基线语义;is_desktop 不再静默兜底成 False --- module/device/desktop_device.py | 70 +++++++++++++++++++++++++++++++++ module/device/device.py | 38 +++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 module/device/desktop_device.py diff --git a/module/device/desktop_device.py b/module/device/desktop_device.py new file mode 100644 index 0000000000..555e4834e9 --- /dev/null +++ b/module/device/desktop_device.py @@ -0,0 +1,70 @@ +# This Python file uses the following encoding: utf-8 +"""桌面客户端(serial='desktop')在 Device 层的生命周期策略。 + +窗口/进程的实现细节在 module/device/method/desktop_impl.py 的 DesktopWindow, +这里只管「什么时候把客户端拉起来、什么时候算它在运行」。 +""" +from module.logger import logger + + +class DesktopDevice: + # 登录态:OAS 自动启动的客户端停在 MPay 登录页时为 False,登录流程成功后 + # 置 True,运行期发现弹窗置回 False。默认 True 表示假设已在游戏中。 + _desktop_login_done = True + + def _desktop_ensure_launched(self) -> bool: + """确保客户端可用:进程已死才拉起,进程活着只等窗口就绪。 + + 判定顺序是关键——先看进程再看窗口。把「窗口不在」当成「客户端没在运行」 + 会拉起多余实例,并把配置里的 handle 覆盖成新实例的 PID,从此再也绑不回 + 原来那个。窗口被最小化不算没运行,截图方法会先还原再取帧。 + """ + pid = self.desktop_pid() + if pid is None or not self._desktop_pid_alive(pid): + return self.launch_desktop_client() + if self.desktop_window_exists(): + return True + return self._desktop_wait_window_ready(pid) + + def app_lost_login(self) -> bool: + """客户端是否掉回了未登录态。 + + MPay 登录窗存活时游戏画面上的一切页面标志都无效,发现即复位登录标记, + 使 app_is_running() 与任务前置检查能拦住后续流程。模拟器模式恒为 False。 + """ + if not self.is_desktop or not self.find_desktop_login_popup(): + return False + logger.warning('Desktop MPay login popup present, client is not logged in') + self.desktop_mark_logged_out() + return True + + def desktop_mark_logged_in(self) -> None: + """标记客户端已完成登录(Restart 的登录流程成功后调用)。""" + self._desktop_login_done = True + + def desktop_mark_logged_out(self) -> None: + """标记客户端处于未登录态。""" + self._desktop_login_done = False + + def _init_desktop(self) -> None: + """桌面客户端模式初始化:跳过模拟器健康检查/full_recovery。""" + logger.info('Desktop client mode: skip emulator health check and full recovery') + if self.config.script.device.screenshot_method == 'auto': + self.config.script.device.screenshot_method = 'window_background' + if self.config.script.device.control_method != 'window_message': + logger.warning( + f'Desktop mode requires control_method=window_message, ' + f'current={self.config.script.device.control_method}, overriding' + ) + self.config.script.device.control_method = 'window_message' + self.config.save() + self.screenshot_interval_set() + # 桌面客户端的渲染经过 DPI 缩放重采样,相对模板截图环境(模拟器原生 + # 1280x720)存在约 1px 的相位差;仓库里约 1/3 的模板 roi 与模板等宽, + # matchTemplate 结果只剩 1 列、横向无法平移补偿,这点相位差就足以把 + # 「元素存在」判成「不存在」。这里统一外扩 2px 吸收之,模拟器模式保持 0。 + from module.atom.image import DESKTOP_ROI_MATCH_PAD, set_roi_match_padding + set_roi_match_padding(DESKTOP_ROI_MATCH_PAD) + # 客户区校准到 1280x720 保证识别 1:1。窗口存在性已由 __init__ 的 + # _desktop_ensure_launched 保证,运行期掉了由 Restart 负责重拉 + self.desktop_window_set_size() diff --git a/module/device/device.py b/module/device/device.py index df40a6164a..aeb9639072 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -12,6 +12,7 @@ from module.config.utils import get_server_next_update from module.device.app_control import AppControl from module.device.control import Control +from module.device.desktop_device import DesktopDevice from module.device.platform2 import Platform from module.device.screenshot import Screenshot from module.exception import (GameNotRunningError, @@ -22,7 +23,7 @@ from module.logger import logger -class Device(Platform, Screenshot, Control, AppControl): +class Device(DesktopDevice, Platform, Screenshot, Control, AppControl): _screen_size_checked = False detect_record = set() click_record = deque(maxlen=15) @@ -36,6 +37,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) break except EmulatorNotRunningError: + # 桌面客户端没有模拟器可拉:确保客户端在,再重跑一次基类完成窗口绑定 + if self.is_desktop: + if trial >= 1 or not self._desktop_ensure_launched(): + logger.critical('Desktop client not found and auto-launch failed, ' + 'please start the game and bind PID first') + raise RequestHumanTakeover from None + continue if trial >= 3: logger.critical('Failed to start emulator after 3 trial') raise RequestHumanTakeover @@ -49,6 +57,11 @@ def __init__(self, *args, **kwargs): ) raise RequestHumanTakeover + # 桌面客户端模式:无模拟器生命周期 + if self.is_desktop: + self._init_desktop() + return + # Auto-fill emulator info if IS_WINDOWS and self.config.script.device.emulatorinfo_type == 'auto': _ = self.emulator_instance @@ -104,6 +117,12 @@ def screenshot(self): """ self.stuck_record_check() + # 桌面模式:窗口缺失说明客户端当前不可用(启动期未出窗口,或客户端崩了)。 + # 直接截图会抛 (1400, '无效的窗口句柄') 搞崩整个进程,必须先拦下;拉客户端 + # 是 Restart 的职责。最小化不算窗口缺失——BitBlt 截图前会先还原窗口。 + if self.is_desktop and not self.desktop_window_exists(): + raise GameNotRunningError('Desktop client window not found') + try: super().screenshot() except RequestHumanTakeover as e: @@ -227,6 +246,17 @@ def empty_function(*arg, **kwargs): self.stuck_record_check = empty_function def app_start(self): + # 桌面模式:窗口缺失时自动启动客户端并绑定 PID(空闲关闭后由 Restart 重新拉起) + if self.is_desktop: + if not self._desktop_ensure_launched(): + logger.error('Desktop client not running and auto-launch failed, ' + 'please start the game manually or check desktop_game_path') + raise GameNotRunningError('Desktop client auto-launch failed') + # 这里必然早于 app_handle_login,且是拉起客户端的唯一入口:客户区没校准到 + # 1280x720 的话登录 OCR 与点击坐标全错。运行期重拉时 Device 对象是复用的, + # _init_desktop 不会再跑,所以校准不能只挂在初始化路径上 + self.desktop_window_set_size() + return if not self.config.script.error.handle_error: logger.critical('No app stop/start, because HandleError disabled') logger.critical('Please enable Alas.Error.HandleError or manually login to AzurLane') @@ -236,6 +266,12 @@ def app_start(self): self.click_record_clear() def app_stop(self): + # 桌面模式:关闭客户端。desktop_stop_client 内部已做「窗口消失 且 进程退出」 + # 验证并多轮重杀,关不掉时它已记 ERROR,这里只把结论透出 + if self.is_desktop: + if not self.desktop_stop_client(): + logger.warning('Desktop client not released, residual process may remain') + return if not self.config.script.error.handle_error: logger.critical('No app stop/start, because HandleError disabled') logger.critical('Please enable Alas.Error.HandleError or manually login to AzurLane') From 3252b0716e3561da3419be10002303a0dee152b2 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 19:23:45 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(Login):=20=E6=A1=8C=E9=9D=A2=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=9C=9F=E5=A4=84=E7=90=86=20MPay=20=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=8C=E9=81=BF=E5=85=8D=E8=AF=AF=E5=88=A4=E6=9C=AA?= =?UTF-8?q?=E7=9F=A5=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录循环每轮及截图后各查一次弹窗 HWND(防竞态),回车关闭并确认消失; 重弹超过 5 次抛 GameStuckError 交 Restart 重建客户端 - 本仓库登录实现在 tasks/Restart/login.py 的 LoginHandler,落点相应改到 _app_handle_login 主循环与 app_handle_login 外层 - 登录成功后标记登录态;close_unknown_pages 改调设备层 app_lost_login(), 任务层不再出现 is_desktop 与 MPay 字样 --- tasks/GameUi/navigator.py | 6 ++++++ tasks/Restart/login.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tasks/GameUi/navigator.py b/tasks/GameUi/navigator.py index 064058767f..1de73fd753 100644 --- a/tasks/GameUi/navigator.py +++ b/tasks/GameUi/navigator.py @@ -710,6 +710,12 @@ def close_unknown_pages(self, skip_first_screenshot: bool = True) -> bool: """ self.maybe_screenshot(skip_first_screenshot) + + # 客户端掉回登录态(掉线/被顶号)时页面上的标志全部不可信,点关闭动作 + # 没有意义,交回 Restart 处理(登录流程会关掉弹窗) + if self.device.app_lost_login(): + raise GameNotRunningError('Client dropped back to login') + logger.warning("Try switch to a supported page") for action in [*self.navigator.local_unknown_closers, *self.DEFAULT_UNKNOWN_CLOSERS]: action_name = self._action_name(action) diff --git a/tasks/Restart/login.py b/tasks/Restart/login.py index 453b38a909..b6711968a0 100644 --- a/tasks/Restart/login.py +++ b/tasks/Restart/login.py @@ -11,6 +11,10 @@ from tasks.base_task import BaseTask import time +# 登录期间 MPay 弹窗「关掉又重弹」的容忍上限,超过说明回车已走不出去,交 Restart 重建 +DESKTOP_MPAY_CLOSE_LIMIT = 5 + + class LoginHandler(BaseTask, RestartAssets, GameUiAssets, GeneralBuffAssets): character: str @@ -31,8 +35,33 @@ def _app_handle_login(self) -> bool: confirm_timer = Timer(1.5, count=2).start() orientation_timer = Timer(10) login_success = False + # 本次登录已处理过的 MPay 弹窗次数,用于给「关掉又重弹」封顶 + popup_handled = 0 + + def handle_desktop_login_popup() -> bool: + """登录期间处理 MPay 弹窗,返回是否刚关闭了弹窗。 + + MPay 是独立顶层窗口,主窗口截图识别不到,只能按 HWND 枚举检查。 + """ + nonlocal popup_handled + if not self.device.is_desktop or not self.device.find_desktop_login_popup(): + return False + popup_handled += 1 + if popup_handled > DESKTOP_MPAY_CLOSE_LIMIT: + raise GameStuckError( + f'Desktop MPay login popup reappeared more than {DESKTOP_MPAY_CLOSE_LIMIT} times') + if not self.device.desktop_confirm_login_popup(): + # 弹窗可能恰好在确认函数最后一次枚举后自行关闭,重新确认避免误判卡死 + if not self.device.find_desktop_login_popup(): + return True + # 弹窗仍存活时不能继续识别页面,否则 MPay 会被当成未知页面 + raise GameStuckError('Desktop MPay login popup cannot be closed') + return True while 1: + # MPay 可能在启动后、重登时或登录中途反复出现,每轮都要处理 + if handle_desktop_login_popup(): + continue # Watch device rotation if not login_success and orientation_timer.reached(): # Screen may rotate after starting an app @@ -40,6 +69,9 @@ def _app_handle_login(self) -> bool: orientation_timer.reset() self.device.screenshot() + # 截图与窗口枚举有竞态,截图后再查一次,避免刚出现的 MPay 进入图像识别 + if handle_desktop_login_popup(): + continue # https://github.com/runhey/OnmyojiAutoScript/pull/1761 if not self.device.check_screen_size_sample(): continue @@ -152,6 +184,9 @@ def app_handle_login(self) -> bool: self.device.click_record_clear() try: self._app_handle_login() + # 桌面:登录成功后标记登录态,app_is_running 才会判定为已在游戏中 + if self.device.is_desktop: + self.device.desktop_mark_logged_in() if self.config.restart.harvest_config.enable: self.harvest() return True From 6ea67658b5ca4de666c7548e8a787a97934ffe96 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 19:24:43 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix(Restart):=20=E6=A1=8C=E9=9D=A2=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E5=90=AF=E5=8A=A8/=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E5=B0=B1=E5=9C=B0=E9=87=8D=E8=AF=95?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=97=A0=E9=99=90=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_start / app_restart 补桌面分支:不再强杀健康客户端 - 本仓库 ScriptTask 直接继承 LoginHandler,且 app_restart 末尾还有调度收尾, 故桌面分支只替换「启动」那一段、不提前 return - 登录流程自身的失败重试已由 app_handle_login 内部完成(它会关掉客户端再重拉), _desktop_start_and_login 只补「客户端窗口丢失」这一类重拉 --- tasks/Restart/script_task.py | 47 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/tasks/Restart/script_task.py b/tasks/Restart/script_task.py index dff894ef3b..d046f1353f 100644 --- a/tasks/Restart/script_task.py +++ b/tasks/Restart/script_task.py @@ -10,7 +10,15 @@ from datetime import datetime, time from module.logger import logger -from module.exception import TaskEnd, RequestHumanTakeover +from module.exception import (TaskEnd, + RequestHumanTakeover, + GameNotRunningError, + GameStuckError, + GameTooManyClickError) + +# 桌面客户端「启动+登录」的就地重试轮数:Restart 就是负责启动客户端的任务, +# 起不来必须自己扛住,抛给调度器会形成无限重启循环 +DESKTOP_RESTART_ATTEMPTS = 3 class ScriptTask(LoginHandler): @@ -30,15 +38,22 @@ def app_stop(self): def app_start(self): logger.hr('App start') + if self.device.is_desktop: + self._desktop_start_and_login() + return self.device.app_start() self.app_handle_login() # self.ensure_no_unfinished_campaign() def app_restart(self): logger.hr('App restart') - self.device.app_stop() - self.device.app_start() - self.app_handle_login() + if self.device.is_desktop: + # 客户端可能刚被自动拉起(停在登录页),先停再起会白关一次 + self._desktop_start_and_login() + else: + self.device.app_stop() + self.device.app_start() + self.app_handle_login() # self.config.task_delay(server_update=True) self.set_next_run(task='Restart', success=True, finish=True, server=True) @@ -55,6 +70,30 @@ def app_restart(self): else: self.custom_next_run(task='Restart', custom_time=Time(12, 0), time_delta=1) + def _desktop_start_and_login(self) -> None: + """桌面模式:拉起客户端并登录,客户端窗口丢失时就地重拉,不把异常抛给调度器。 + + Restart 就是负责启动客户端的任务:GameNotRunningError 抛出去会被 script.py + 接住再 task_call('Restart'),形成无限重启循环(每轮日志都「正常」,比直接崩 + 更难排查),所以必须在这里就地消化。登录流程自身的失败重试由 app_handle_login + 内部完成(它会关掉客户端再重拉),这里只补「客户端窗口丢失」这一类重拉。 + """ + for attempt in range(1, DESKTOP_RESTART_ATTEMPTS + 1): + try: + self.device.app_start() + self.app_handle_login() + return + except (GameNotRunningError, GameStuckError, GameTooManyClickError) as e: + logger.warning(f'桌面客户端启动后仍未就绪(第 {attempt}/{DESKTOP_RESTART_ATTEMPTS} 轮): {e}') + # 每轮失败都清掉本轮客户端,否则残留窗口会干扰下一轮的新窗口识别 + logger.info('清理本轮残留客户端') + if not self.device.desktop_stop_client(): + # 关不掉就别重建:残留窗口会让下一轮绑错句柄,越试越乱 + logger.critical('桌面客户端无法关闭,残留进程会干扰重建,请手动结束该进程') + raise RequestHumanTakeover + logger.critical(f'桌面客户端连续 {DESKTOP_RESTART_ATTEMPTS} 轮启动失败,请检查客户端与机器状态') + raise RequestHumanTakeover + def delay_pending_tasks(self) -> bool: """ 周三更新游戏的时候延迟 From f80c3a6d622d6470d9022a555a638438320dbe47 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Wed, 30 Sep 2026 18:41:21 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(Config):=20=E6=A1=8C=E9=9D=A2=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E9=85=8D=E7=BD=AE=E9=A1=B9=E4=B8=8E=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_model:桌面模式下 handle 变“已开客户端窗口”下拉;回传展示串落盘前剥回纯 PID - 新增 desktop_game_path 配置项(留空自动发现客户端安装目录) - i18n 增加桌面模式说明(挂在 desktop_game_path 下) --- assets/i18n/zh-CN.json | 2 ++ dev_tools/bind_desktop_window.py | 2 +- module/config/config_model.py | 60 ++++++++++++++++++++++++++++++++ tasks/Script/config_device.py | 4 +++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index bbfb9cd751..4363cb1e93 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -1,4 +1,6 @@ { + "desktop_game_path": "桌面版安装目录", + "desktop_game_path_help": "本项:游戏安装目录(如D:\\yys)留空则自动查找。\n\n桌面版模式(Serial 填 \"desktop\" 启用;截图和控制方式只能选 window开头的,支持多开,不支持最小化):\n\n句柄Handle 填游戏窗口的进程 PID(多开时用于区分实例),PID 每次重启客户端都会变、需重新绑定(单开理论上只有第一次需要绑定);\n可以空启动一次脚本获取PID,使句柄Handle可展开进行选择\n或任务栏管理器查找直接填写;也可用管理员 cmd 在 OAS 目录下执行 【.\\toolkit\\python.exe -m dev_tools.bind_desktop_window --config 配置名 --pick】 一键绑定(配置名不带 .json;--pick 后把鼠标移到游戏窗口上回车,不用点击;多开改用 --list 选序号)\n\n游戏以管理员运行时 OAS 也需以管理员启动,否则 UIPI 会拦截点击(截图正常但无法点击点)。\n\n高分辨率设备请在游戏目录\\bin\\onmyoji.exe属性设置\\兼容性\\高DPI设置中勾选“替代高DPI缩放行为”,并选择“由应用程序执行”。", "boss_limit_time": "单局Boss限制时间", "kirin_battle_config": "麒麟战斗配置", "netherworld_battle_config": "阴界之门战斗配置", diff --git a/dev_tools/bind_desktop_window.py b/dev_tools/bind_desktop_window.py index dcf700d126..66e9e52cb1 100644 --- a/dev_tools/bind_desktop_window.py +++ b/dev_tools/bind_desktop_window.py @@ -108,7 +108,7 @@ def main(): sys.exit(1) for i, (title, hwnd, pid) in enumerate(windows): print(f'[{i}] {title} PID={pid} HWND=0x{hwnd:X}') - choice = input('请输入要绑定的序号: ') + choice = input('请输入方括号内的序号: ') idx = int(choice) _, _, pid = windows[idx] bind_pid_to_config(args.config, pid) diff --git a/module/config/config_model.py b/module/config/config_model.py index 46b60f5939..9c8882474f 100644 --- a/module/config/config_model.py +++ b/module/config/config_model.py @@ -356,8 +356,58 @@ def merge_value(groups, jsons, definitions) -> list[dict]: groups_value[key] = groups[group_name] result[key] = merge_value(groups_value[key], value, schema["$defs"]) + self._inject_desktop_handle_options(result) + return result + def _inject_desktop_handle_options(self, result: dict) -> None: + """桌面模式下把 handle 改成"已开客户端窗口"下拉,供界面选择而非手填 PID。 + + 仅 serial == 'desktop' 时注入:handle 是桌面与模拟器共用字段,模拟器模式 + 需要保留窗口标题/HWND 的自由输入。字段类型仍是 str,只是补上候选项。 + """ + device_items = result.get('device') + if not device_items: + return + if getattr(self.script.device, 'serial', '') != 'desktop': + return + try: + from module.device.method.desktop_impl import desktop_window_option, list_desktop_windows + windows = list_desktop_windows() + except Exception as e: + # 枚举依赖 win32 且只服务于界面展示,失败时退回手填输入框 + logger.warning(f'list desktop windows failed, handle stays a text input: {e}') + return + + options = [desktop_window_option(w) for w in windows] + current = '' + for item in device_items: + if item['name'] == 'handle': + current = str(item.get('value') or '') + break + # 落盘的 handle 是纯 PID,候选项却是带标题坐标的展示串;界面靠"值等于候选项" + # 来选中,所以要把当前值换成同款展示串,否则下拉显示为空白 + display = '' + for window, option in zip(windows, options): + if str(window['pid']) == current: + display = option + break + # 已绑定的 PID 此刻可能不在枚举结果里(客户端未启动/换了 PID), + # 原值仍作候选保留,让界面能看出配置里存的是谁 + if current and not display: + display = current + options.append(current) + # 空串候选用于解除绑定 + options.insert(0, '') + + for item in device_items: + if item['name'] != 'handle': + continue + item['type'] = 'enum' + item['enumEnum'] = options + item['value'] = display + break + def script_set_arg(self, task: str, group: str, argument: str, value) -> bool: # 验证参数 task = convert_to_underscore(task) @@ -386,6 +436,16 @@ def script_set_arg(self, task: str, group: str, argument: str, value) -> bool: if isinstance(value, str) and value == 'false': value = False + # 桌面模式下 handle 在界面上是下拉项,回传的是 "PID (x,y)" 展示串,落盘前剥回 + # 纯 PID;必须限定 desktop 模式,模拟器的 handle 允许填窗口标题等非数字文本 + if (task == 'script' and group == 'device' and argument == 'handle' + and isinstance(value, str) and self.script.device.serial == 'desktop'): + from module.device.method.desktop_impl import desktop_option2pid + resolved = desktop_option2pid(value) + if resolved != value: + logger.info(f'Desktop handle option resolved to PID: {resolved}') + value = resolved + task_object = getattr(self, task, None) group_object = getattr(task_object, group, None) if group_object is None: # deal list diff --git a/tasks/Script/config_device.py b/tasks/Script/config_device.py index feee62e8cc..e45dd77a7f 100644 --- a/tasks/Script/config_device.py +++ b/tasks/Script/config_device.py @@ -54,6 +54,10 @@ class Device(BaseModel): description='serial_help') handle: str = Field(default='', description='handle_help') + # 桌面客户端自动启动用:游戏安装目录(含 bin\onmyoji.exe 或 Launch.exe 的根目录), + # 留空时按 %ProgramFiles%\Onmyoji 与注册表 InstallLocation 自动发现;仅桌面模式使用 + desktop_game_path: str = Field(default='', + description='desktop_game_path_help') package_name: PackageName = Field(title='Package Name', default=PackageName.AUTO, description='package_name_help') From 251cb1cdea26c2b61a9898e741ef0d0d8cd59021 Mon Sep 17 00:00:00 2001 From: User <190754820@qq.com> Date: Fri, 9 Oct 2026 17:25:55 +0800 Subject: [PATCH 8/9] fix(Scheduler): ensure desktop client logged in before gameplay tasks Auto-launched desktop client stays at login page (_desktop_login_done=False), so force Restart to login first instead of running gameplay on a not-ready game. --- script.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/script.py b/script.py index 72fadaccea..e7a65cf756 100644 --- a/script.py +++ b/script.py @@ -680,6 +680,18 @@ def loop(self): else: _ = self.device + # 桌面模式:客户端刚被自动拉起时停在登录页(_desktop_login_done=False), + # 此时 app_is_running() 返回 False。若下一个任务是 gameplay,先强制走 Restart + # 登录流程把游戏带到庭院主界面,避免 gameplay 在登录页盲点后被迫重启。 + # 注意:不能只靠 task_call('Restart') 排队,因为 get_next_task 按 next_run 取最早 + # 到期的任务,Exploration 等的 next_run 是 2023 永远更优先,Restart 永远排不上去, + # 所以这里直接把 task 改写成 Restart 并落进下方 Run 分支执行。 + if task != 'Restart' and getattr(self.device, 'is_desktop', False) \ + and not self.device.app_is_running(): + logger.info('Desktop client not logged in, run `Restart` to login first') + task = 'Restart' + self.is_first_task = False + # Run logger.info(f'Scheduler: Start task `{task}`') self.device.stuck_record_clear() From 1d990fce677256612b31115e9d39831dd2a51bea Mon Sep 17 00:00:00 2001 From: LGG686 <112461290+LGG686@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:49:43 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(script)=EF=BC=9A=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=8B=AC=E7=AB=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=BB=84=E4=B8=8E=E5=A4=9A=E5=BC=80=E8=B4=A6=E5=8F=B7=E9=9A=94?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - script.desktop 新组(enable/game_path/profile_dir/window_pos/window_title/ launch_instance)替代 serial="desktop" 魔法字符串,handle 语义变为窗口标题 - 客户端 PID 改为运行时状态:不写配置,档案内 oas_instance.json 供下次启动 认领;删除 handle 下拉注入与 PID 回退补丁 - 档案目录隔离启动:cwd 与 USERPROFILE 落在档案内(Documents 与 SDK 账号库 独立),大目录 junction 共享 - 「启动实例」一次性按钮:主进程直接拉起客户端,值自动弹回 false - 自定义窗口标题:绑定时改名,按标题找窗口的路径放行自定义标题 - 桌面模式强制 screenshot/control 方式为 window_background/window_message --- assets/i18n/zh-CN.json | 14 +- config/template.json | 8 + dev_tools/bind_desktop_window.py | 134 ++-- module/config/config_model.py | 68 +- module/config/utils.py | 5 + module/device/connection_attr.py | 10 +- module/device/desktop_device.py | 36 +- module/device/device.py | 2 +- module/device/handle.py | 33 +- module/device/method/desktop_impl.py | 1049 +++++++++++++++++++------- module/device/method/windows_impl.py | 2 + tasks/Script/config.py | 4 +- tasks/Script/config_desktop.py | 26 + tasks/Script/config_device.py | 6 +- 14 files changed, 942 insertions(+), 455 deletions(-) create mode 100644 tasks/Script/config_desktop.py diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 4363cb1e93..0072efefbc 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -1,6 +1,16 @@ { - "desktop_game_path": "桌面版安装目录", - "desktop_game_path_help": "本项:游戏安装目录(如D:\\yys)留空则自动查找。\n\n桌面版模式(Serial 填 \"desktop\" 启用;截图和控制方式只能选 window开头的,支持多开,不支持最小化):\n\n句柄Handle 填游戏窗口的进程 PID(多开时用于区分实例),PID 每次重启客户端都会变、需重新绑定(单开理论上只有第一次需要绑定);\n可以空启动一次脚本获取PID,使句柄Handle可展开进行选择\n或任务栏管理器查找直接填写;也可用管理员 cmd 在 OAS 目录下执行 【.\\toolkit\\python.exe -m dev_tools.bind_desktop_window --config 配置名 --pick】 一键绑定(配置名不带 .json;--pick 后把鼠标移到游戏窗口上回车,不用点击;多开改用 --list 选序号)\n\n游戏以管理员运行时 OAS 也需以管理员启动,否则 UIPI 会拦截点击(截图正常但无法点击点)。\n\n高分辨率设备请在游戏目录\\bin\\onmyoji.exe属性设置\\兼容性\\高DPI设置中勾选“替代高DPI缩放行为”,并选择“由应用程序执行”。", + "desktop": "桌面版设置", + "desktop_enable_help": "开启此项则控制电脑上的阴阳师桌面版,窗口可以互相遮挡,但不能最小化。\n游戏用管理员启动时,OAS 也需要用管理员启动,否则会点不动。\n高分辨率屏幕需在游戏目录 bin\\onmyoji.exe 的 属性-兼容性-高DPI设置 中勾选“替代高DPI缩放行为”,否则点击位置会偏。", + "game_path": "游戏安装目录", + "game_path_help": "游戏安装目录(如 D:\\yys),留空自动查找。", + "launch_instance": "启动客户端实例", + "launch_instance_help": "一次性按钮:点击后按上面的设置启动本配置对应的客户端,已在运行则跳过。\n它是按钮不是开关,点击后自动弹回,不会保存为开启。", + "profile_dir": "档案目录", + "profile_dir_help": "多开账号隔离:每个配置填一个不同目录(如 D:/yys-profiles/p1),无需手动创建,OAS 会自动准备好。\n填了之后每个实例拥有独立的游戏数据和账号。\n留空 = 和直接双击打开的游戏共享账号。", + "window_pos": "窗口位置", + "window_pos_help": "多开时自动把窗口移到指定位置,格式 横,竖(如 0,0 / 660,0);留空不移动。", + "window_title": "窗口标题", + "window_title_help": "多开时用来在任务栏区分实例:OAS 绑定该客户端时会把窗口标题改成它。\n留空 = 保持客户端默认标题,改动在下次启动或认领客户端时生效。识别和点击都不依赖标题,填不填都能正常跑。", "boss_limit_time": "单局Boss限制时间", "kirin_battle_config": "麒麟战斗配置", "netherworld_battle_config": "阴界之门战斗配置", diff --git a/config/template.json b/config/template.json index 96d881be5a..b78c12ecbf 100644 --- a/config/template.json +++ b/config/template.json @@ -15,6 +15,14 @@ "emulator_window_minimize": false, "run_background_only": false }, + "desktop": { + "enable": false, + "game_path": "", + "profile_dir": "", + "window_pos": "", + "window_title": "", + "launch_instance": false + }, "error": { "handle_error": true, "error_repeated": false, diff --git a/dev_tools/bind_desktop_window.py b/dev_tools/bind_desktop_window.py index 66e9e52cb1..977ea56729 100644 --- a/dev_tools/bind_desktop_window.py +++ b/dev_tools/bind_desktop_window.py @@ -1,117 +1,67 @@ # -*- coding: utf-8 -*- -""" -桌面客户端窗口 PID 绑定工具 +"""桌面客户端模式配置工具 ============================ -把"阴阳师-网易游戏"桌面客户端窗口的 PID 写入指定 OAS 配置,并切到桌面模式 -(serial='desktop'、handle=PID、screenshot_method='window_background'、control_method='window_message')。 +把指定 OAS 配置切到桌面客户端模式(script.desktop.enable=True、 +screenshot_method='window_background'、control_method='window_message'), +并可顺带启动一个实例验证安装目录与档案配置是否可用。 + +PID 不再写进配置:客户端进程 PID 每次重启都会变,OAS 启动/认领时自动绑定, +档案目录内会写 oas_instance.json 运行时状态供下次认领(多开账号隔离)。 用法: - ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --pick - ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --list - ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --pid 4242 + ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 + ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --path D:\\yys + ./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --profile D:\\yys-profiles\\p1 --launch """ import argparse -import ctypes -import sys -from ctypes import wintypes - -import win32gui -import win32process from module.config.config import Config from module.logger import logger -DESKTOP_WINDOW_TITLES = ('阴阳师-网易游戏', '阴阳师-MuMu模拟器专版') - - -def list_desktop_windows() -> list: - """枚举所有桌面客户端窗口,返回 [(title, hwnd, pid)]。""" - def enum_cb(hwnd, param): - param.append(hwnd) - return True - - windows = [] - win32gui.EnumWindows(enum_cb, windows) - result = [] - for hwnd in windows: - title = win32gui.GetWindowText(hwnd) - if title in DESKTOP_WINDOW_TITLES: - pid = win32process.GetWindowThreadProcessId(hwnd)[1] - result.append((title, hwnd, pid)) - return result - - -def pick_window_under_cursor() -> int: - """返回鼠标光标所指窗口的句柄;无窗口返回 0。""" - user32 = ctypes.WinDLL('user32', use_last_error=True) - - class POINT(ctypes.Structure): - _fields_ = [('x', ctypes.c_long), ('y', ctypes.c_long)] - - point = POINT() - user32.GetCursorPos.argtypes = [ctypes.POINTER(POINT)] - user32.GetCursorPos.restype = wintypes.BOOL - if not user32.GetCursorPos(ctypes.byref(point)): - return 0 - user32.WindowFromPoint.argtypes = [POINT] - user32.WindowFromPoint.restype = wintypes.HWND - return int(user32.WindowFromPoint(point)) or 0 - -def bind_pid_to_config(config_name: str, pid: int) -> None: - """把 PID 写入指定配置,并切到桌面模式。""" +def bind_desktop_mode(config_name: str, game_path: str = '', profile_dir: str = '') -> None: + """把配置切到桌面客户端模式(幂等,可重复执行)。""" config = Config(config_name) - config.script.device.serial = 'desktop' - config.script.device.handle = str(pid) + config.script.desktop.enable = True + if game_path: + config.script.desktop.game_path = game_path + if profile_dir: + config.script.desktop.profile_dir = profile_dir # PrintWindow 对客户端的 DirectX 渲染窗口返回纯黑,桌面模式统一用 BitBlt config.script.device.screenshot_method = 'window_background' config.script.device.control_method = 'window_message' config.save() - logger.info(f'Config [{config_name}] bound to desktop client PID={pid}') - # 绑定后检测并调整窗口客户区到 1280x720,保证识别 1:1 - from module.device.handle import Handle - h = Handle(config) - h.desktop_window_set_size() + logger.info(f'Config [{config_name}] switched to desktop client mode ' + f'(game_path={config.script.desktop.game_path or "auto"}, ' + f'profile_dir={config.script.desktop.profile_dir or "-"})') -def main(): - parser = argparse.ArgumentParser(description='绑定桌面客户端窗口 PID 到 OAS 配置') - parser.add_argument('--config', required=True, help='配置实例名,如 oas1') - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--pick', action='store_true', help='鼠标点选窗口(光标所指)') - group.add_argument('--list', action='store_true', help='从窗口列表按序号选择') - group.add_argument('--pid', type=int, help='直接指定 PID') - args = parser.parse_args() +def launch_and_check(config_name: str) -> None: + """按当前 desktop 配置启动一个实例并校准窗口尺寸,验证配置可用。""" + from module.device.device import Device + from module.device.method.desktop_impl import desktop_launch_instance - if args.pid: - bind_pid_to_config(args.config, args.pid) + config = Config(config_name) + if not desktop_launch_instance(config): + logger.error('启动失败,请检查 游戏安装目录 / 档案目录 配置') return + device = Device(config) + device.desktop_window_set_size() + logger.info('桌面客户端已启动并完成窗口校准,可以启动脚本了') - if args.pick: - input('请把鼠标移到目标游戏窗口上,然后按回车…') - hwnd = pick_window_under_cursor() - if not hwnd: - logger.error('未获取到光标下的窗口') - sys.exit(1) - title = win32gui.GetWindowText(hwnd) - if title not in DESKTOP_WINDOW_TITLES: - logger.error(f'光标下的窗口不是桌面客户端(标题: "{title}"),' - f'请把鼠标移到游戏画面实体区域上重试') - sys.exit(1) - pid = win32process.GetWindowThreadProcessId(hwnd)[1] - bind_pid_to_config(args.config, pid) - return - windows = list_desktop_windows() - if not windows: - logger.error('未找到桌面客户端窗口,请先启动游戏') - sys.exit(1) - for i, (title, hwnd, pid) in enumerate(windows): - print(f'[{i}] {title} PID={pid} HWND=0x{hwnd:X}') - choice = input('请输入方括号内的序号: ') - idx = int(choice) - _, _, pid = windows[idx] - bind_pid_to_config(args.config, pid) +def main(): + parser = argparse.ArgumentParser(description='把 OAS 配置切换到桌面客户端模式') + parser.add_argument('--config', required=True, help='配置实例名,如 oas1') + parser.add_argument('--path', default='', help='游戏安装目录(含 bin\\onmyoji.exe 的根目录)') + parser.add_argument('--profile', default='', help='多开档案目录(每个配置一个,实现账号隔离)') + parser.add_argument('--launch', action='store_true', + help='切换后立即启动一个客户端实例并校准窗口尺寸') + args = parser.parse_args() + + bind_desktop_mode(args.config, args.path, args.profile) + if args.launch: + launch_and_check(args.config) if __name__ == '__main__': diff --git a/module/config/config_model.py b/module/config/config_model.py index 9c8882474f..d4b2c5d893 100644 --- a/module/config/config_model.py +++ b/module/config/config_model.py @@ -356,58 +356,8 @@ def merge_value(groups, jsons, definitions) -> list[dict]: groups_value[key] = groups[group_name] result[key] = merge_value(groups_value[key], value, schema["$defs"]) - self._inject_desktop_handle_options(result) - return result - def _inject_desktop_handle_options(self, result: dict) -> None: - """桌面模式下把 handle 改成"已开客户端窗口"下拉,供界面选择而非手填 PID。 - - 仅 serial == 'desktop' 时注入:handle 是桌面与模拟器共用字段,模拟器模式 - 需要保留窗口标题/HWND 的自由输入。字段类型仍是 str,只是补上候选项。 - """ - device_items = result.get('device') - if not device_items: - return - if getattr(self.script.device, 'serial', '') != 'desktop': - return - try: - from module.device.method.desktop_impl import desktop_window_option, list_desktop_windows - windows = list_desktop_windows() - except Exception as e: - # 枚举依赖 win32 且只服务于界面展示,失败时退回手填输入框 - logger.warning(f'list desktop windows failed, handle stays a text input: {e}') - return - - options = [desktop_window_option(w) for w in windows] - current = '' - for item in device_items: - if item['name'] == 'handle': - current = str(item.get('value') or '') - break - # 落盘的 handle 是纯 PID,候选项却是带标题坐标的展示串;界面靠"值等于候选项" - # 来选中,所以要把当前值换成同款展示串,否则下拉显示为空白 - display = '' - for window, option in zip(windows, options): - if str(window['pid']) == current: - display = option - break - # 已绑定的 PID 此刻可能不在枚举结果里(客户端未启动/换了 PID), - # 原值仍作候选保留,让界面能看出配置里存的是谁 - if current and not display: - display = current - options.append(current) - # 空串候选用于解除绑定 - options.insert(0, '') - - for item in device_items: - if item['name'] != 'handle': - continue - item['type'] = 'enum' - item['enumEnum'] = options - item['value'] = display - break - def script_set_arg(self, task: str, group: str, argument: str, value) -> bool: # 验证参数 task = convert_to_underscore(task) @@ -436,16 +386,6 @@ def script_set_arg(self, task: str, group: str, argument: str, value) -> bool: if isinstance(value, str) and value == 'false': value = False - # 桌面模式下 handle 在界面上是下拉项,回传的是 "PID (x,y)" 展示串,落盘前剥回 - # 纯 PID;必须限定 desktop 模式,模拟器的 handle 允许填窗口标题等非数字文本 - if (task == 'script' and group == 'device' and argument == 'handle' - and isinstance(value, str) and self.script.device.serial == 'desktop'): - from module.device.method.desktop_impl import desktop_option2pid - resolved = desktop_option2pid(value) - if resolved != value: - logger.info(f'Desktop handle option resolved to PID: {resolved}') - value = resolved - task_object = getattr(self, task, None) group_object = getattr(task_object, group, None) if group_object is None: # deal list @@ -468,6 +408,14 @@ def script_set_arg(self, task: str, group: str, argument: str, value) -> bool: logger.info(f"reset_task_datetime={date_time}") self.reset_datetime_for_all_enabled_tasks(date_time) + # 桌面客户端「启动实例」按钮:点击置 True → 主进程直接拉起该配置的客户端 + # 实例,完成后改写 False 再走通用保存 + if (task == 'script' and group == 'desktop' and argument == 'launch_instance' + and value is True): + from module.device.method.desktop_impl import desktop_launch_instance + desktop_launch_instance(self) + value = False + # 设置参数 try: setattr(group_object, argument, value) diff --git a/module/config/utils.py b/module/config/utils.py index e0c7ab44ef..d933e426cd 100644 --- a/module/config/utils.py +++ b/module/config/utils.py @@ -273,6 +273,11 @@ def parse_tomorrow_server(server_update: time, delay_date: int = 1, float_second return next_run +def desktop_mode_enabled(config) -> bool: + """是否启用桌面客户端模式(script.desktop.enable)。""" + return bool(config.script.desktop.enable) + + def deep_get(d, keys, default=None): """ Get values in dictionary safely. diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py index ff53e7f6f9..ffd0ddc43c 100644 --- a/module/device/connection_attr.py +++ b/module/device/connection_attr.py @@ -9,7 +9,7 @@ from module.base.decorator import cached_property from module.config.config import Config -from module.config.utils import deep_iter +from module.config.utils import deep_iter, desktop_mode_enabled from module.exception import RequestHumanTakeover from module.logger import logger @@ -131,12 +131,12 @@ def is_emulator(self): @cached_property def is_desktop(self): - """桌面客户端模式:serial 配置为 'desktop'。 + """桌面客户端模式:script.desktop.enable 显式开关。 - 不做异常兜底:读不到配置说明 Device 构造链没走到 ConnectionAttr, - 此时静默返回 False 会把桌面模式当成模拟器模式继续跑,报错指向完全无关的地方。 + 刻意不兜底:读不到配置时抛 AttributeError 比静默 False 好——后者会把 + 桌面模式当成模拟器模式继续跑,报错指向完全无关的地方。 """ - return str(self.config.script.device.serial) == 'desktop' + return desktop_mode_enabled(self.config) @cached_property def is_network_device(self): diff --git a/module/device/desktop_device.py b/module/device/desktop_device.py index 555e4834e9..e850257937 100644 --- a/module/device/desktop_device.py +++ b/module/device/desktop_device.py @@ -1,9 +1,11 @@ # This Python file uses the following encoding: utf-8 -"""桌面客户端(serial='desktop')在 Device 层的生命周期策略。 +"""桌面客户端(script.desktop.enable)在 Device 层的生命周期策略。 窗口/进程的实现细节在 module/device/method/desktop_impl.py 的 DesktopWindow, 这里只管「什么时候把客户端拉起来、什么时候算它在运行」。 """ +from module.device.method.desktop_impl import ( + desktop_pid_alive, desktop_window_titles, find_desktop_window_by_pid) from module.logger import logger @@ -16,15 +18,28 @@ def _desktop_ensure_launched(self) -> bool: """确保客户端可用:进程已死才拉起,进程活着只等窗口就绪。 判定顺序是关键——先看进程再看窗口。把「窗口不在」当成「客户端没在运行」 - 会拉起多余实例,并把配置里的 handle 覆盖成新实例的 PID,从此再也绑不回 - 原来那个。窗口被最小化不算没运行,截图方法会先还原再取帧。 + 会拉起多余实例,从此再也绑不回原来那个。窗口被最小化不算没运行, + 截图方法会先还原再取帧。 + + 本会话没有存活 PID 时(刚启动、或上一个客户端已退出),先按「档案状态 + 文件 → 唯一窗口枚举」认领已在运行的实例——按钮手动启动的实例就是这样 + 被接上的;认领不到才拉起新的。 """ pid = self.desktop_pid() - if pid is None or not self._desktop_pid_alive(pid): - return self.launch_desktop_client() - if self.desktop_window_exists(): + if pid is not None and desktop_pid_alive(pid): + if self.desktop_window_exists(): + return True + return self._desktop_wait_window_ready(pid) + located_pid, hwnd = self._desktop_locate_window() + if located_pid: + if not hwnd and not self._desktop_wait_window_ready(located_pid): + return False + if not hwnd: + hwnd = find_desktop_window_by_pid( + located_pid, desktop_window_titles(self.config)) + self.desktop_bind_pid(located_pid, hwnd) return True - return self._desktop_wait_window_ready(pid) + return self.launch_desktop_client() def app_lost_login(self) -> bool: """客户端是否掉回了未登录态。 @@ -50,6 +65,13 @@ def _init_desktop(self) -> None: """桌面客户端模式初始化:跳过模拟器健康检查/full_recovery。""" logger.info('Desktop client mode: skip emulator health check and full recovery') if self.config.script.device.screenshot_method == 'auto': + # auto 是「自动选择」的语义,桌面模式自动落到后台截图,无需告警 + self.config.script.device.screenshot_method = 'window_background' + elif self.config.script.device.screenshot_method != 'window_background': + logger.warning( + f'Desktop mode requires screenshot_method=window_background, ' + f'current={self.config.script.device.screenshot_method}, overriding' + ) self.config.script.device.screenshot_method = 'window_background' if self.config.script.device.control_method != 'window_message': logger.warning( diff --git a/module/device/device.py b/module/device/device.py index aeb9639072..a388cbc052 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -250,7 +250,7 @@ def app_start(self): if self.is_desktop: if not self._desktop_ensure_launched(): logger.error('Desktop client not running and auto-launch failed, ' - 'please start the game manually or check desktop_game_path') + 'please start the game manually or check game_path') raise GameNotRunningError('Desktop client auto-launch failed') # 这里必然早于 app_handle_login,且是拉起客户端的唯一入口:客户区没校准到 # 1280x720 的话登录 OCR 与点击坐标全错。运行期重拉时 Device 对象是复用的, diff --git a/module/device/handle.py b/module/device/handle.py index 547a4bdadc..31bf295f7c 100644 --- a/module/device/handle.py +++ b/module/device/handle.py @@ -18,7 +18,9 @@ WM_LBUTTONDOWN, WM_ACTIVATE, WA_ACTIVE, MK_LBUTTON, WM_NCHITTEST, WM_SETCURSOR, HTCLIENT, WM_MOUSEMOVE) from module.config.config import Config -from module.device.method.desktop_impl import DesktopWindow, DESKTOP_WINDOW_TITLES +from module.config.utils import desktop_mode_enabled +from module.device.method.desktop_impl import DesktopWindow +from module.exception import EmulatorNotRunningError from module.logger import logger @@ -159,7 +161,7 @@ class Handle(DesktopWindow): } config: Config = None is_desktop_window: bool = False - """是否为桌面客户端模式(serial='desktop'),桌面分支统一用此标志隔离""" + """是否为桌面客户端模式(script.desktop.enable),桌面分支统一用此标志隔离""" def __init__(self, config) -> None: """ @@ -172,20 +174,23 @@ def __init__(self, config) -> None: self.config = Config(config, task=None) else: self.config = config - if not self.config.script.device.handle or self.config.script.device.handle == '': - logger.info('Handle is empty, oas not use handle') - return - - # 桌面客户端模式:按 PID 定位窗口,跳过模拟器窗口树逻辑 - if self.config.script.device.serial == 'desktop': + # 桌面模式:PID 只在运行时存在(不落配置),按「档案状态文件 → 窗口枚举」定位, + # 找不到抛错交给 Device.__init__ 自动拉起。须排在 handle 空判之前(桌面模式留空是常态) + if desktop_mode_enabled(self.config): self.root_handle_title = '' self.root_handle_num = 0 - self.root_handle = self.config.script.device.handle - logger.info(f'Desktop handle PID is {self.root_handle}') - self.root_handle_num = self.find_desktop_window_by_pid(self.root_handle) - self.root_handle_title = DESKTOP_WINDOW_TITLES[0] - self.is_desktop_window = True - logger.info(f'Desktop client window found: title={self.root_handle_title}, hwnd={self.root_handle_num}') + self.root_handle = '' + pid, hwnd = self._desktop_locate_window() + if pid and hwnd: + self.desktop_bind_pid(pid, hwnd) + logger.info(f'Desktop client window bound: PID={pid}, hwnd={hwnd}') + return + if pid: + logger.info(f'Desktop client process alive (PID={pid}) but window not ready yet') + raise EmulatorNotRunningError('Desktop client window not found') + + if not self.config.script.device.handle or self.config.script.device.handle == '': + logger.info('Handle is empty, oas not use handle') return # 获取根的句柄 diff --git a/module/device/method/desktop_impl.py b/module/device/method/desktop_impl.py index 55bb2ce00b..beb0abb377 100644 --- a/module/device/method/desktop_impl.py +++ b/module/device/method/desktop_impl.py @@ -10,13 +10,19 @@ - ``DesktopWindow`` 不定义 ``__init__``,全部实例状态由 ``Handle.__init__`` 的 桌面分支设置;类内方法名与 Handle 模拟器方法无重名,MRO 无歧义; - 本模块不反向依赖 handle.py,是叶子模块,无循环导入; -- 模块级常量与 helper(``list_desktop_windows`` / ``desktop_window_option`` / - ``desktop_option2pid`` / ``dpi_awareness`` 等)一并住在这里,外部调用方直接 +- 模块级常量与 helper(``list_desktop_windows`` / ``desktop_prepare_profile`` / + ``desktop_launch_instance`` / ``dpi_awareness`` 等)一并住在这里,外部调用方直接 从本模块导入,不经 handle.py 转出——避免"补丁打在转出别名上静默失效"。 + +PID 生命周期约定:客户端进程 PID 每次重启都会变,因此它**不写配置**,只活在 +运行时(实例 root_handle);配了档案目录时另在档案内写 oas_instance.json 状态 +文件,供下次启动精确认领(多开必需,枚举无法区分多个同标题窗口的归属)。 """ import os import re +import json +import shutil import subprocess import time import ctypes @@ -29,9 +35,11 @@ from win32process import GetWindowThreadProcessId from win32gui import (GetWindowText, EnumWindows, GetWindowRect, IsWindow, GetClientRect, ClientToScreen, GetWindowLong, - SetWindowPos, GetClassName, IsIconic, ShowWindow) + SetWindowPos, GetClassName, IsIconic, ShowWindow, + SetWindowText) from win32con import (WM_KEYDOWN, WM_KEYUP, VK_RETURN, GWL_STYLE, GWL_EXSTYLE, - HWND_TOP, SWP_NOMOVE, SWP_SHOWWINDOW, SW_RESTORE) + HWND_TOP, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SWP_NOACTIVATE, + SWP_SHOWWINDOW, SW_RESTORE) from module.base.decorator import del_cached_property from module.exception import EmulatorNotRunningError @@ -40,6 +48,56 @@ # 桌面客户端窗口标题(官方桌面版,多开共用同一标题) DESKTOP_WINDOW_TITLES = ('阴阳师-网易游戏', '阴阳师-MuMu模拟器专版') + + +def desktop_window_titles(config=None) -> tuple: + """本配置认可的窗口标题白名单:内置官方标题 + 自定义窗口标题。 + + 自定义标题(script.desktop.window_title)非空时,OAS 绑定客户端时会把窗口 + 改名成它;此后**所有按标题找窗口的路径**都必须放行这个标题,否则改名后的 + 实例会被当成「窗口不存在」——desktop_window_exists 判掉线触发重拉、 + _desktop_locate_window 认领不到,多开直接乱套。 + """ + custom = '' + if config is not None: + try: + custom = str(config.script.desktop.window_title or '').strip() + except AttributeError: + custom = '' + return DESKTOP_WINDOW_TITLES + ((custom,) if custom else ()) + + +def desktop_rename_window(hwnd, config) -> bool: + """把客户端窗口标题改成配置的自定义标题,返回是否实际改了名。 + + 仅用于多开时在任务栏区分实例。客户端会在首次登录流程中自行重设一次标题, + 故除绑定时的改名外,检测到 MPay 登录弹窗时补改一次;失败只记 warning, + 标题不参与识别与点击。 + """ + if not hwnd: + return False + try: + custom = str(config.script.desktop.window_title or '').strip() + except AttributeError: + return False + if not custom: + return False + try: + if GetWindowText(hwnd) == custom: + return False + SetWindowText(hwnd, custom) + # 回读确认:win32gui.SetWindowText 对无效句柄不抛异常,不回读会把 + # 「没改成」记成「已改名」,排查时会被日志带偏 + if GetWindowText(hwnd) != custom: + logger.warning(f'重命名桌面客户端窗口未生效(目标标题 {custom},不影响控制)') + return False + logger.info(f'Desktop window title renamed to: {custom}') + return True + except Exception as e: + logger.warning(f'重命名桌面客户端窗口失败(不影响控制): {e}') + return False + + # 「进程活着但窗口还没就绪」时的等待上限(秒)与轮询间隔。空窗期有两种:客户端 # 启动期还没出窗口、确认 MPay 弹窗后销毁登录界面重建主窗口的那几秒。这段等待只 # 轮询窗口,绝不重新启动进程——把「窗口暂时不在」当成「客户端没在运行」会拉起 @@ -62,6 +120,24 @@ DESKTOP_KILL_ATTEMPTS = 3 DESKTOP_KILL_POLL_INTERVAL = 0.5 +# ---- 多开档案(profile)隔离 ---- +# 档案内的只读大目录:junction 共享真实安装目录,不占空间、不重下资源 +DESKTOP_PROFILE_SHARED_DIRS = ('bin', 'bin_patch', 'res', 'thd', 'thdext1', 'zipres', '_check_preload_') +# 档案内每实例独立的真实目录(Documents 内的子目录随后按 junction 共享) +DESKTOP_PROFILE_ISOLATED_DIRS = ('Documents', 'cefLocalStorage') +# 档案内「假用户主目录」子树。SDK 用 SHGetFolderPathW / SHGetSpecialFolderLocation 解析 +# %APPDATA%,这些 API 拿【进程环境的 USERPROFILE】展开注册表里的 REG_EXPAND_SZ +# ("%USERPROFILE%\AppData\Roaming"),且目标目录必须真实存在,否则 API 直接失败 +DESKTOP_PROFILE_HOME_DIRS = ( + 'home/AppData/Roaming', + 'home/AppData/Local', + 'home/AppData/Local/Temp', + 'home/AppData/LocalLow', + 'home/Documents', +) +# QueryInformationJobObject 的 JobObjectBasicProcessIdList 信息类 +_JOB_OBJECT_BASIC_PROCESS_ID_LIST = 3 + # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,SetThreadDpiAwarenessContext 的入参 _PER_MONITOR_AWARE_V2 = ctypes.c_void_p(-4) @@ -183,132 +259,584 @@ def _desktop_pid_under_root(pid, root) -> bool: return exe.startswith(root + os.sep) -def list_desktop_windows() -> list: +def list_desktop_windows(titles=None) -> list: """枚举当前所有桌面客户端窗口。 - 返回 [{'pid': int, 'title': str, 'x': int, 'y': int}],按屏幕位置排序。 - 桌面客户端多开时窗口标题完全相同,只能靠 PID 区分,附带左上角坐标是为了 - 让用户在界面上按"窗口摆在哪"对号入座。按坐标排序保证同一组窗口每次枚举 - 顺序稳定(EnumWindows 的返回顺序随 Z 序变化)。 + 返回 [{'pid': int, 'title': str, 'hwnd': int, 'x': int, 'y': int}],按屏幕位置 + 排序。桌面客户端多开时窗口标题完全相同,只能靠 PID 区分,附带左上角坐标是为了 + 按"窗口摆在哪"对号入座。按坐标排序保证同一组窗口每次枚举顺序稳定 + (EnumWindows 的返回顺序随 Z 序变化)。 + + titles 为 None 时用 DESKTOP_WINDOW_TITLES;调用方按配置里的窗口标题 + (device.handle)过滤候选时传入自定义标题组。坐标在 DPI 感知上下文内读取, + 是物理像素,与 desktop_window_pos 配置同一空间(OAS 进程自身未声明 DPI 感知, + 上下文外读到的是虚拟化坐标)。 """ def enum_cb(hwnd, param): param.append(hwnd) return True + wanted = tuple(titles) if titles else DESKTOP_WINDOW_TITLES handles = [] EnumWindows(enum_cb, handles) result = [] - for hwnd in handles: - if GetWindowText(hwnd) not in DESKTOP_WINDOW_TITLES: - continue - try: - rect = GetWindowRect(hwnd) - except Exception: - # 窗口在枚举与取矩形之间被关闭,跳过即可 - continue - result.append({ - 'pid': GetWindowThreadProcessId(hwnd)[1], - 'title': GetWindowText(hwnd), - 'hwnd': hwnd, - 'x': rect[0], - 'y': rect[1], - }) + with dpi_awareness(): + for hwnd in handles: + title = GetWindowText(hwnd) + if title not in wanted: + continue + try: + rect = GetWindowRect(hwnd) + except Exception: + # 窗口在枚举与取矩形之间被关闭,跳过即可 + continue + result.append({ + 'pid': GetWindowThreadProcessId(hwnd)[1], + 'title': title, + 'hwnd': hwnd, + 'x': rect[0], + 'y': rect[1], + }) result.sort(key=lambda w: (w['x'], w['y'], w['pid'])) return result -def desktop_window_option(window: dict) -> str: - """把窗口信息格式化成界面下拉项文本,形如 `27272 (154,38)`。 +# ---------------------------------------------------------------------- 多开档案隔离 + +def desktop_make_junction(link: Path, target: Path) -> bool: + """创建目录联接(junction 不需要管理员权限);已链接/目标缺失返回 False。 - 不显示窗口标题:桌面客户端多开时标题完全相同,且中文字样在英文界面里冗余。 - PID 放在最前,desktop_option2pid 只认第一段数字,坐标纯属给用户辨识用, - 改动展示格式不会影响解析。 + junction 是 NTFS 重解析点,对程序透明且不占空间,用来让每个档案共享真实安装 + 目录里的只读大目录(bin/res/zipres 等),避免多开复制数个 GB。 """ - return f"{window['pid']} ({window['x']},{window['y']})" + if link.is_dir(): + # 真实目录或已建好的 junction 都命中(is_dir 会穿透到目标) + return False + if os.path.lexists(link): + # 断裂的 junction:目标被删掉后 is_dir 为 False 但链接本身还在,摘掉重建 + try: + os.rmdir(link) + except OSError: + return False + if not target.is_dir(): + logger.warning(f'junction 目标不存在,跳过共享: {target}') + return False + link.parent.mkdir(parents=True, exist_ok=True) + # 不走 text=True:mklink 的输出是中文 GBK,按 UTF-8 解码会在管道读取线程里 + # 炸 UnicodeDecodeError;bytes 接收,只在失败时容错解码后记日志 + result = subprocess.run(['cmd', '/c', 'mklink', '/J', str(link), str(target)], + capture_output=True, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + if result.returncode != 0: + stdout = result.stdout.decode('utf-8', errors='replace') + stderr = result.stderr.decode('utf-8', errors='replace') + logger.warning(f'mklink 失败: {link} <- {target}\\n{stdout}{stderr}') + return False + return True -def desktop_option2pid(option: str) -> str: - """从下拉项文本中取回纯 PID;取不到时返回空串。 +def desktop_prepare_profile(profile: Path, root: Path) -> None: + """搭建多开实例档案目录(幂等,可重复执行)。 - 界面回传的是展示文本,但落盘的 handle 必须是纯 PID(Handle 按数字消费), - 因此写入配置前统一在这里剥掉标题与坐标。用户手工填的纯数字原样通过。 + 隔离结构: + - 安装根与 Documents 下的大量子目录:junction 共享,0 额外占用; + - Documents 根配置文件(clientconfig 等,实测合计仅几 MB):复制成独立文件; + - home/:假用户主目录,SDK 账号库(Netease\\Mpay)按档案落盘。 """ - if not option: - return '' - matched = re.match(r'\s*(\d+)', str(option)) - return matched.group(1) if matched else '' + profile.mkdir(parents=True, exist_ok=True) + for name in DESKTOP_PROFILE_SHARED_DIRS: + desktop_make_junction(profile / name, root / name) + for name in DESKTOP_PROFILE_ISOLATED_DIRS: + (profile / name).mkdir(exist_ok=True) + for name in DESKTOP_PROFILE_HOME_DIRS: + (profile / name).mkdir(parents=True, exist_ok=True) + # 安装根小文件(neox.xml / client.ini / pkginfo.json 等,KB 级)。引擎在 cwd 下 + # 找 neox.xml,缺了起不来;cwd 即档案目录,所以这些文件必须各档案一份 + for item in root.iterdir(): + if item.is_dir(): + continue + destination = profile / item.name + if not destination.exists(): + shutil.copy2(item, destination) + # Documents:根文件独立复制(账号记忆所在),子目录 junction 共享(8G 资源不复制) + real_docs = root / 'Documents' + if real_docs.is_dir(): + for sub in real_docs.iterdir(): + destination = profile / 'Documents' / sub.name + if sub.is_dir(): + desktop_make_junction(destination, sub) + elif not destination.exists(): + shutil.copy2(sub, destination) + + +def desktop_profile_env(profile: Path) -> dict: + """构造档案隔离的启动环境:USERPROFILE 指向档案内的假主目录。 + + SDK 并不读 APPDATA 环境变量(走 Shell 已知文件夹 API + 注册表),真正起隔离 + 作用的是 USERPROFILE;APPDATA/LOCALAPPDATA/TEMP 一并设上,避免个别组件走 + 环境变量时写到全局用户目录。 + """ + home = profile / 'home' + env = dict(os.environ) + env['USERPROFILE'] = str(home) + env['HOMEDRIVE'] = home.drive + env['HOMEPATH'] = str(home)[len(home.drive):] + env['APPDATA'] = str(home / 'AppData' / 'Roaming') + env['LOCALAPPDATA'] = str(home / 'AppData' / 'Local') + env['TEMP'] = str(home / 'AppData' / 'Local' / 'Temp') + env['TMP'] = env['TEMP'] + return env + + +def desktop_profile_path(configured: str): + """把配置里的档案目录归一化成 Path;未配置返回 None(原行为,不隔离)。""" + configured = (configured or '').strip() + if not configured: + return None + path = Path(configured).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + return path + + +def desktop_root_from_path(value: str): + """把用户填的路径归一化成安装目录;无效返回 None。""" + path = Path(value).expanduser() + if path.is_file(): + # 允许直接填 bin\onmyoji.exe 或 Launch.exe 的完整路径 + if path.name.lower() == 'onmyoji.exe' and path.parent.name.lower() == 'bin': + path = path.parent.parent + elif path.name.lower() == 'launch.exe': + path = path.parent + else: + path = path.parent + candidate = path / 'bin' / 'onmyoji.exe' + if candidate.is_file(): + return path.resolve() + return None + + +def desktop_discover_install_root(): + """按 %ProgramFiles%\Onmyoji 与注册表 Uninstall 的 InstallLocation 自动发现安装目录。""" + for env in ('ProgramFiles', 'ProgramFiles(x86)'): + raw = os.environ.get(env) + if raw and (Path(raw) / 'Onmyoji' / 'bin' / 'onmyoji.exe').is_file(): + return (Path(raw) / 'Onmyoji').resolve() + try: + import winreg + roots = ( + (winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'), + (winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'), + (winreg.HKEY_CURRENT_USER, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'), + ) + for hive, key_path in roots: + try: + with winreg.OpenKey(hive, key_path) as parent: + for index in range(winreg.QueryInfoKey(parent)[0]): + try: + with winreg.OpenKey(parent, winreg.EnumKey(parent, index)) as child: + display, _ = winreg.QueryValueEx(child, 'DisplayName') + if '阴阳师' not in str(display) and 'Onmyoji' not in str(display): + continue + location, _ = winreg.QueryValueEx(child, 'InstallLocation') + if location and (Path(location) / 'bin' / 'onmyoji.exe').is_file(): + return Path(location).resolve() + except OSError: + continue + except OSError: + continue + except Exception: + pass + return None -class DesktopWindow: - def find_desktop_window_by_pid(self, pid) -> int: - """按 PID 查找桌面客户端窗口,返回窗口句柄;未找到抛 EmulatorNotRunningError。 +def desktop_install_root(game_path: str) -> str: + """解析桌面客户端安装目录:优先配置的 game_path,其次自动发现。 + + 只认含 bin\onmyoji.exe(或根目录 Launch.exe)的安装目录,找不到返回空串。 + """ + configured = (game_path or '').strip() + if configured: + root = desktop_root_from_path(configured) + if root is not None: + return str(root) + logger.warning(f'游戏安装目录配置无效: {configured},尝试自动发现') + root = desktop_discover_install_root() + return str(root) if root else '' - 「找不到窗口」记 warning 而非 error:本方法的调用方多为探测存在性 - (desktop_window_exists 判断是否需要重拉、_desktop_wait_closed 确认已关闭), - 找不到是正常答案。真正的故障由上层在拿到 EmulatorNotRunningError 后判定, - 这样真故障不会被淹没在每轮任务收尾都出现的噪音里。 - 同一 PID 下可能同时存在多个标题命中的顶层窗口(客户端多开共用同一标题; - 新版 MuMu 内核客户端启动期还带着启动器残留窗口),取客户区最大者即可。 +def desktop_instance_state_path(profile): + """档案内运行时状态文件路径;未配档案返回 None。 - 这里刻意**不设客户区面积下限**:窗口被最小化时客户区是 0x0,但客户端仍在 - 运行,还原交给 desktop_window_restore_if_minimized。用面积下限去宣布「未运行」 - 会把这个正常挂着的客户端判成掉线 → 触发误重拉并把配置 handle 覆盖成新实例 - (真机实测的循环就是这么起来的),而 BitBlt 截图前本来就会先把窗口还原。 - """ + PID 不写配置(每次重启都会变),改记在档案里:OAS 下次启动凭它精确认领 + "这个档案的实例是哪个 PID",多开时枚举同标题窗口无法区分归属。 + """ + if profile is None: + return None + return Path(profile) / 'oas_instance.json' + + +def desktop_read_instance_state(profile) -> dict: + """读档案状态文件;不存在/损坏/未配档案返回空 dict。""" + path = desktop_instance_state_path(profile) + if path is None or not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding='utf-8')) + return data if isinstance(data, dict) else {} + except Exception as e: + logger.warning(f'读取桌面实例状态文件失败: {path}: {e}') + return {} + + +def desktop_write_instance_state(profile, pid) -> None: + """把当前实例 PID 写进档案状态文件(tmp + replace 原子替换,防读写半截)。""" + path = desktop_instance_state_path(profile) + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix('.json.tmp') + tmp.write_text(json.dumps({'pid': int(pid)}, ensure_ascii=False), encoding='utf-8') + os.replace(tmp, path) + except Exception as e: + logger.warning(f'写入桌面实例状态文件失败: {path}: {e}') + + +def desktop_clear_instance_state(profile) -> None: + """清除档案状态文件(客户端已关闭/状态失效时)。""" + path = desktop_instance_state_path(profile) + if path is None: + return + try: + path.unlink(missing_ok=True) + except Exception as e: + logger.warning(f'清除桌面实例状态文件失败: {path}: {e}') + + +def desktop_prepare_profile_if_needed(profile: Path, root: str) -> None: + """档案未就绪时搭建隔离目录;已就绪(含历史登录态)直接用。 + + 以 home/AppData/Roaming 存在作为「已就绪」判据:prepare 的任何一步失败都 + 不允许带着半成品启动——那会悄悄退回全局账号库,登录到错误的账号。 + """ + if (profile / 'home' / 'AppData' / 'Roaming').is_dir(): + return + logger.info(f'首次使用多开档案,正在准备隔离目录: {profile}') + desktop_prepare_profile(profile, Path(root)) + logger.info(f'多开档案就绪: {profile}') + + +def _desktop_job_api_ready() -> None: + """给 kernel32 的 Job 相关导出声明指针尺寸的 argtypes/restype。 + + ctypes 默认按 32 位 int 收发返回值,x64 上句柄高位会被截断。这几个导出只在 + 本模块的多开归属判定里使用,声明不影响文件内其他 kernel32 调用。 + """ + kernel32 = ctypes.windll.kernel32 + if kernel32.CreateJobObjectW.restype is wintypes.HANDLE: + return + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPWSTR] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + kernel32.QueryInformationJobObject.restype = wintypes.BOOL + kernel32.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, wintypes.INT, wintypes.LPVOID, wintypes.DWORD, wintypes.PDWORD] + + +def _desktop_job_member_pids(job) -> set: + """查询 Job 当前成员进程 PID 集合;查询失败返回空集(调用方退回宽松匹配)。""" + kernel32 = ctypes.windll.kernel32 + capacity = 2048 + pointer_size = ctypes.sizeof(ctypes.c_void_p) + buffer = ctypes.create_string_buffer(8 + capacity * pointer_size) + returned = wintypes.DWORD() + if not kernel32.QueryInformationJobObject( + wintypes.HANDLE(job), _JOB_OBJECT_BASIC_PROCESS_ID_LIST, + buffer, len(buffer), ctypes.byref(returned)): + return set() + count = int.from_bytes(buffer.raw[4:8], 'little') + pids = set() + for index in range(min(count, capacity)): + offset = 8 + index * pointer_size + pids.add(int.from_bytes(buffer.raw[offset:offset + pointer_size], 'little')) + return pids + + +def _desktop_attach_job(pid) -> int: + """把本轮 Popen 的启动器进程挂进新建的匿名 Job,返回 Job 句柄;失败返回 0。 + + 多开时各实例同标题同 exe,并发启动会把别人刚出现的窗口当成自己的绑走; + 窗口进程由启动器派生、自动加入同一 Job,成员名单即「本轮拉起的全部进程」。 + 刻意不设 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE:OAS 退出不影响客户端继续运行, + 句柄关闭也不会杀成员。 + """ + kernel32 = ctypes.windll.kernel32 + try: + _desktop_job_api_ready() + job = kernel32.CreateJobObjectW(None, None) + if not job: + logger.warning('CreateJobObjectW 失败,多开窗口归属退回宽松匹配') + return 0 + # PROCESS_SET_QUOTA(0x0100) | PROCESS_TERMINATE(0x0001) + handle = kernel32.OpenProcess(0x0101, False, int(pid)) + if not handle: + kernel32.CloseHandle(job) + logger.warning(f'OpenProcess 失败(PID={pid}),多开窗口归属退回宽松匹配') + return 0 try: - pid_int = int(str(pid)) - except (TypeError, ValueError): - logger.error(f'Invalid desktop PID: {pid}') - raise EmulatorNotRunningError(f'Invalid desktop client PID: {pid}') + if not kernel32.AssignProcessToJobObject(wintypes.HANDLE(job), wintypes.HANDLE(handle)): + kernel32.CloseHandle(job) + logger.warning('AssignProcessToJobObject 失败,多开窗口归属退回宽松匹配') + return 0 + finally: + kernel32.CloseHandle(handle) + return int(job) + except Exception as e: + logger.warning(f'Job 归属初始化异常,退回宽松匹配: {e}') + return 0 - def enum_cb(hwnd, param): - param.append(hwnd) + +def desktop_pid_alive(pid) -> bool: + """进程是否还活着。无法判定时按「还活着」返回,宁可多等一轮也不误报已关闭。 + + 不能只看 OpenProcess 是否成功:进程已退出但仍有内核对象引用时 OpenProcess + 照样返回句柄,必须再用 GetExitCodeProcess 看退出码是否还是 STILL_ACTIVE(259)。 + 窗口枚举不能替代这里——强杀被拒时窗口可能已销毁而进程还在,只验窗口会误判。 + """ + try: + pid_int = int(str(pid)) + except (TypeError, ValueError): + return False + kernel32 = ctypes.windll.kernel32 + # PROCESS_QUERY_LIMITED_INFORMATION(0x1000),比 QUERY_INFORMATION 权限要求更低 + handle = kernel32.OpenProcess(0x1000, False, pid_int) + if not handle: + # 打不开通常就是进程已退出;权限不足时也走这里,交给窗口检查兜底 + return False + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): return True + return code.value == 259 # STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) - windows = [] - EnumWindows(enum_cb, windows) - matches = [] - for hwnd in windows: - if GetWindowText(hwnd) in DESKTOP_WINDOW_TITLES: - _, win_pid = GetWindowThreadProcessId(hwnd) - if win_pid == pid_int: - matches.append(hwnd) - # 同 PID 的多个候选里取客户区最大者:正常渲染窗口面积最大,启动器残留/ - # 最小化窗口(客户区 0x0)会排在后面。读不到客户区的(枚举与取值之间被销毁) - # 直接跳过,全部读不到才算未找到 - best, best_area = 0, -1 - for hwnd in matches: - try: - cl, ct, cr, cb = GetClientRect(hwnd) - area = (cr - cl) * (cb - ct) - except Exception: - continue - if area > best_area: - best, best_area = hwnd, area - if best: - return best - logger.warning(f'Desktop client window not found, PID={pid}') - raise EmulatorNotRunningError(f'Desktop client window not found, PID={pid}') - - def _desktop_best_window(self, pid, fallback: int = 0) -> int: - """取指定 PID 下客户区最大的候选窗口;查不到时回退 fallback。 - - 用于自动启动完成时的句柄确定:同一 PID 可能既带启动器残留窗口又带真渲染窗口, - 取客户区最大者才不会把截图绑到残壳上。 - """ + +def desktop_game_exe(root) -> str: + """返回游戏可执行文件路径;找不到返回空串。 + + 顺序即优先级:必须优先游戏本体 bin/onmyoji.exe。 + Launch.exe 是登录器,它会再拉起 bin/onmyoji.exe, + 导致双开且 OAS 绑定到登录器 PID 上找不到游戏窗口。 + """ + root = Path(root) + for name in ('bin/onmyoji.exe', 'Launch.exe'): + exe = root / name + if exe.is_file(): + return str(exe) + return '' + + +def desktop_spawn_client(exe: str, root: str, profile=None, timeout: int = 90, + attempt: int = 1, on_bound=None): + """启动客户端并等其窗口就绪;返回 (bound_pid, spawned_pids),Popen 失败返回 None。 + + profile 非空时按档案隔离启动:cwd=档案目录(游戏数据 \Documents 独立), + env 的 USERPROFILE 指向档案内假主目录(SDK 账号库 Netease\\Mpay 独立)。 + on_bound(pid, hwnd) 在窗口就绪时回调,供调用方绑定自己的实例状态。 + + 多开窗口归属靠匿名 Job(见 _desktop_attach_job):没有它,并发启动的实例会把 + 别人刚出现的窗口绑走——实测 4/4 复现。Job 不可用时退回旧宽松匹配(新出现 + + exe 归属),单开场景不受影响。 + """ + before_pids = {w['pid'] for w in list_desktop_windows()} + logger.info(f'启动桌面客户端(第 {attempt} 轮): {exe}') + spawned = set() + # 档案隔离:cwd 决定游戏数据根,env 决定 SDK 账号库落点 + cwd = str(profile) if profile is not None else root + env = desktop_profile_env(Path(profile)) if profile is not None else None + if profile is not None: + logger.info(f'多开档案隔离启动: cwd={cwd}, USERPROFILE={env["USERPROFILE"]}') + try: + proc = subprocess.Popen([exe], cwd=cwd, env=env, + creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)) + spawned.add(proc.pid) + except Exception as e: + logger.error(f'启动桌面客户端失败: {e}') + return None + + job = _desktop_attach_job(proc.pid) + try: + deadline = time.time() + timeout + stable_hwnd = 0 + stable_count = 0 + while time.time() < deadline: + # 候选必须落在安装目录下的进程:客户端新旧版本窗口标题不同 + # ("阴阳师-网易游戏" / "阴阳师-MuMu模拟器专版"),启动等待期内任何 + # 匹配标题的新窗口都会成为候选,按 exe 归属收口,避免绑到别的进程。 + # Job 可用时再按「本轮启动的进程树」精确收口(多开必需) + job_pids = _desktop_job_member_pids(job) if job else set() + candidates = [w for w in list_desktop_windows() + if _desktop_pid_under_root(w['pid'], root) + and (w['pid'] in job_pids if job_pids + else w['pid'] not in before_pids)] + if candidates: + candidate = candidates[0] + if candidate['hwnd'] == stable_hwnd: + stable_count += 1 + else: + stable_hwnd = candidate['hwnd'] + stable_count = 1 + if stable_count >= 2: + # 同一 PID 可能还带着启动器残留窗口(客户区极小),句柄统一取 + # 客户区最大者,否则截图会绑到残壳上(BitBlt 取不到画面) + hwnd = _desktop_best_window(candidate['pid'], candidate['hwnd']) + if on_bound is not None: + on_bound(candidate['pid'], hwnd) + spawned.add(candidate['pid']) + logger.info(f'桌面客户端已启动并就绪 PID={candidate["pid"]}') + # 到这里启动就完成了:进程在跑、窗口句柄已绑定。 + # MPay 登录弹窗与进游戏属于登录流程,由 Restart 的 app_handle_login + # 负责(登录循环每轮都复查弹窗,中途冒出来也能接住)。 + return candidate['pid'], spawned + time.sleep(1) + logger.warning(f'桌面客户端进程已启动,但 {timeout} 秒内未识别到游戏窗口') + return 0, spawned + finally: + # Job 只用于启动期的归属判定;关闭句柄不影响已加入的成员进程继续运行 + if job: + ctypes.windll.kernel32.CloseHandle(wintypes.HANDLE(job)) + + +def desktop_launch_instance(config) -> bool: + """「启动实例」按钮的落地动作:按 config.script.desktop 参数拉起一个客户端实例。 + + 在 GUI/主进程里执行(script_set_arg 的副作用),不依赖 worker 的 Device 实例: + 启动、Job 归属、档案状态文件都是模块级能力,worker 下次需要窗口时按状态文件 + 认领。已在运行(状态文件里的 PID 还活着)则跳过,避免多开出一个。 + """ + desktop = getattr(config.script, 'desktop', None) + if desktop is None or not desktop.enable: + logger.error('未启用桌面客户端模式(script.desktop.enable),已忽略启动请求') + return False + root = desktop_install_root(desktop.game_path) + if not root: + logger.error('未找到阴阳师桌面客户端安装目录,' + '请在 设置-Script-桌面客户端 中填写 游戏安装目录') + return False + exe = desktop_game_exe(root) + if not exe: + logger.error(f'未找到游戏程序(bin\\onmyoji.exe 或 Launch.exe):{root}') + return False + profile = desktop_profile_path(desktop.profile_dir) + if profile is not None: try: - return self.find_desktop_window_by_pid(pid) - except EmulatorNotRunningError: - return fallback + desktop_prepare_profile_if_needed(profile, root) + except Exception as e: + logger.error(f'准备多开档案失败,拒绝启动(避免登录到错误账号): {e}') + return False + state = desktop_read_instance_state(profile) + state_pid = state.get('pid') + if state_pid and desktop_pid_alive(state_pid): + logger.info(f'客户端实例已在运行(PID={state_pid}),跳过启动') + return True + # 按钮路径不经过 Device.desktop_bind_pid,窗口标题改名在这里补上: + # 绑定瞬间(on_bound)就改,与 Device 流程同一时机 + result = desktop_spawn_client( + exe, root, profile, + on_bound=lambda pid, hwnd: desktop_rename_window(hwnd, config)) + if result is None: + return False + bound_pid, spawned = result + if not bound_pid: + logger.error('客户端已启动但未识别到游戏窗口') + return False + # 状态文件供 worker 认领;PID 不写配置,只活在这里与运行时 + desktop_write_instance_state(profile, bound_pid) + logger.info(f'已按按钮请求启动客户端实例 PID={bound_pid}') + return True + + +def find_desktop_window_by_pid(pid, titles=None) -> int: + """按 PID 查找桌面客户端窗口,返回窗口句柄;未找到抛 EmulatorNotRunningError。 + + 「找不到窗口」记 warning 而非 error:本方法的调用方多为探测存在性 + (desktop_window_exists 判断是否需要重拉、_desktop_wait_closed 确认已关闭), + 找不到是正常答案。真正的故障由上层在拿到 EmulatorNotRunningError 后判定, + 这样真故障不会被淹没在每轮任务收尾都出现的噪音里。 + + 同一 PID 下可能同时存在多个标题命中的顶层窗口(客户端多开共用同一标题; + 新版 MuMu 内核客户端启动期还带着启动器残留窗口),取客户区最大者即可。 + + 这里刻意**不设客户区面积下限**:窗口被最小化时客户区是 0x0,但客户端仍在 + 运行,还原交给 desktop_window_restore_if_minimized。用面积下限去宣布「未运行」 + 会把这个正常挂着的客户端判成掉线 → 触发误重拉(真机实测的循环就是这么起来的), + 而 BitBlt 截图前本来就会先把窗口还原。 + + titles 为 None 时用 DESKTOP_WINDOW_TITLES;配置了自定义窗口标题 + (script.desktop.window_title)时必须传 desktop_window_titles(config), + 否则改名后的实例会被当成「窗口不存在」。 + """ + try: + pid_int = int(str(pid)) + except (TypeError, ValueError): + logger.error(f'Invalid desktop PID: {pid}') + raise EmulatorNotRunningError(f'Invalid desktop client PID: {pid}') + + wanted = tuple(titles) if titles else DESKTOP_WINDOW_TITLES + + def enum_cb(hwnd, param): + param.append(hwnd) + return True + + windows = [] + EnumWindows(enum_cb, windows) + matches = [] + for hwnd in windows: + if GetWindowText(hwnd) in wanted: + _, win_pid = GetWindowThreadProcessId(hwnd) + if win_pid == pid_int: + matches.append(hwnd) + # 同 PID 的多个候选里取客户区最大者:正常渲染窗口面积最大,启动器残留/ + # 最小化窗口(客户区 0x0)会排在后面。读不到客户区的(枚举与取值之间被销毁) + # 直接跳过,全部读不到才算未找到 + best, best_area = 0, -1 + for hwnd in matches: + try: + cl, ct, cr, cb = GetClientRect(hwnd) + area = (cr - cl) * (cb - ct) + except Exception: + continue + if area > best_area: + best, best_area = hwnd, area + if best: + return best + logger.warning(f'Desktop client window not found, PID={pid}') + raise EmulatorNotRunningError(f'Desktop client window not found, PID={pid}') + + +def _desktop_best_window(pid, fallback: int = 0, titles=None) -> int: + """取指定 PID 下客户区最大的候选窗口;查不到时回退 fallback。 + + 用于自动启动完成时的句柄确定:同一 PID 可能既带启动器残留窗口又带真渲染窗口, + 取客户区最大者才不会把截图绑到残壳上。titles 语义同 find_desktop_window_by_pid。 + """ + try: + return find_desktop_window_by_pid(pid, titles) + except EmulatorNotRunningError: + return fallback + +class DesktopWindow: def desktop_pid(self): """返回当前桌面客户端 PID(int);取不到返回 None。 - 优先用实例 root_handle(运行时自动启动绑定后的新 PID),未设置时回退配置里的 - handle。本会话的 PID 判断必须以实例状态为准,否则重新拉起后会误判。 + 只用实例 root_handle:PID 每次重启都会变、不写配置,本会话的 PID 判断 + 必须以实例状态为准。为空的唯一场景是还没绑定过(尚未启动/认领)。 """ - pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + pid = getattr(self, 'root_handle', None) try: return int(str(pid)) except (TypeError, ValueError): @@ -361,6 +889,9 @@ def desktop_confirm_login_popup(self, wait: float = 15.0) -> bool: if not self.find_desktop_login_popup(): return False logger.info('Desktop MPay login popup found, press Enter to enter game') + # 客户端在首次登录流程中会自行重设一次标题(绑定时的改名因此被覆盖), + # 检测到弹窗时补改一次;标题已正确时仅是字符串比较 + desktop_rename_window(self.root_handle_num, self.config) deadline = time.time() + wait while time.time() < deadline: hwnd = self.find_desktop_login_popup() @@ -378,12 +909,14 @@ def desktop_window_exists(self) -> bool: 只判「标题命中的顶层窗口在不在」,不看客户区尺寸——窗口被最小化或被遮挡时 客户端仍在运行,截图方法会先把窗口还原回来再取帧。 - 优先用实例 root_handle(运行时自动启动绑定后的新 PID),未设置时回退配置里的 - handle。本会话的窗口存在性判断必须以实例状态为准,否则重新拉起后会误判为未运行。 + 只用实例 root_handle:PID 不写配置,本会话的窗口存在性判断必须以实例状态 + 为准。root_handle 为空说明还没绑定过(未启动/未认领),按未运行处理。 """ - pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + pid = getattr(self, 'root_handle', None) + if not pid: + return False try: - self.find_desktop_window_by_pid(pid) + find_desktop_window_by_pid(pid, self._desktop_titles()) return True except EmulatorNotRunningError: return False @@ -392,13 +925,12 @@ def _desktop_wait_window_ready(self, pid, wait: float = DESKTOP_WINDOW_READY_WAI """在 wait 秒内等待指定 PID 的客户端窗口出现;超时返回 False。 用于「进程还活着、窗口暂时不可见」的空窗期。这段时间只能等,不能重新启动 - 客户端:真正在跑的那个实例会变成多开或被顶掉,配置里的 handle 也会被新实例 - 覆盖,从此再也绑不回原来的客户端。 + 客户端:真正在跑的那个实例会变成多开或被顶掉,从此再也绑不回原来那个。 """ deadline = time.time() + wait while True: try: - self.find_desktop_window_by_pid(pid) + find_desktop_window_by_pid(pid, self._desktop_titles()) return True except EmulatorNotRunningError: pass @@ -463,7 +995,7 @@ def _desktop_rebind_window(self) -> bool: 下换到客户区更大的那个候选窗口。进程还活着,不需要重拉客户端。 """ try: - hwnd = self.find_desktop_window_by_pid(self.root_handle) + hwnd = find_desktop_window_by_pid(self.root_handle, self._desktop_titles()) except EmulatorNotRunningError: return False if hwnd != self.root_handle_num: @@ -517,6 +1049,9 @@ def _desktop_try_set_size(self, width: int, height: int): return None client_w, client_h = size logger.info(f'Desktop client size: {client_w}x{client_h} (physical), target {width}x{height}') + # 多开摆位与尺寸校准解耦:客户端启动后会自行把窗口挪回屏幕中央, + # 摆位只挂在「尺寸不对」的分支上会被尺寸已正确的窗口整份跳过 + self._desktop_assert_window_pos(hwnd) if client_w == width and client_h == height: logger.info('Desktop client size already matches target') return False @@ -555,86 +1090,26 @@ def _desktop_try_set_size(self, width: int, height: int): # ------------------------------------------------------------------ 桌面客户端自动生命周期 - def desktop_resolve_install_root(self) -> str: - """解析桌面客户端安装目录:优先 desktop_game_path 配置,其次自动发现。 - - 只认含 bin\\onmyoji.exe(或根目录 Launch.exe)的安装目录,找不到返回空串。 - 仅桌面模式调用,不影响模拟器流程。 - """ - configured = getattr(self.config.script.device, 'desktop_game_path', '') or '' - if configured: - root = self._desktop_root_from_path(configured) - if root is not None: - return str(root) - logger.warning(f'desktop_game_path 配置无效: {configured},尝试自动发现') - root = self._desktop_discover_install_root() - return str(root) if root else '' - - @staticmethod - def _desktop_root_from_path(value: str): - """把用户填的路径归一化成安装目录;无效返回 None。""" - path = Path(value).expanduser() - if path.is_file(): - # 允许直接填 bin\\onmyoji.exe 或 Launch.exe 的完整路径 - if path.name.lower() == 'onmyoji.exe' and path.parent.name.lower() == 'bin': - path = path.parent.parent - elif path.name.lower() == 'launch.exe': - path = path.parent - else: - path = path.parent - candidate = path / 'bin' / 'onmyoji.exe' - if candidate.is_file(): - return path.resolve() - return None + def desktop_profile_dir(self): + """返回多开档案目录(Path);未配置 script.desktop.profile_dir 时返回 None。""" + return desktop_profile_path(getattr(self.config.script.desktop, 'profile_dir', '')) - @staticmethod - def _desktop_discover_install_root(): - """按 %ProgramFiles%\\Onmyoji 与注册表 Uninstall 的 InstallLocation 自动发现安装目录。""" - for env in ('ProgramFiles', 'ProgramFiles(x86)'): - raw = os.environ.get(env) - if raw and (Path(raw) / 'Onmyoji' / 'bin' / 'onmyoji.exe').is_file(): - return (Path(raw) / 'Onmyoji').resolve() - try: - import winreg - roots = ( - (winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'), - (winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'), - (winreg.HKEY_CURRENT_USER, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'), - ) - for hive, key_path in roots: - try: - with winreg.OpenKey(hive, key_path) as parent: - for index in range(winreg.QueryInfoKey(parent)[0]): - try: - with winreg.OpenKey(parent, winreg.EnumKey(parent, index)) as child: - display, _ = winreg.QueryValueEx(child, 'DisplayName') - if '阴阳师' not in str(display) and 'Onmyoji' not in str(display): - continue - location, _ = winreg.QueryValueEx(child, 'InstallLocation') - if location and (Path(location) / 'bin' / 'onmyoji.exe').is_file(): - return Path(location).resolve() - except OSError: - continue - except OSError: - continue - except Exception: - pass - return None + def desktop_window_pos(self): + """返回配置的窗口摆位 (x, y);未配置返回 None。""" + return self.desktop_window_pos_parsed( + getattr(self.config.script.desktop, 'window_pos', '')) @staticmethod - def desktop_game_exe(root) -> str: - """返回游戏可执行文件路径;找不到返回空串。 + def desktop_window_pos_parsed(value: str): + """解析窗口摆位配置("x,y");非法或留空返回 None。""" + matched = re.fullmatch(r'\s*(-?\d+)\s*[,,]\s*(-?\d+)\s*', str(value or '')) + if not matched: + return None + return int(matched.group(1)), int(matched.group(2)) - 顺序即优先级:必须优先游戏本体 bin/onmyoji.exe。 - Launch.exe 是登录器,它会再拉起 bin/onmyoji.exe, - 导致双开且 OAS 绑定到登录器 PID 上找不到游戏窗口。 - """ - root = Path(root) - for name in ('bin/onmyoji.exe', 'Launch.exe'): - exe = root / name - if exe.is_file(): - return str(exe) - return '' + def desktop_resolve_install_root(self) -> str: + """解析桌面客户端安装目录:优先 script.desktop.game_path 配置,其次自动发现。""" + return desktop_install_root(getattr(self.config.script.desktop, 'game_path', '')) def launch_desktop_client(self, timeout: int = 90) -> bool: """自动启动桌面客户端并绑定新窗口的 PID/HWND,成功返回 True。 @@ -644,18 +1119,31 @@ def launch_desktop_client(self, timeout: int = 90) -> bool: timeout 内没等到窗口说明客户端起歪了(卡加载、崩在启动期等),强杀本轮进程后 整轮重跑一次;第二轮仍失败则记 error 返回 False,由上层停下等人工,不无限重试。 安装目录/exe 找不到属配置问题,重试无意义,直接返回 False。 + + 配置了档案目录(script.desktop.profile_dir)时按档案隔离启动:cwd 与 + USERPROFILE 都落在档案内,每个档案拥有独立的 Documents 与 SDK 账号库 + (多开账号隔离);未配置时行为不变。 """ root = self.desktop_resolve_install_root() if not root: - logger.error('未找到阴阳师桌面客户端安装目录,请在 设置-Script-设备 中填写 desktop_game_path') + logger.error('未找到阴阳师桌面客户端安装目录,' + '请在 设置-Script-桌面客户端 中填写 游戏安装目录') return False - exe = self.desktop_game_exe(root) + exe = desktop_game_exe(root) if not exe: logger.error(f'未找到游戏程序(bin\\onmyoji.exe 或 Launch.exe):{root}') return False + profile = self.desktop_profile_dir() + if profile is not None: + try: + desktop_prepare_profile_if_needed(profile, root) + except Exception as e: + logger.error(f'准备多开档案失败,拒绝启动(避免登录到错误账号): {e}') + return False for attempt in (1, 2): - pids = self._desktop_launch_attempt(exe, root, timeout, attempt) + pids = desktop_spawn_client(exe, root, profile, timeout, attempt, + on_bound=self.desktop_bind_pid) if pids is None: # Popen 本身失败,重试也起不来 return False @@ -666,8 +1154,6 @@ def launch_desktop_client(self, timeout: int = 90) -> bool: # 89 线程 42s CPU 却无窗口)。OAS 靠枚举窗口只绑到窗口进程,关闭时若只杀 # 它,启动器就成了无主残留。这里留档,交给 desktop_stop_client 一并清理 self._desktop_spawned_pids = set(spawned_pid) - # 客户端已拉起,强制保持显示器常亮,防止电源超时关屏导致截图黑帧卡死 - desktop_keep_screen_on(True) return True if attempt == 1: # 只杀本轮确切启动的进程:绑定阶段就失败时 root_handle 仍是上一次的陈旧 @@ -677,54 +1163,27 @@ def launch_desktop_client(self, timeout: int = 90) -> bool: logger.error('桌面客户端连续 2 轮启动均未就绪,请检查客户端状态与机器负载') return False - def _desktop_launch_attempt(self, exe: str, root: str, timeout: int, attempt: int): - """启动客户端并绑定其窗口的单轮尝试。 + def _desktop_assert_window_pos(self, hwnd) -> None: + """按 script.desktop.window_pos 摆放窗口;未配置或已在位则什么都不做。 - 返回 (bound_pid, spawned_pid):bound_pid 非 0 表示本轮成功;spawned_pid 是本轮 - Popen 出的进程与绑定到的窗口 PID 集合,供失败清理精确定位。Popen 失败返回 None。 + 客户端启动后会自行把窗口挪回屏幕中央,只在绑定时摆一次压不住,故在尺寸 + 校准与截图路径上持续重断言。SWP_NOZORDER|SWP_NOACTIVATE:不抢焦点也不改 + 层级——多开窗口互相遮挡是允许的(后台截图不受遮挡影响)。 """ - before_pids = {w['pid'] for w in list_desktop_windows()} - logger.info(f'自动启动桌面客户端(第 {attempt} 轮): {exe}') - spawned = set() + position = self.desktop_window_pos() + if not position or not hwnd: + return + x, y = position try: - proc = subprocess.Popen([exe], cwd=root, - creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)) - spawned.add(proc.pid) + with dpi_awareness(): + rect = GetWindowRect(hwnd) + if (rect[0], rect[1]) == (x, y): + return + SetWindowPos(hwnd, HWND_TOP, x, y, 0, 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE) + logger.info(f'多开窗口已摆放到 ({x},{y}): hwnd={hwnd}') except Exception as e: - logger.error(f'启动桌面客户端失败: {e}') - return None - - deadline = time.time() + timeout - stable_hwnd = 0 - stable_count = 0 - while time.time() < deadline: - # 候选必须落在安装目录下的进程:客户端新旧版本窗口标题不同 - # ("阴阳师-网易游戏" / "阴阳师-MuMu模拟器专版"),启动等待期内任何 - # 匹配标题的新窗口都会成为候选,按 exe 归属收口,避免绑到别的进程 - candidates = [w for w in list_desktop_windows() - if w['pid'] not in before_pids - and _desktop_pid_under_root(w['pid'], root)] - if candidates: - candidate = candidates[0] - if candidate['hwnd'] == stable_hwnd: - stable_count += 1 - else: - stable_hwnd = candidate['hwnd'] - stable_count = 1 - if stable_count >= 2: - # 同一 PID 可能还带着启动器残留窗口(客户区极小),句柄统一取 - # 客户区最大者,否则截图会绑到残壳上(BitBlt 取不到画面) - hwnd = self._desktop_best_window(candidate['pid'], candidate['hwnd']) - self.desktop_bind_pid(candidate['pid'], hwnd) - spawned.add(candidate['pid']) - logger.info(f'桌面客户端已自动启动并绑定 PID={candidate["pid"]}') - # 到这里启动就完成了:进程在跑、窗口句柄已绑定。 - # MPay 登录弹窗与进游戏属于登录流程,由 Restart 的 app_handle_login - # 负责(登录循环每轮都复查弹窗,中途冒出来也能接住)。 - return candidate['pid'], spawned - time.sleep(1) - logger.warning(f'桌面客户端进程已启动,但 {timeout} 秒内未识别到游戏窗口') - return 0, spawned + logger.warning(f'摆放多开窗口失败 (hwnd={hwnd}): {e}') def _desktop_kill_pids(self, pids) -> None: """强杀指定 PID 集合(本轮启动失败的清理),逐个容错并验证真的退出。""" @@ -760,29 +1219,103 @@ def _desktop_wait_pid_gone(self, pid, wait: float) -> bool: """在 wait 秒内等待指定进程退出,返回是否已退出。""" deadline = time.time() + wait while True: - if not self._desktop_pid_alive(pid): + if not desktop_pid_alive(pid): return True if time.time() >= deadline: return False time.sleep(DESKTOP_KILL_POLL_INTERVAL) + def _desktop_titles(self) -> tuple: + """本配置认可的窗口标题白名单(内置官方标题 + 自定义窗口标题)。""" + return desktop_window_titles(getattr(self, 'config', None)) + + def _desktop_locate_window(self): + """定位本配置对应的已在运行的客户端窗口,返回 (pid, hwnd)。 + + pid 非空而 hwnd 为 0 表示进程活着但窗口还没就绪(启动期/登录后重建), + 由调用方等待;两者都空表示没有可归属的实例,应走启动流程。 + + 归属优先级: + 1. 档案内的运行时状态文件(oas_instance.json)——OAS 自己拉起的实例都记了 + PID,多开重开后凭它精确认领,绝不会绑到别的账号实例上; + 2. 窗口枚举——仅未配档案的单实例场景用:按 device.handle(窗口标题)过滤, + 只剩一个候选时才认领。多开时各实例标题相同,枚举无法区分归属, + 宁可重拉一个自己的,也不能控制别人的客户端。 + """ + profile = self.desktop_profile_dir() + state = desktop_read_instance_state(profile) + state_pid = state.get('pid') + if state_pid: + if desktop_pid_alive(state_pid): + try: + return int(state_pid), find_desktop_window_by_pid( + state_pid, self._desktop_titles()) + except EmulatorNotRunningError: + # 进程活着、窗口还没就绪:交调用方等,不能重拉(会多开) + return int(state_pid), 0 + # PID 已死:状态失效,清掉免得下次误判 + desktop_clear_instance_state(profile) + if profile is not None: + # 档案模式只认状态文件:枚举分不清多个同标题实例属于哪个账号 + return None, 0 + # 候选标题 = device.handle(原标题过滤)+ 自定义窗口标题(改名后的标题), + # 两个都可能命中正在跑的实例 + title = str(getattr(self.config.script.device, 'handle', '') or '').strip() + custom = str(getattr(self.config.script.desktop, 'window_title', '') or '').strip() + wanted = tuple(t for t in (title, custom) if t) + candidates = list_desktop_windows(wanted or None) + if not candidates: + # 按标题没命中:窗口可能被自定义标题改过名,或客户端换了新标题, + # 退回内置官方标题再找一次,只剩一个候选时才认领(单实例场景) + fallback = list_desktop_windows() + if len(fallback) == 1: + candidates = fallback + elif len(fallback) > 1: + logger.warning(f'按标题过滤未命中,且检测到 {len(fallback)} 个桌面客户端窗口,' + f'无法区分归属,将重新拉起一个实例') + if len(candidates) == 1: + candidate = candidates[0] + return candidate['pid'], _desktop_best_window( + candidate['pid'], candidate['hwnd'], self._desktop_titles()) + if len(candidates) > 1: + logger.warning(f'检测到 {len(candidates)} 个桌面客户端窗口且未配置档案目录,' + f'无法区分归属,将重新拉起一个实例') + return None, 0 + def desktop_bind_pid(self, pid, hwnd=0) -> None: - """把新 PID/HWND 绑定到实例,并尽量持久化到配置。""" + """把运行时 PID/HWND 绑定到实例;PID 不写配置,只进档案状态文件。 + + PID 每次重启都会变,落配置只会认领一个死进程;配了档案目录时写 + oas_instance.json 供下次认领。同时强制显示器常亮,防止电源超时关屏导致 + BitBlt 截图返回黑帧卡死登录循环。 + """ self.root_handle = str(pid) if hwnd: self.root_handle_num = hwnd - self.root_handle_title = DESKTOP_WINDOW_TITLES[0] + if hwnd: + try: + title = GetWindowText(hwnd) or DESKTOP_WINDOW_TITLES[0] + except Exception: + title = DESKTOP_WINDOW_TITLES[0] + # 自定义窗口标题:绑定即改名,便于多开时在任务栏区分实例 + desktop_rename_window(hwnd, self.config) + try: + title = GetWindowText(hwnd) or title + except Exception: + pass + else: + title = DESKTOP_WINDOW_TITLES[0] + self.root_handle_title = title self.is_desktop_window = True # 换了新 hwnd,截图句柄缓存必须同步失效,否则截图仍走上一个客户端的废句柄 self._desktop_clear_handle_cache() # 新绑定的客户端刚启动,必然未登录,需先走 restart 登录流程 self._desktop_login_done = False + # 客户端已拉起,强制保持显示器常亮(线程级状态,随客户端关闭解除) + desktop_keep_screen_on(True) + # 档案状态文件:下次启动凭它认领「这个档案的实例是哪个 PID」 + desktop_write_instance_state(self.desktop_profile_dir(), pid) logger.info(f'Desktop client bound: PID={pid}, hwnd={hwnd}') - try: - self.config.script.device.handle = str(pid) - self.config.save() - except Exception as e: - logger.warning(f'持久化桌面 PID 到配置失败: {e}') def _desktop_send_enter(self, hwnd) -> None: """向窗口发送回车键,用于确认 MPay 账号登录弹窗的默认按钮"进入游戏"。 @@ -795,31 +1328,6 @@ def _desktop_send_enter(self, hwnd) -> None: except Exception as e: logger.info(f'Send Enter to window {hwnd} failed (window may be closed): {e}') - def _desktop_pid_alive(self, pid) -> bool: - """进程是否还活着。无法判定时按「还活着」返回,宁可多等一轮也不误报已关闭。 - - 不能只看 OpenProcess 是否成功:进程已退出但仍有内核对象引用时 OpenProcess - 照样返回句柄,必须再用 GetExitCodeProcess 看退出码是否还是 STILL_ACTIVE(259)。 - 窗口枚举不能替代这里——强杀被拒时窗口可能已销毁而进程还在,只验窗口会误判。 - """ - try: - pid_int = int(str(pid)) - except (TypeError, ValueError): - return False - kernel32 = ctypes.windll.kernel32 - # PROCESS_QUERY_LIMITED_INFORMATION(0x1000),比 QUERY_INFORMATION 权限要求更低 - handle = kernel32.OpenProcess(0x1000, False, pid_int) - if not handle: - # 打不开通常就是进程已退出;权限不足时也走这里,交给窗口检查兜底 - return False - try: - code = ctypes.c_ulong() - if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): - return True - return code.value == 259 # STILL_ACTIVE - finally: - kernel32.CloseHandle(handle) - def _desktop_wait_closed(self, wait: float) -> bool: """在 wait 秒内轮询等待桌面客户端窗口消失,返回是否已关闭。""" deadline = time.time() + wait @@ -839,7 +1347,7 @@ def _desktop_wait_released(self, pid, wait: float) -> bool: """ deadline = time.time() + wait while True: - if not self.desktop_window_exists() and not self._desktop_pid_alive(pid): + if not self.desktop_window_exists() and not desktop_pid_alive(pid): return True if time.time() >= deadline: return False @@ -857,19 +1365,20 @@ def desktop_stop_client(self) -> bool: 就再杀一轮,全部轮次用尽仍在则返回 False——调用方据此决定是重建还是交人工, 绝不能发完 kill 指令就当成功返回。 - 关闭后保留原 PID 在配置里;下次检测到该 PID 无效(客户端未运行)时, - 由启动/重拉链路重启客户端并把配置更新为重启后的新 PID。 + PID 只活在运行时:关闭后清除档案状态文件,下次启动由启动/重拉链路重新拉起 + 并绑定新 PID(不写配置)。 """ # 客户端关闭后必然未登录,下次启动需重新走登录流程 self._desktop_login_done = False # PID 要在清理句柄前取,后面的验证全靠它 - pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + pid = getattr(self, 'root_handle', None) hwnd = self.root_handle_num - if (not hwnd or not IsWindow(hwnd)) and not self._desktop_pid_alive(pid): + if (not hwnd or not IsWindow(hwnd)) and not desktop_pid_alive(pid): logger.info('Desktop client not running, skip stop') # 客户端已不在,解除强制亮屏,允许系统恢复正常的显示器电源管理 desktop_keep_screen_on(False) self._reset_desktop_handle_state() + desktop_clear_instance_state(self.desktop_profile_dir()) # 窗口进程已没了也要收启动器:它无窗口,只看窗口永远发现不了 self._desktop_kill_spawned_leftovers(pid) return True @@ -892,6 +1401,8 @@ def desktop_stop_client(self) -> bool: logger.error(f'Desktop client PID={pid} not released after ' f'{DESKTOP_KILL_ATTEMPTS} force kill attempts, manual cleanup needed') self._reset_desktop_handle_state() + # 实例已终结(或杀而不死),档案状态文件不能再指着一个死 PID + desktop_clear_instance_state(self.desktop_profile_dir()) # 无论窗口进程是否关掉,本轮自己拉起的启动器都要一并收掉 leftovers_cleared = self._desktop_kill_spawned_leftovers(pid) return released and leftovers_cleared @@ -913,13 +1424,13 @@ def _desktop_kill_spawned_leftovers(self, bound_pid) -> bool: except (TypeError, ValueError): bound = None # 已绑定的那个由主流程负责,这里只收剩下的 - leftovers = {p for p in spawned if p != bound and self._desktop_pid_alive(p)} + leftovers = {p for p in spawned if p != bound and desktop_pid_alive(p)} self._desktop_spawned_pids = set() if not leftovers: return True logger.info(f'清理本次启动残留的客户端进程(启动器): {sorted(leftovers)}') self._desktop_kill_pids(leftovers) - still = {p for p in leftovers if self._desktop_pid_alive(p)} + still = {p for p in leftovers if desktop_pid_alive(p)} if still: logger.error(f'客户端启动器进程无法清理: {sorted(still)},可能需要手动结束') return False @@ -946,7 +1457,7 @@ def _desktop_close_wait_seconds(self) -> int: def desktop_force_kill(self) -> None: """强杀桌面客户端进程(WM_CLOSE 超时未退出的兜底)。""" - pid = getattr(self, 'root_handle', None) or self.config.script.device.handle + pid = getattr(self, 'root_handle', None) try: pid_int = int(str(pid)) except (TypeError, ValueError): diff --git a/module/device/method/windows_impl.py b/module/device/method/windows_impl.py index 1f4bb0ef47..633afe2103 100644 --- a/module/device/method/windows_impl.py +++ b/module/device/method/windows_impl.py @@ -95,6 +95,8 @@ def screenshot_desktop_bitblt(self): 与资产 1:1,客户区 DC 原点即画面左上角、不含标题栏,故无偏移、无缩放。 """ self.desktop_window_restore_if_minimized() + # 多开摆位重断言:客户端启动后会自行挪回屏幕中央,只在启动时摆一次压不住 + self._desktop_assert_window_pos(self.root_handle_num) with dpi_awareness(): client_rect = GetClientRect(self.screenshot_handle_num) widthScreen = client_rect[2] - client_rect[0] diff --git a/tasks/Script/config.py b/tasks/Script/config.py index 4dc520cd35..5ac12cfab3 100644 --- a/tasks/Script/config.py +++ b/tasks/Script/config.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field from tasks.Script.config_device import Device +from tasks.Script.config_desktop import Desktop from tasks.Script.config_error import Error from tasks.Script.config_optimization import Optimization from tasks.Script.config_antiban import AntiBan @@ -11,6 +12,7 @@ class Script(BaseModel): device: Device = Field(default_factory=Device) + desktop: Desktop = Field(default_factory=Desktop) error: Error = Field(default_factory=Error) optimization: Optimization = Field(default_factory=Optimization) - anti_ban: AntiBan = Field(default_factory=AntiBan) \ No newline at end of file + anti_ban: AntiBan = Field(default_factory=AntiBan) diff --git a/tasks/Script/config_desktop.py b/tasks/Script/config_desktop.py new file mode 100644 index 0000000000..324b746e56 --- /dev/null +++ b/tasks/Script/config_desktop.py @@ -0,0 +1,26 @@ +# This Python file uses the following encoding: utf-8 +# @author runhey +# github https://github.com/runhey +from pydantic import BaseModel, Field + + +class Desktop(BaseModel): + """桌面客户端(阴阳师 PC 版)模式开关与专属参数,与 device 组并列。 + + enable 为 True 时 OAS 不走 adb,直接控制本机 Windows 上的客户端窗口; + 多开账号隔离见 profile_dir。 + """ + + # 启用后 device 组的模拟器参数退场,handle 改作客户端窗口标题(留空自动识别) + enable: bool = Field(default=False, description='desktop_enable_help') + # 游戏安装目录(含 bin\onmyoji.exe 或 Launch.exe 的根目录),留空自动查找 + game_path: str = Field(default='', description='game_path_help') + # 多开账号隔离:实例档案目录,每个配置一个 + profile_dir: str = Field(default='', description='profile_dir_help') + # 多开时自动摆放窗口位置,格式 "x,y"(如 0,0 / 660,0);留空 = 不移动 + window_pos: str = Field(default='', description='window_pos_help') + # 自定义窗口标题:绑定时改窗口标题,仅用于多开时在任务栏区分实例。 + # 留空 = 不改;识别与点击不依赖标题 + window_title: str = Field(default='', description='window_title_help') + # 一次性按钮:点击后启动本配置对应的客户端实例;执行后自动弹回 false + launch_instance: bool = Field(default=False, description='launch_instance_help') diff --git a/tasks/Script/config_device.py b/tasks/Script/config_device.py index e45dd77a7f..4875d0418e 100644 --- a/tasks/Script/config_device.py +++ b/tasks/Script/config_device.py @@ -50,14 +50,12 @@ class EmulatorInfoType(str, Enum): MEmuPlayer = 'MEmuPlayer' class Device(BaseModel): + # adb 序列号;桌面客户端模式(见 desktop 组)下本字段退场 serial: str = Field(default="auto", description='serial_help') + # 模拟器模式:窗口标题或句柄编号;桌面客户端模式:客户端窗口标题(留空自动识别) handle: str = Field(default='', description='handle_help') - # 桌面客户端自动启动用:游戏安装目录(含 bin\onmyoji.exe 或 Launch.exe 的根目录), - # 留空时按 %ProgramFiles%\Onmyoji 与注册表 InstallLocation 自动发现;仅桌面模式使用 - desktop_game_path: str = Field(default='', - description='desktop_game_path_help') package_name: PackageName = Field(title='Package Name', default=PackageName.AUTO, description='package_name_help')