From 90be84c489da32edc1fdd7c6c7b154b1398196f3 Mon Sep 17 00:00:00 2001
From: Douya36 <274382944+Douya36@users.noreply.github.com>
Date: Fri, 13 Nov 2026 21:50:44 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix(m9a):=20=E4=BF=AE=E5=A4=8D=20B=20?=
=?UTF-8?q?=E6=9C=8D=E9=80=89=E5=8F=B7=E5=B9=B6=E5=9C=A8=E4=BB=A3=E7=90=86?=
=?UTF-8?q?=E5=89=8D=E6=A0=B8=E5=AF=B9=20UID?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/models/config/script.py | 15 +-
app/task/M9A/AGENTS.md | 8 +-
app/task/M9A/flavor.py | 15 ++
app/task/M9A/login.py | 166 ++++++++++------
app/task/MaaFW/tools/embedded/runner_task.py | 49 ++++-
changelog.d/fix-m9a-bili-nickname.fix.md | 4 +
frontend/src/api/models/M9AUserConfig_Info.ts | 4 +
.../src/api/models/M9AUserConfig_InfoInput.ts | 4 +
frontend/src/types/script.ts | 2 +
.../EditView/MaaFWFlavor/sectionContracts.ts | 1 +
.../src/views/EditView/User/MaaFWUserEdit.vue | 1 +
.../User/MaaFWUserEdit/BasicInfoSection.vue | 12 ++
res/i18n/en.json | 3 +-
res/i18n/ja.json | 3 +-
res/i18n/zh_CN.json | 3 +-
tests/task/test_m9a_login_flow.py | 180 +++++++++++++-----
16 files changed, 354 insertions(+), 116 deletions(-)
create mode 100644 changelog.d/fix-m9a-bili-nickname.fix.md
diff --git a/app/models/config/script.py b/app/models/config/script.py
index aa134852fd..2116860358 100644
--- a/app/models/config/script.py
+++ b/app/models/config/script.py
@@ -2670,12 +2670,23 @@ class UpdateFields(ConfigGroup):
class M9AUserConfig(MaaFWUserConfig):
- """M9A 账号配置:与 MaaFW 账号配置同形。
+ """M9A 账号配置,含 B 服登录后的 UID 核对。
``Info.Account`` 在这里不只是备注:脚本资源为官服且账号非空时,运行前会自动插入
「切换账号」任务(见 ``app/task/M9A/flavor.py``)。
"""
+ class InfoFields(MaaFWUserConfig.InfoFields):
+ GameUID: str = Field(
+ default_factory=lambda: "",
+ pattern=r"^[0-9]*$",
+ description="游戏内数字 UID,用于 MAS 登录 B 服后核对账号",
+ )
+
+ Info: InfoFields = Field( # pyright: ignore[reportIncompatibleVariableOverride]
+ default_factory=InfoFields
+ )
+
class M9AConfig(MaaFWConfig):
"""M9A 托管配置:MaaFW 的特调类型。
@@ -2708,7 +2719,7 @@ class RunFields(MaaFWConfig.RunFields):
default_factory=lambda: "Project",
description="B 服登录方式。Project:由 M9A 项目的启动任务自己登录(上游「切换账号」仍只声明官服);"
"Mas:由 MAS 完成 B 服登录——拉起 B 服客户端后处理隐私政策提示、在登录记录里选择已保存的账号,"
- "回到标题页后再交给 M9A 的启动任务进游戏并照常运行。Mas 需要 ADB controller 并在「游戏包名」里"
+ "进入游戏并核对账号配置中的 UID 后再交给 M9A 运行。Mas 需要 ADB controller 并在「游戏包名」里"
"填写 B 服包名(com.shenlan.m.reverse1999.bilibili),账号填登录记录里显示的完整昵称,"
"且不再向 M9A 下发「切换账号」。自动同意隐私提示复用全局「同意哔哩哔哩用户协议」开关(默认关闭),"
"未开启时登录会停下并提示手动处理。",
diff --git a/app/task/M9A/AGENTS.md b/app/task/M9A/AGENTS.md
index 22d2d3592b..1d774547fa 100644
--- a/app/task/M9A/AGENTS.md
+++ b/app/task/M9A/AGENTS.md
@@ -67,10 +67,10 @@
- `login.py`:`Run.LoginMode == "Mas"` 时的 B 服原生登录(`ensure_logged_in` 钩子)。MAS 启动模拟器
(连带拉起游戏)之后、第一个任务之前,用 `adb` 截图 + `app.tools.common.ocr` 识别哔哩哔哩登录面板:
点「隐私政策提示」里唯一的「同意」,在「登录记录」里按 `Info.Account`(登录记录里显示的完整昵称)
- 精确匹配、唯一命中后选中并点「登录」,等面板消失、连续两帧认出标题页后才返回;进游戏与跑任务交给
- M9A 的 `StartUp` 与用户队列。标题页的正向信号有四种:OCR 到「START」(或漏首字母的 TART)、
- 标题 logo 下的完整「REVERSE」或实机常读到的残词 `ERSE`、或登录成功后顶栏的「<昵称> 欢迎回来」横幅。
- 游戏已登录停在标题页(任一信号)时,
+ 精确匹配、唯一命中后选中并点「登录」。登录面板退出后先运行 M9A 的 `StartUp`,
+ 连续两帧识别到的游戏内 UID 与 `Info.GameUID` 一致才运行剩余任务,缺填、识别超时或不匹配均停止。
+ 标题页使用 START(或 TART)、REVERSE(或 ERSE)识别。
+ 游戏已登录停在标题页时,
MAS 不知道当前是哪个号,为保证切到目标账号,先点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」
出现再选号。`adb` 子进程走 `app/utils/common/process/runner.py` 的受管入口:任务被停止时命令随作用域
回收,截图分支保留 PNG 原始 bytes(不能按文本解码)。
diff --git a/app/task/M9A/flavor.py b/app/task/M9A/flavor.py
index 748edf8e6d..a4d9ac4f01 100644
--- a/app/task/M9A/flavor.py
+++ b/app/task/M9A/flavor.py
@@ -361,6 +361,21 @@ async def ensure_logged_in(
host_adb=host_adb,
)
+ def account_verification_entry(self, script_config: Any) -> str | None:
+ return STARTUP_ENTRY if _login_mode(script_config) == LOGIN_MODE_MAS else None
+
+ async def verify_game_account(
+ self, *, user_config: Any, adb_path: str | None, adb_address: str, host_adb: Any
+ ) -> str:
+ from .login import verify_game_uid
+
+ return await verify_game_uid(
+ user_config=user_config,
+ adb_path=adb_path,
+ adb_address=adb_address,
+ host_adb=host_adb,
+ )
+
async def ensure_game_updated(
self,
*,
diff --git a/app/task/M9A/login.py b/app/task/M9A/login.py
index b7cb24ce98..92b66e1186 100644
--- a/app/task/M9A/login.py
+++ b/app/task/M9A/login.py
@@ -31,13 +31,12 @@
- 「隐私政策提示」弹层:找到唯一的「同意」按钮(中心点限定在屏幕中下部)就点它;
- 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称精确匹配
- (中心点限定在列表区域),唯一命中后点击,复核「登录」按钮与仍可见的昵称,最后点「登录」;
- - 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`,
- 或登录成功后顶栏的「<昵称> 欢迎回来」横幅;START 有时太淡读不到):MAS 无法知道当前登录的是哪个
+ (兼容「ゞ」被 OCR 读成「>」,中心点限定在列表区域),唯一命中后点击,
+ 复核「登录」按钮与仍可见的昵称,最后点「登录」;
+ - 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`):MAS 无法知道当前登录的是哪个
账号,为了保证切到目标账号,点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」面板出现后按
上一条选号;
-3. 登录后等哔哩哔哩面板消失(游戏回到标题页),把这之后的进游戏与任务执行交给 M9A 的
- ``StartUp`` 与用户任务队列。
+3. 登录后等面板消失,由 M9A 的 StartUp 进入游戏。核对游戏内 UID 后才运行剩余任务。
沿用的是哔哩哔哩 SDK 登录面板与标题页的 UI 锚点;布局改版后这里要跟着改。脚本能自己登录的
项目照旧走 ``Run.LoginMode == "Project"``,本模块只在 MAS 原生登录模式下生效。
@@ -50,8 +49,7 @@
把游戏放到标题页——不能把"没认出登录页"当成"目标账号已登录",否则任务会跑在错的账号上;
等待时长服从脚本的 `Game.WaitTime`(加载画面同样认不出,不能按固定 60 秒截断慢启动的客户端),
总时长仍受登录主循环的 240 秒约束;
-- 登录后必须连续若干帧认出标题页才算完成;过渡黑屏 / 加载 / OCR 漏字只算未知画面,继续
- 有界等待,到点仍未确认就失败;
+- 登录后等待面板退出,进游戏后核对 UID,未确认身份时不运行后续任务;
- 所有循环都先检查总时限,``adb input tap`` 失败直接抛错,不让点击失败被静默吞掉;
- 只在 `Mas` 模式、资源是「B 服」、且实际要启动的客户端是 B 服包名时干活:配错客户端要立刻
报错,不能拿官服客户端去走哔哩哔哩登录面板。
@@ -98,7 +96,6 @@
BILIBILI_START_ACTIVITY = "com.ssgame.mobile.gamesdk.frame.AppStartUpActivity"
_PRIVACY_HINT = "隐私政策"
-_WELCOME_HINT = "欢迎回来"
#: 冷启动的健康警告页标题(「警告:请在游戏前阅读」),点一下继续。
_WARNING_HINT = "请在游戏前阅读"
_LOGIN_RECORD = "登录记录"
@@ -110,8 +107,10 @@
#: 标题页可读的 OCR 文字:「START」按钮(允许 OCR 漏掉首字母)与标题 logo 下的「REVERSE」
#: 副标题(实机常只读到 ERSE)。登录面板与主界面都读不到这两处。
_TITLE_HINTS = ("START", "TART", "REVERSE", "ERSE")
-#: 标题页右下角的退出图标位置(相对屏幕;实机 2560x1440 验证,点它弹「是否登出当前账号?」)。
-_EXIT_POINT = (0.956, 0.91)
+#: 标题页右下角退出图标的中心(相对游戏画面宽高)。
+_EXIT_POINT = (0.963, 0.912)
+#: 登录后的标题页「START」按钮中心(相对游戏画面宽高)。
+_START_POINT = (0.50, 0.745)
#: ``dumpsys activity activities`` 里表示 Activity 在前台的几种写法(与模拟器应用启动同一口径)。
_FOREGROUND_MARKERS = (
@@ -131,8 +130,8 @@
#: 冷启动的加载页(只有版本号)也算认不出;上限由脚本的 ``Game.WaitTime`` 抬到用户愿意
#: 为启动等的时间(见 ``_unknown_screen_budget``),不能按这个固定值截断慢启动的客户端。
_UNKNOWN_SCREEN_SECONDS = 60.0
-#: 连续多少帧都认出标题页才判「已回到标题页」,避免过渡帧 / OCR 漏字误判。
-_TITLE_CONFIRM_FRAMES = 2
+#: 连续多少帧未见登录面板才允许 M9A 接手启动。
+_PANEL_CLOSED_FRAMES = 2
#: 连续多少帧都认成标题页才动手登出,避免偶发误识别点到别处。
_TITLE_SETTLE_POLLS = 3
#: 点退出图标后等「确认」弹窗的上限。
@@ -148,8 +147,8 @@
#: 登录记录里账号昵称中心点范围(列表区域)。
_ACCOUNT_X_RANGE = (0.40, 0.57)
_ACCOUNT_Y_RANGE = (0.37, 0.70)
-#: 收起态「切换其他账号」行右侧展开箭头的位置(相对屏幕)。
-_EXPAND_POINT = (0.608, 0.43)
+#: 收起态账号卡片右侧的展开箭头(相对游戏画面宽高)。
+_EXPAND_POINT = (0.635, 0.413)
Box = tuple[int, int, int, int]
@@ -163,6 +162,12 @@ def _mask(text: str) -> str:
return f"{value[0]}{'*' * (len(value) - 1)}"
+def _account_matches(text: str, account: str) -> bool:
+ """兼容实机 OCR 将昵称中的「ゞ」读成「>」;仍需唯一匹配并核对游戏 UID。"""
+
+ return text.replace("ゞ", ">") == account.replace("ゞ", ">")
+
+
def _center(box: Box) -> tuple[float, float]:
x, y, width, height = box
return x + width / 2, y + height / 2
@@ -207,16 +212,6 @@ def _privacy_prompt(items: list[tuple[str, Box]]) -> bool:
return any(_PRIVACY_HINT in text for text, _ in items)
-def _welcome_like(items: list[tuple[str, Box]]) -> bool:
- """标题页的「<昵称> 欢迎回来」横幅。
-
- 登录成功后标题页顶部会出现这条横幅,START 有时被它盖住或太淡 OCR 读不到;横幅本身
- 是「已登录并回到标题页」的正向信号。只在标题页出现,登录面板与主界面都读不到。
- """
-
- return any(_WELCOME_HINT in text for text, _ in items)
-
-
def _warning_like(items: list[tuple[str, Box]]) -> bool:
"""冷启动的健康警告页(「警告:请在游戏前阅读」):点一下继续。
@@ -227,10 +222,28 @@ def _warning_like(items: list[tuple[str, Box]]) -> bool:
return any(_WARNING_HINT in text for text, _ in items)
-def _title_like(items: list[tuple[str, Box]]) -> bool:
- """标题页的样子:OCR 读到「START」这类按钮文字(登录面板与主界面都读不到)。"""
+def _title_like(items: list[tuple[str, Box]], frame: np.ndarray) -> bool:
+ """识别标题文字,或用左上版本号和左下适龄标识共同确认标题页。"""
- return any(text.strip().upper() in _TITLE_HINTS for text, _ in items)
+ if any("".join(text.upper().split()) in _TITLE_HINTS for text, _ in items):
+ return True
+ version = _pick(
+ items,
+ lambda text: bool(
+ re.fullmatch(r"[A-Z]?\d+\.\d+\.\d+(?:[-.]\d+)+", text.strip(), re.I)
+ ),
+ frame,
+ x_range=(0.02, 0.25),
+ y_range=(0.0, 0.10),
+ )
+ rating = _pick(
+ items,
+ lambda text: text.strip().upper() in {"CADPA", "适龄提示"},
+ frame,
+ x_range=(0.0, 0.12),
+ y_range=(0.80, 0.98),
+ )
+ return bool(version and rating)
def _is_bilibili_resource(resource_name: str | None) -> bool:
@@ -499,7 +512,7 @@ async def _select_account(
matches = _pick(
items,
- lambda text: text == account,
+ lambda text: _account_matches(text, account),
frame,
x_range=_ACCOUNT_X_RANGE,
y_range=_ACCOUNT_Y_RANGE,
@@ -523,7 +536,7 @@ async def _select_account(
selected = _pick(
items,
- lambda text: text == account,
+ lambda text: _account_matches(text, account),
frame,
x_range=_ACCOUNT_X_RANGE,
y_range=_ACCOUNT_Y_RANGE,
@@ -541,40 +554,83 @@ async def _wait_panel_closed(
device: _Device,
*,
progress: Progress | None,
-) -> str:
- """登录后等哔哩哔哩面板消失,并用标题页的正向信号确认已回到标题页。
-
- 「一帧没有登录记录文字」不算完成:过渡黑屏、加载动画、OCR 漏字都可能满足;
- 只有连续 ``_TITLE_CONFIRM_FRAMES`` 帧认出标题页才算成功,其余画面继续有界等待,
- 等满总时限仍没确认就失败。标题页的正向信号有两种:OCR 读到「START」,或读到登录成功
- 后的「<昵称> 欢迎回来」横幅(横幅有时会盖住或淡化 START)。
- """
+) -> None:
+ """等待登录面板消失,遇到标题页先点击 START,再交给 M9A。"""
deadline = time.monotonic() + _LOGIN_TIMEOUT
- title_frames = 0
+ closed_frames = 0
while True:
if time.monotonic() > deadline:
raise MaaFWLoginRequired(
- "B 服登录失败:点击登录后未确认回到游戏标题页(等待超时)"
+ "B 服登录失败:点击登录后等待超时,登录面板未关闭或未识别到标题页、游戏主页"
)
frame, items = await _ocr(device)
if _privacy_prompt(items):
await _agree_privacy(device, frame, items, progress=progress)
- title_frames = 0
+ closed_frames = 0
continue
if _has(items, _LOGIN_RECORD, _SWITCH_OTHER):
- title_frames = 0
- elif _title_like(items) or _welcome_like(items):
- title_frames += 1
- if title_frames >= _TITLE_CONFIRM_FRAMES:
- await asyncio.sleep(_TITLE_SETTLE_SECONDS)
- return "B 服登录完成,已确认回到游戏标题页,交给 M9A 进游戏运行"
+ closed_frames = 0
else:
- # 过渡帧(黑屏 / 加载 / OCR 漏字):不提前判成功,继续有界等待
- title_frames = 0
+ closed_frames += 1
+ if closed_frames == _PANEL_CLOSED_FRAMES:
+ await asyncio.sleep(_TITLE_SETTLE_SECONDS)
+ continue
+ if closed_frames > _PANEL_CLOSED_FRAMES:
+ if _title_like(items, frame):
+ height, width = frame.shape[:2]
+ await device.tap_point(
+ width * _START_POINT[0], height * _START_POINT[1]
+ )
+ await _progress(progress, "B 服选号后已点击标题页 START")
+ return
+ if _homepage_uid(items, frame, require_label=True):
+ await _progress(progress, "B 服登录后已进入游戏主页")
+ return
await asyncio.sleep(_POLL_SECONDS)
+def _homepage_uid(
+ items: list[tuple[str, Box]],
+ frame: np.ndarray,
+ *,
+ require_label: bool = False,
+ expected_length: int | None = None,
+) -> str:
+ height, width = frame.shape[:2]
+ matches = [
+ match[2]
+ for text, box in items
+ if (match := re.fullmatch(r"(?:(UID|ID)\s*[::]?\s*)?([0-9]+)", text.strip(), re.I))
+ and (not require_label or match[1])
+ and (expected_length is None or len(match[2]) == expected_length)
+ and 45 <= _center(box)[0] * 1280 / width <= 165
+ and 113 <= _center(box)[1] * 720 / height <= 145
+ ]
+ return matches[0] if len(matches) == 1 else ""
+
+
+async def verify_game_uid(
+ *, user_config: Any, adb_path: str | None, adb_address: str, host_adb: Any = None
+) -> str:
+ """M9A 的 StartUp 完成后,在游戏主页核对 UID。"""
+
+ expected_uid = user_config.Info.GameUID.strip()
+ device = _Device(adb_path, adb_address, host_adb)
+ deadline = time.monotonic() + _LOGIN_PANEL_SECONDS
+ previous_uid = ""
+ while time.monotonic() < deadline:
+ frame, items = await _ocr(device)
+ actual_uid = _homepage_uid(items, frame, expected_length=len(expected_uid))
+ if actual_uid and actual_uid == previous_uid:
+ if actual_uid != expected_uid:
+ raise MaaFWLoginRequired("B 服登录失败:游戏内 UID 与配置不一致,已停止运行")
+ return "B 服登录完成,已核对游戏内 UID,继续运行 M9A 任务"
+ previous_uid = actual_uid
+ await asyncio.sleep(_POLL_SECONDS)
+ raise MaaFWLoginRequired("B 服登录失败:未能确认游戏内 UID,已停止运行")
+
+
async def _logout_to_login_panel(
device: _Device,
frame: np.ndarray,
@@ -649,6 +705,9 @@ async def ensure_logged_in(
raise MaaFWLoginRequired(
"B 服登录失败:账号为空,请在本账号的「账号」里填写登录记录中显示的完整昵称"
)
+ expected_uid = str(getattr(user_config.Info, "GameUID", "") or "").strip()
+ if not re.fullmatch(r"[0-9]+", expected_uid):
+ raise MaaFWLoginRequired("B 服登录失败:请在账号配置中填写游戏内数字 UID")
package = str(package_name or "").strip() or BILIBILI_PACKAGE
if package != BILIBILI_PACKAGE:
raise MaaFWLoginRequired(
@@ -682,6 +741,7 @@ async def ensure_logged_in(
if _privacy_prompt(items):
await _agree_privacy(device, frame, items, progress=progress)
unknown_since = None
+ title_polls = 0
continue
if _warning_like(items):
# 冷启动的健康警告页:点一下继续(M9A 的 StartUp 也会处理,但登录跑在它前面)
@@ -690,18 +750,18 @@ async def ensure_logged_in(
await device.tap_point(width * 0.5, height * 0.5)
await asyncio.sleep(1.0)
unknown_since = None
+ title_polls = 0
continue
if _has(items, _LOGIN_RECORD):
await _select_account(device, frame, items, account, progress=progress)
- return await _wait_panel_closed(device, progress=progress)
+ await _wait_panel_closed(device, progress=progress)
+ return "B 服登录面板已关闭,交给 M9A 启动游戏"
- # 标题页 = 已登录:MAS 不知道当前是哪个号,为了保证切到目标账号,登出后重选。
- # 「START」与「<昵称> 欢迎回来」横幅都算标题页已登录。
- if not logout_tried and (_title_like(items) or _welcome_like(items)):
+ if not logout_tried and _title_like(items, frame):
title_polls += 1
if title_polls >= _TITLE_SETTLE_POLLS:
- logout_tried = True
await _logout_to_login_panel(device, frame, progress=progress)
+ logout_tried = True
unknown_since = None
title_polls = 0
continue
diff --git a/app/task/MaaFW/tools/embedded/runner_task.py b/app/task/MaaFW/tools/embedded/runner_task.py
index 6452afdead..54a99e832c 100644
--- a/app/task/MaaFW/tools/embedded/runner_task.py
+++ b/app/task/MaaFW/tools/embedded/runner_task.py
@@ -2096,7 +2096,8 @@ async def _resolve_window_handle(self, controller: MaaFWController) -> int:
return int(matches[0].hWnd)
async def _run_maafw(self, device_config: MaaFWDeviceConfig) -> MaaFWRunResult:
- if self.run_plan is None:
+ plan = self.run_plan
+ if plan is None:
raise RuntimeError("MaaFW 运行计划尚未初始化")
limit_minutes = self.script_config.Run.RunTimeLimit
timeout = limit_minutes * 60
@@ -2107,15 +2108,45 @@ async def _run_maafw(self, device_config: MaaFWDeviceConfig) -> MaaFWRunResult:
# 截止时刻交给 worker:到点它自己停任务、截图、把已完成的任务带回来。
# 宿主这层只在 worker 没停下时才强杀,那时既没有截图也没有进度。
run_deadline_at = time.time() + timeout
- try:
- return await asyncio.wait_for(
- self._run_maafw_worker(
+ async def run_plan() -> MaaFWRunResult:
+ flavor = resolve_flavor(self.script_config)
+ get_entry = getattr(flavor, "account_verification_entry", None)
+ entry = get_entry(self.script_config) if get_entry is not None else None
+
+ async def run(part: MaaFWRunPlan) -> MaaFWRunResult:
+ return await self._run_maafw_worker(
device_config,
+ plan=part,
run_deadline_at=run_deadline_at,
task_time_limit_seconds=task_limit_seconds,
task_time_limit_overrides=task_limit_overrides,
loop_guard=loop_guard,
- ),
+ )
+
+ if entry is None:
+ return await run(plan)
+ if not plan.tasks or plan.tasks[0].entry != entry:
+ raise MaaFWLoginRequired("无法核对游戏账号:运行计划缺少开头的启动游戏任务")
+ startup = await run(plan.model_copy(update={"tasks": plan.tasks[:1]}))
+ if not startup.success or startup.signal is not None:
+ return startup
+ message = await flavor.verify_game_account(
+ user_config=self.cur_user_config,
+ adb_path=device_config.adbPath,
+ adb_address=device_config.address or "",
+ host_adb=await resolve_host_adb(self),
+ )
+ self._append_log(message)
+ if len(plan.tasks) == 1:
+ return startup
+ result = await run(plan.model_copy(update={"tasks": plan.tasks[1:]}))
+ return result.model_copy(
+ update={"completedTasks": startup.completedTasks + result.completedTasks}
+ )
+
+ try:
+ return await asyncio.wait_for(
+ run_plan(),
timeout=timeout + _RUN_DEADLINE_GRACE_SECONDS,
)
except asyncio.TimeoutError as exc:
@@ -2131,12 +2162,14 @@ async def _run_maafw_worker(
self,
device_config: MaaFWDeviceConfig,
*,
+ plan: MaaFWRunPlan | None = None,
run_deadline_at: float | None = None,
task_time_limit_seconds: int = 0,
task_time_limit_overrides: dict[str, int] | None = None,
loop_guard: bool = False,
) -> MaaFWRunResult:
- if self.run_plan is None:
+ plan = self.run_plan if plan is None else plan
+ if plan is None:
raise RuntimeError("MaaFW 运行计划尚未初始化")
service = MaaFWRunnerService()
@@ -2234,9 +2267,9 @@ def _prepare_environment_with_proxy() -> MaaFWRunnerEnvironment:
event_server_failure: str | None = None
try:
self._check_avd_runtime_version(runner_environment.maafw_version)
- runner_plan = self.run_plan
+ runner_plan = plan
if runner_environment.maafw_version:
- runner_plan = self.run_plan.model_copy(deep=True)
+ runner_plan = plan.model_copy(deep=True)
runner_plan.piEnv["PI_CLIENT_MAAFW_VERSION"] = (
f"v{runner_environment.maafw_version.lstrip('v')}"
)
diff --git a/changelog.d/fix-m9a-bili-nickname.fix.md b/changelog.d/fix-m9a-bili-nickname.fix.md
new file mode 100644
index 0000000000..419579f773
--- /dev/null
+++ b/changelog.d/fix-m9a-bili-nickname.fix.md
@@ -0,0 +1,4 @@
+project: m9a
+beta-only: true
+
+修复 B 服特殊昵称选号失败,并在代理前核对游戏 UID。
diff --git a/frontend/src/api/models/M9AUserConfig_Info.ts b/frontend/src/api/models/M9AUserConfig_Info.ts
index c4877b3973..744731c360 100644
--- a/frontend/src/api/models/M9AUserConfig_Info.ts
+++ b/frontend/src/api/models/M9AUserConfig_Info.ts
@@ -63,5 +63,9 @@ export type M9AUserConfig_Info = {
* 账号标签信息
*/
readonly Tag?: (string | null);
+ /**
+ * 游戏内数字 UID,用于 MAS 登录 B 服后核对账号
+ */
+ GameUID?: (string | null);
};
diff --git a/frontend/src/api/models/M9AUserConfig_InfoInput.ts b/frontend/src/api/models/M9AUserConfig_InfoInput.ts
index 750671c95b..28dcdfed50 100644
--- a/frontend/src/api/models/M9AUserConfig_InfoInput.ts
+++ b/frontend/src/api/models/M9AUserConfig_InfoInput.ts
@@ -63,5 +63,9 @@ export type M9AUserConfig_InfoInput = {
* 账号标签信息
*/
readonly Tag?: (string | null);
+ /**
+ * 游戏内数字 UID,用于 MAS 登录 B 服后核对账号
+ */
+ GameUID?: (string | null);
};
diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts
index 402afe8f13..884bcbb0c8 100644
--- a/frontend/src/types/script.ts
+++ b/frontend/src/types/script.ts
@@ -311,6 +311,8 @@ export interface MaaFWUserConfig {
Tag?: string | null
Account: string
Password: string
+ /** M9A 游戏内 UID,供 MAS 登录 B 服后核对。 */
+ GameUID?: string
Resource?: string
Base?: 'shared' | 'independent' | 'native'
/** 快速配置:独立于配置来源的用户级开关 */
diff --git a/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts b/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts
index 63ca0784b6..385fda7bbe 100644
--- a/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts
+++ b/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts
@@ -213,6 +213,7 @@ export interface MaaFWUserBasicInfoSectionProps {
accountRecordTooltip: string
/** 账号字段占位:特调类型可能把账号绑成切号任务,文案不再是「仅本地记录」 */
accountPlaceholder?: string
+ showGameUid?: boolean
}
export interface MaaFWUserBasicInfoSectionEmits {
diff --git a/frontend/src/views/EditView/User/MaaFWUserEdit.vue b/frontend/src/views/EditView/User/MaaFWUserEdit.vue
index 6605d2d6a3..eaa98f6ef3 100644
--- a/frontend/src/views/EditView/User/MaaFWUserEdit.vue
+++ b/frontend/src/views/EditView/User/MaaFWUserEdit.vue
@@ -36,6 +36,7 @@
:interface-dependent-disabled="interfaceDependentDisabled"
:account-record-tooltip="accountRecordTooltip"
:account-placeholder="t(flavor.userPage.text.accountPlaceholderKey)"
+ :show-game-uid="flavor.type === 'M9A'"
@save="handleFieldSave"
/>
diff --git a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
index de8188b6af..f44a7f0dc9 100644
--- a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
+++ b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
@@ -47,6 +47,18 @@
/>
+
+
+
+
+
diff --git a/res/i18n/en.json b/res/i18n/en.json
index 338106cc38..d62fb846e9 100644
--- a/res/i18n/en.json
+++ b/res/i18n/en.json
@@ -1370,10 +1370,11 @@
"core.edit.m9aFlavorAccountTooltip": "With “M9A project login”, filling the account adds a “Switch account” task automatically (CN official server only); with “MAS login for the Bilibili server” the account is the exact nickname shown in the login records, used only to pick the target account and never added as a “Switch account” task. The password is a local note only and is never passed to the script",
"core.edit.m9aFlavorGameUpdateHint": "After the emulator starts, compare the game client with the latest version on the official site. Official server only (Bilibili and other resources are not checked). When outdated: \"Check only\" fails this run and asks you to update manually; \"Download and install automatically\" downloads the official package (about 2 GB) and installs it over the old client, keeping game data",
"core.edit.m9aFlavorLoginMode": "Login mode",
- "core.edit.m9aFlavorLoginModeHint": "\"M9A project login\": the M9A startup task logs in by itself (the upstream \"Switch account\" task still only declares the CN official server). \"MAS login for the Bilibili server\": MAS performs the login — handles the privacy policy prompt, picks a saved account in the login records, and hands the title screen back to the M9A startup task, which enters the game and runs as usual. MAS login requires the ADB controller, the Bilibili package in \"Game package\" (com.shenlan.m.reverse1999.bilibili) and the exact nickname shown in the login records as the account; a \"Switch account\" task is no longer added automatically. Automatic agreement to the privacy prompt reuses the global \"Agree to the Bilibili user agreement\" switch (off by default); when the switch is off the login stops and asks for manual handling",
+ "core.edit.m9aFlavorLoginModeHint": "\"M9A project login\": the M9A startup task logs in by itself (the upstream \"Switch account\" task still only declares the CN official server). \"MAS login for the Bilibili server\": MAS performs the login — handles the privacy policy prompt, picks a saved account in the login records, enters the game, and hands control to M9A after verifying the configured UID. MAS login requires the ADB controller, the Bilibili package in \"Game package\" (com.shenlan.m.reverse1999.bilibili) and the exact nickname shown in the login records as the account; a \"Switch account\" task is no longer added automatically. Automatic agreement to the privacy prompt reuses the global \"Agree to the Bilibili user agreement\" switch (off by default); when the switch is off the login stops and asks for manual handling",
"core.edit.m9aFlavorManagedTaskNotice": "\"{tasks}\" are added by MAS from the info above and do not need to stay in the queue; runs use the fixed order anyway. They are removed the next time the queue is saved or AUTO-MAS restarts (a switch-account target goes into \"Account\" above)",
"core.edit.m9aFlavorManagedTaskWarning": "This queue has {count} \"Switch account\" tasks (accounts {accounts}). In M9A one account is one game account: split into {count} accounts (fill one account into \"Account\" above for each), then delete these tasks from the queue. This account will not run until then",
"core.edit.m9aFlavorQueueHint": "Start game, switch account and close game are added by MAS from \"Account\" above (start first, switch right after, close last); they are not offered in \"Add task\" or presets",
+ "core.edit.m9aGameUidPlaceholder": "Enter the in-game numeric UID (required for MAS Bilibili login)",
"core.edit.m9aLoginModeMas": "MAS login for the Bilibili server",
"core.edit.m9aLoginModeProject": "M9A project login",
"core.edit.m9aFlavorScriptTitle": "Edit M9A managed script",
diff --git a/res/i18n/ja.json b/res/i18n/ja.json
index 90b83802ef..c0875298f8 100644
--- a/res/i18n/ja.json
+++ b/res/i18n/ja.json
@@ -1370,10 +1370,11 @@
"core.edit.m9aFlavorAccountTooltip": "「M9A プロジェクトでログイン」ではアカウントを入力すると「アカウント切替」タスクが自動で追加されます(公式サーバーのみ)。「MAS で bilibili サーバーにログイン」ではアカウントはログイン記録に表示される完全なニックネームで、対象アカウントの選択にのみ使われ、「アカウント切替」は追加されません。パスワードはローカルのメモ用で、スクリプトには渡されません",
"core.edit.m9aFlavorGameUpdateHint": "エミュレーター起動後、ゲームクライアントを公式サイトの最新版と比較します。公式サーバーのみ対象です(bilibili サーバーなど他のリソースは確認しません)。古い場合:「確認のみ」は今回の実行を失敗にして手動更新を案内し、「自動でダウンロードしてインストール」は約 2 GB の公式インストーラーをダウンロードして上書きインストールします(ゲームデータは保持されます)",
"core.edit.m9aFlavorLoginMode": "ログイン方法",
- "core.edit.m9aFlavorLoginModeHint": "「M9A プロジェクトでログイン」:M9A の起動タスク自身がログインします(上流の「アカウント切替」は現在も公式サーバーのみ宣言)。「MAS で bilibili サーバーにログイン」:MAS がログインを実行します——プライバシーポリシーの案内を処理し、ログイン記録から保存済みのアカウントを選択し、タイトル画面に戻ったら M9A の起動タスクに引き継いでゲームに入り、通常どおり実行します。MAS ログインには ADB コントローラー、「ゲームパッケージ」に bilibili パッケージ(com.shenlan.m.reverse1999.bilibili)、アカウントにログイン記録に表示される完全なニックネームが必要です。この場合「アカウント切替」は自動で追加されません。プライバシー案内の自動同意は全体設定の「bilibili ユーザー契約に同意」スイッチ(既定はオフ)を再利用し、オフのときはログインを停止して手動対応を案内します",
+ "core.edit.m9aFlavorLoginModeHint": "「M9A プロジェクトでログイン」:M9A の起動タスク自身がログインします(上流の「アカウント切替」は現在も公式サーバーのみ宣言)。「MAS で bilibili サーバーにログイン」:MAS がログインを実行します——プライバシーポリシーの案内を処理し、ログイン記録から保存済みのアカウントを選択し、ゲームに入り、設定された UID を確認してから M9A に引き継ぎます。MAS ログインには ADB コントローラー、「ゲームパッケージ」に bilibili パッケージ(com.shenlan.m.reverse1999.bilibili)、アカウントにログイン記録に表示される完全なニックネームが必要です。この場合「アカウント切替」は自動で追加されません。プライバシー案内の自動同意は全体設定の「bilibili ユーザー契約に同意」スイッチ(既定はオフ)を再利用し、オフのときはログインを停止して手動対応を案内します",
"core.edit.m9aFlavorManagedTaskNotice": "「{tasks}」は上の情報をもとに MAS が自動で追加するため、キューに残す必要はありません(実行時も固定の順序で実行されます)。次にタスクキューを保存するか AUTO-MAS を再起動するとキューから外れます(アカウント切替の対象アカウントは上の「アカウント」に入ります)",
"core.edit.m9aFlavorManagedTaskWarning": "このキューには「アカウント切替」が {count} 件あります(アカウント {accounts})。M9A では 1 アカウント = 1 ゲームアカウントです。{count} 個のアカウントに分けて(それぞれ上の「アカウント」に 1 つずつ入力)、これらのタスクをキューから削除してください。分けるまでこのアカウントは実行されません",
"core.edit.m9aFlavorQueueHint": "ゲーム起動・アカウント切替・ゲーム終了は上の「アカウント」をもとに MAS が自動で追加します(起動が先頭、切替はその直後、終了は最後)。「タスクを追加」やプリセットには表示されません",
+ "core.edit.m9aGameUidPlaceholder": "ゲーム内の数字 UID を入力(MAS の bilibili ログイン時は必須)",
"core.edit.m9aLoginModeMas": "MAS で bilibili サーバーにログイン",
"core.edit.m9aLoginModeProject": "M9A プロジェクトでログイン",
"core.edit.m9aFlavorScriptTitle": "M9A マネージドスクリプトを編集",
diff --git a/res/i18n/zh_CN.json b/res/i18n/zh_CN.json
index f0506be000..bbd1a47578 100644
--- a/res/i18n/zh_CN.json
+++ b/res/i18n/zh_CN.json
@@ -1370,10 +1370,11 @@
"core.edit.m9aFlavorAccountTooltip": "「由 M9A 登录」时账号填写后会自动加入「切换账号」任务(官服有效);「由 MAS 登录 B 服」时账号是登录记录里显示的完整昵称,只用于选中目标账号,不会加入「切换账号」。密码仅用于本地记录,不会传入脚本",
"core.edit.m9aFlavorGameUpdateHint": "启动模拟器后比对游戏客户端与官网最新版本,只对官服生效(B 服等其他资源不检查)。落后时:「仅检查」让本次运行失败并提示手动更新;「自动下载安装」下载约 2 GB 的官方安装包并覆盖安装,保留游戏数据",
"core.edit.m9aFlavorLoginMode": "登录方式",
- "core.edit.m9aFlavorLoginModeHint": "「由 M9A 登录」:由 M9A 的启动任务自己登录(上游「切换账号」目前只声明官服)。「由 MAS 登录 B 服」:MAS 完成 B 服登录——处理隐私政策提示、在登录记录里选择已保存的账号,回到标题页后交给 M9A 的启动任务进游戏并照常运行。选 MAS 登录需要 ADB 控制方式,并在「游戏包名」填写 B 服包名(com.shenlan.m.reverse1999.bilibili)、在账号里填登录记录中显示的完整昵称;此时不再自动加入「切换账号」。自动同意隐私提示复用全局「同意哔哩哔哩用户协议」开关(默认关闭),未开启时登录会停下并提示手动处理",
+ "core.edit.m9aFlavorLoginModeHint": "「由 M9A 登录」:由 M9A 的启动任务自己登录(上游「切换账号」目前只声明官服)。「由 MAS 登录 B 服」:MAS 完成 B 服登录——处理隐私政策提示、在登录记录里选择已保存的账号,进入游戏并核对账号配置中的 UID 后,交给 M9A 运行。选 MAS 登录需要 ADB 控制方式,并在「游戏包名」填写 B 服包名(com.shenlan.m.reverse1999.bilibili)、在账号里填登录记录中显示的完整昵称;此时不再自动加入「切换账号」。自动同意隐私提示复用全局「同意哔哩哔哩用户协议」开关(默认关闭),未开启时登录会停下并提示手动处理",
"core.edit.m9aFlavorManagedTaskNotice": "「{tasks}」由 MAS 按上方信息自动加入,不需要留在队列里,运行时也会按固定顺序执行;下次保存任务队列或重启 AUTO-MAS 时会移出队列(切换账号的目标账号会填进上方「账号」)",
"core.edit.m9aFlavorManagedTaskWarning": "该账号队列里有 {count} 个切换账号(账号 {accounts}),M9A 一个账号对应一个游戏账号:请拆成 {count} 个账号(每个账号在上方「账号」填一个),再把这些切换账号从队列里删掉;拆分前该账号不会运行",
"core.edit.m9aFlavorQueueHint": "启动游戏、切换账号、关闭游戏由 MAS 按上方「账号」自动加入(启动在前、切号紧跟、关闭在最后),无需手动添加,「添加任务」与预设里也不提供",
+ "core.edit.m9aGameUidPlaceholder": "填写游戏内数字 UID(MAS 登录 B 服时必填)",
"core.edit.m9aLoginModeMas": "由 MAS 登录 B 服",
"core.edit.m9aLoginModeProject": "由 M9A 登录",
"core.edit.m9aFlavorScriptTitle": "编辑 M9A 托管",
diff --git a/tests/task/test_m9a_login_flow.py b/tests/task/test_m9a_login_flow.py
index d7c7fdd11a..fdbe17b88b 100644
--- a/tests/task/test_m9a_login_flow.py
+++ b/tests/task/test_m9a_login_flow.py
@@ -24,10 +24,10 @@
1. 既认不出登录记录、也认不出标题页(例如停在别的账号的主界面)→ 明确失败,
不能把"没认出登录页"当成目标账号已登录;
2. 隐私政策提示 + 全局「同意哔哩哔哩用户协议」关闭 → 不点同意、失败并提示手动处理;
-3. 登录后只出现空帧 / 面板帧 → 不提前判成功(有界等待后失败);
-4. 登录后连续两帧标题页 → 成功;
+3. 登录面板持续出现时,等待超时后失败;
+4. 登录后进入游戏,连续两帧读到匹配 UID → 成功;
5. 引擎侧:首次登录失败后重试必须重新调用登录钩子;客户端被重启后清掉「已登录」标记;
-6. 标题页信号:完整「REVERSE」、残词 `ERSE`、欢迎横幅都算,`PLAYERS` 这类词不算;
+6. 标题页信号:完整「REVERSE」、残词 `ERSE`算,`PLAYERS` 这类词不算;
7. 冷启动加载超过固定预算时服从 `Game.WaitTime`,不提前判「无法确认账号」;
8. `Mas` 模式只接受 B 服资源与 B 服包名,配错客户端在碰模拟器之前就报错;
9. 宿主推不出 ADB 路径时按地址用 MaaFW Toolkit 补上,钩子不再退回 PATH 的 `adb`;
@@ -71,12 +71,7 @@
("START", (45, 50, 10, 10)),
("V4.1.0", (5, 2, 20, 5)),
]
-#: 登录成功后的标题页:START 被「<昵称> 欢迎回来」横幅盖住 / 淡化,只剩横幅可读
-_WELCOME_SCREEN = [
- ("示例账号 欢迎回来", (20, 4, 40, 8)),
- ("切换账号", (80, 4, 10, 8)),
-]
-#: 认不出的画面(主界面截图里没有 START / 登录记录)
+#: 无法识别为登录面板或标题页的画面。
_UNKNOWN_SCREEN = [
("邮件", (10, 10, 10, 10)),
("任务", (30, 10, 10, 10)),
@@ -117,6 +112,7 @@ async def tap_point(self, x: float, y: float) -> None:
class _Info:
def __init__(self, account: str) -> None:
self.Account = account
+ self.GameUID = "104939362"
self.Notes = ""
@@ -182,6 +178,31 @@ def test_unknown_screen_stops_instead_of_assuming_logged_in(monkeypatch) -> None
asyncio.run(_run_login())
+@pytest.mark.parametrize("ambiguous", [False, True])
+def test_login_handles_nickname_iteration_mark(monkeypatch, ambiguous):
+ """实机把「ゞ」识别为 >;唯一昵称可登录,归一化后重名必须停止。"""
+ _fast(monkeypatch)
+ monkeypatch.setattr(login_mod, "_LOGIN_TIMEOUT", 5)
+ panel = [
+ ("测试>账号" if text == "示例账号" else text, box)
+ for text, box in _PANEL_SCREEN
+ ]
+ expanded = [(text, box) for text, box in panel if text != "切换其他账号"]
+ if ambiguous:
+ expanded.append(("测试ゞ账号", (45, 60, 10, 10)))
+ device = _install_fake_device(
+ monkeypatch, [panel, expanded, panel, _TITLE_SCREEN, _TITLE_SCREEN, _TITLE_SCREEN]
+ )
+ if ambiguous:
+ with pytest.raises(MaaFWLoginRequired, match="多个同名账号"):
+ asyncio.run(_run_login("测试ゞ账号"))
+ assert device.taps == [pytest.approx((63.5, 41.3))]
+ else:
+ asyncio.run(_run_login("测试ゞ账号"))
+ assert (45, 55, 10, 10) in device.taps
+ assert device.taps[-1] == (50.0, 74.5)
+
+
def test_privacy_prompt_requires_agreement_switch(monkeypatch) -> None:
_fast(monkeypatch)
_install_fake_device(monkeypatch, [_PRIVACY_SCREEN])
@@ -204,26 +225,77 @@ async def fake_ocr(_device):
assert device.taps == [(55, 70, 10, 10)]
-def test_empty_frames_do_not_declare_success(monkeypatch) -> None:
+def test_visible_login_panel_blocks_startup(monkeypatch) -> None:
_fast(monkeypatch)
- device = _install_fake_device(monkeypatch, [_PANEL_SCREEN, _UNKNOWN_SCREEN])
- with pytest.raises(MaaFWLoginRequired, match="未确认回到游戏标题页"):
+ device = _install_fake_device(monkeypatch, [_PANEL_SCREEN])
+ with pytest.raises(MaaFWLoginRequired, match="登录面板未关闭"):
asyncio.run(login_mod._wait_panel_closed(device, progress=None))
def test_title_frames_confirm_handover(monkeypatch) -> None:
_fast(monkeypatch)
device = _install_fake_device(
- monkeypatch, [_PANEL_SCREEN, _TITLE_SCREEN, _TITLE_SCREEN]
+ monkeypatch, [_PANEL_SCREEN, [], [], [], _TITLE_SCREEN]
)
- message = asyncio.run(login_mod._wait_panel_closed(device, progress=None))
- assert "已确认回到游戏标题页" in message
+ asyncio.run(login_mod._wait_panel_closed(device, progress=None))
+ assert device.taps == [(50.0, 74.5)]
class _Plan:
resourceName = "B 服"
+@pytest.mark.parametrize("uid_matches", [True, False])
+def test_m9a_starts_game_before_allowing_daily_tasks(monkeypatch, uid_matches):
+ from types import SimpleNamespace
+
+ from app.task.M9A.flavor import FLAVOR
+ from app.task.MaaFW.tools.core.runner.models import MaaFWDeviceConfig, MaaFWRunResult
+
+ runner = _fake_runner()
+ runner.script_config = SimpleNamespace(
+ Run=SimpleNamespace(
+ LoginMode="Mas", RunTimeLimit=1, TaskTimeLimit=0, TaskTimeLimitOverrides="{}"
+ )
+ )
+ plan = runner_task.MaaFWRunPlan.model_construct(
+ tasks=[SimpleNamespace(entry="StartUp"), SimpleNamespace(entry="Daily")]
+ )
+ runner.run_plan = plan
+ calls = []
+
+ async def worker(device, *, plan, **kwargs):
+ entries = [task.entry for task in plan.tasks]
+ calls.extend(entries)
+ return MaaFWRunResult(
+ success=True, projectName="M9A", controllerName="ADB",
+ resourceName="B 服", completedTasks=entries,
+ )
+
+ async def verify(self, **kwargs):
+ calls.append("verify UID")
+ if not uid_matches:
+ raise MaaFWLoginRequired("UID 与配置不一致")
+ return "UID 已核对"
+
+ async def host_adb(task):
+ return None
+
+ monkeypatch.setattr(runner_task, "resolve_flavor", lambda config: FLAVOR)
+ monkeypatch.setattr(runner_task, "resolve_host_adb", host_adb)
+ monkeypatch.setattr(type(FLAVOR), "verify_game_account", verify)
+ monkeypatch.setattr(runner, "_run_maafw_worker", worker)
+ device = MaaFWDeviceConfig(type="Adb")
+ if uid_matches:
+ result = asyncio.run(runner._run_maafw(device))
+ assert result.completedTasks == ["StartUp", "Daily"]
+ assert calls == ["StartUp", "verify UID", "Daily"]
+ else:
+ with pytest.raises(MaaFWLoginRequired, match="UID 与配置不一致"):
+ asyncio.run(runner._run_maafw(device))
+ assert calls == ["StartUp", "verify UID"]
+
+
def _fake_runner() -> runner_task.MaaFWPluginAutoProxyTask:
runner = object.__new__(runner_task.MaaFWPluginAutoProxyTask)
runner._login_checked = False
@@ -297,49 +369,58 @@ def test_privacy_prompt_persists_until_timeout(monkeypatch) -> None:
assert device.taps
-def test_alternating_title_and_empty_frames_do_not_succeed(monkeypatch) -> None:
- """标题页与空帧交替时凑不出连续两帧,不能提前判完成。"""
- _fast(monkeypatch)
- device = _install_fake_device(
- monkeypatch, [_TITLE_SCREEN, _UNKNOWN_SCREEN, _TITLE_SCREEN, _UNKNOWN_SCREEN]
+@pytest.mark.parametrize("text", ["PLAYERS", "OTHERS", "ERS", "RESTART"])
+def test_unrelated_words_are_not_title(text):
+ assert not login_mod._title_like(
+ [(text, (0, 0, 10, 10))], np.zeros((100, 100, 3), dtype=np.uint8)
)
- with pytest.raises(MaaFWLoginRequired, match="未确认回到游戏标题页"):
- asyncio.run(login_mod._wait_panel_closed(device, progress=None))
-@pytest.mark.parametrize("text", ["PLAYERS", "OTHERS", "ERS", "RESTART"])
-def test_unrelated_words_are_not_title(text):
- assert not login_mod._title_like([(text, (0, 0, 10, 10))])
+def test_title_logout_requires_confirmation(monkeypatch):
+ _fast(monkeypatch)
+ monkeypatch.setattr(login_mod, "_LOGOUT_DIALOG_SECONDS", 0.1)
+ device = _install_fake_device(monkeypatch, [_TITLE_SCREEN] * 3 + [_UNKNOWN_SCREEN])
+ with pytest.raises(MaaFWLoginRequired, match="登出确认按钮未出现"):
+ asyncio.run(_run_login())
+ assert device.taps == [pytest.approx((96.3, 91.2))]
-def test_login_selects_target_after_logging_out(monkeypatch):
+@pytest.mark.parametrize(
+ ("version", "rating"),
+ [("74.1.0-113.0.1421-180", "适龄提示"), ("J4.1.0-113.0.1421-180", "CADPA")],
+)
+def test_login_selects_target_after_logging_out(monkeypatch, version, rating):
_fast(monkeypatch)
+ # 实机标题页 OCR 漏掉 START / REVERSE,只读到了版本号和适龄标识。
+ title = [(version, (5, 3, 14, 2)), (rating, (4, 89, 3, 2))]
expanded = [(text, box) for text, box in _PANEL_SCREEN if text != "切换其他账号"]
device = _install_fake_device(
monkeypatch,
[
- _TITLE_SCREEN,
- _TITLE_SCREEN,
- _TITLE_SCREEN,
+ title,
+ title,
+ title,
[("确认", (40, 50, 10, 10))],
_PANEL_SCREEN,
_PANEL_SCREEN,
expanded,
_PANEL_SCREEN,
- _TITLE_SCREEN,
- _TITLE_SCREEN,
+ title,
+ title,
+ title,
],
)
# The real selection flow includes two one-second UI waits.
monkeypatch.setattr(login_mod, "_LOGIN_TIMEOUT", 5)
message = asyncio.run(_run_login())
- assert "已确认回到游戏标题页" in message
+ assert "交给 M9A 启动游戏" in message
assert device.taps == [
- (95.6, 91.0),
+ pytest.approx((96.3, 91.2)),
(40, 50, 10, 10),
- (60.8, 43.0),
+ pytest.approx((63.5, 41.3)),
(45, 45, 10, 10),
(45, 55, 10, 10),
+ (50.0, 74.5),
]
@@ -409,14 +490,20 @@ def test_win32_device_build_blocks_mas_login_before_window_access(monkeypatch):
assert not runner._login_checked
-def test_welcome_banner_confirms_handover(monkeypatch) -> None:
- """START 被「<昵称> 欢迎回来」横幅盖住时,横幅本身也算标题页的正向信号。"""
+@pytest.mark.parametrize("actual_uid", ["104939362", "104939363"])
+def test_verify_uid_after_startup(monkeypatch, actual_uid) -> None:
_fast(monkeypatch)
- device = _install_fake_device(
- monkeypatch, [_PANEL_SCREEN, _WELCOME_SCREEN, _WELCOME_SCREEN]
+ screen = [(f"ID: {actual_uid}", (5, 16, 7, 4))]
+ device = _install_fake_device(monkeypatch, [screen, screen])
+ verify = login_mod.verify_game_uid(
+ user_config=_User("示例账号"), adb_path=None, adb_address="127.0.0.1:1"
)
- message = asyncio.run(login_mod._wait_panel_closed(device, progress=None))
- assert "已确认回到游戏标题页" in message
+ if actual_uid == "104939362":
+ assert "已核对游戏内 UID" in asyncio.run(verify)
+ else:
+ with pytest.raises(MaaFWLoginRequired, match="UID 与配置不一致"):
+ asyncio.run(verify)
+ assert not device.taps
def test_health_warning_is_dismissed_before_login(monkeypatch) -> None:
@@ -433,10 +520,11 @@ def test_health_warning_is_dismissed_before_login(monkeypatch) -> None:
_PANEL_SCREEN,
_TITLE_SCREEN,
_TITLE_SCREEN,
+ _TITLE_SCREEN,
],
)
message = asyncio.run(_run_login())
- assert "已确认回到游戏标题页" in message
+ assert "交给 M9A 启动游戏" in message
# 第一下点击是警告页中心(100x100 画布 → 50,50)
assert device.taps[0] == (50.0, 50.0)
@@ -446,8 +534,7 @@ def test_title_logo_fragment_confirms_handover(monkeypatch) -> None:
_fast(monkeypatch)
ers = [("ERSE", (55, 60, 15, 4)), ("V4.1.0-113.0.1421-180", (5, 2, 20, 5))]
device = _install_fake_device(monkeypatch, [_PANEL_SCREEN, ers, ers])
- message = asyncio.run(login_mod._wait_panel_closed(device, progress=None))
- assert "已确认回到游戏标题页" in message
+ asyncio.run(login_mod._wait_panel_closed(device, progress=None))
def test_title_logo_full_word_confirms_handover(monkeypatch) -> None:
@@ -455,8 +542,7 @@ def test_title_logo_full_word_confirms_handover(monkeypatch) -> None:
_fast(monkeypatch)
reverse = [("REVERSE", (55, 60, 15, 4)), ("V4.1.0-113.0.1421-180", (5, 2, 20, 5))]
device = _install_fake_device(monkeypatch, [_PANEL_SCREEN, reverse, reverse])
- message = asyncio.run(login_mod._wait_panel_closed(device, progress=None))
- assert "已确认回到游戏标题页" in message
+ asyncio.run(login_mod._wait_panel_closed(device, progress=None))
def test_unknown_screen_budget_follows_game_wait_time(monkeypatch) -> None:
@@ -501,6 +587,7 @@ def test_slow_loading_is_not_cut_off_by_fixed_budget(monkeypatch) -> None:
_TITLE_SCREEN,
_TITLE_SCREEN,
_TITLE_SCREEN,
+ _TITLE_SCREEN,
[("确认", (40, 50, 10, 10))],
_PANEL_SCREEN,
_PANEL_SCREEN,
@@ -508,11 +595,12 @@ def test_slow_loading_is_not_cut_off_by_fixed_budget(monkeypatch) -> None:
_PANEL_SCREEN,
_TITLE_SCREEN,
_TITLE_SCREEN,
+ _TITLE_SCREEN,
],
)
# 固定 0.2 秒预算会先报「无法确认当前账号」;服从 WaitTime 后能等到 0.6 秒后的登录记录。
message = asyncio.run(_run_login())
- assert "已确认回到游戏标题页" in message
+ assert "交给 M9A 启动游戏" in message
assert device.taps
From dd6b9a31259b165c200324e4f82eaf5ad63ad682 Mon Sep 17 00:00:00 2001
From: Douya36 <274382944+Douya36@users.noreply.github.com>
Date: Fri, 13 Nov 2026 23:36:16 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(m9a):=20=E6=94=B6=E6=95=9B=20B=20?=
=?UTF-8?q?=E6=9C=8D=E7=99=BB=E5=BD=95=E5=92=8C=20UID=20=E6=A0=A1=E9=AA=8C?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/task/M9A/AGENTS.md | 14 +-
app/task/M9A/flavor.py | 34 ++--
app/task/M9A/login.py | 163 +++++++++++++++---
app/task/M9A/login_assets/MainMenuEntry.png | Bin 0 -> 3667 bytes
app/task/M9A/login_assets/MenuRight.png | Bin 0 -> 1021 bytes
app/task/MaaFW/AGENTS.md | 7 +
app/task/MaaFW/tools/core/runner/models.py | 2 +
app/task/MaaFW/tools/core/runner/runner.py | 8 +-
app/task/MaaFW/tools/embedded/flavor.py | 17 ++
app/task/MaaFW/tools/embedded/runner_task.py | 71 +++-----
changelog.d/fix-m9a-bili-nickname.fix.md | 2 +-
.../User/MaaFWUserEdit/BasicInfoSection.vue | 20 ++-
res/i18n/en.json | 1 +
res/i18n/ja.json | 1 +
res/i18n/zh_CN.json | 1 +
tests/task/test_m9a_login_flow.py | 160 ++++++++++-------
tests/task/test_m9a_login_mode.py | 2 +-
tests/task/test_maafw_run_record.py | 42 +++++
18 files changed, 373 insertions(+), 172 deletions(-)
create mode 100644 app/task/M9A/login_assets/MainMenuEntry.png
create mode 100644 app/task/M9A/login_assets/MenuRight.png
diff --git a/app/task/M9A/AGENTS.md b/app/task/M9A/AGENTS.md
index 1d774547fa..7d5123bbb9 100644
--- a/app/task/M9A/AGENTS.md
+++ b/app/task/M9A/AGENTS.md
@@ -25,7 +25,7 @@
任一命中即认领——导入完成后引擎据此把托管类型定成 `M9AConfig`(不命中的项目导进 M9A 脚本会变回
`MaaFWConfig`,类型由项目决定、双向自动、uid 不变)。
2. `decorate_selection(...)`:运行前先把勾选列表里三类受管任务**全部剔掉**(连同它们的选项),再按
- 固定顺序补:`StartUp` 放队首;脚本资源支持切号且有账号时 `SwitchAccount` 紧跟启动、账号填进它
+ 固定顺序补:项目登录时 `StartUp` 放队首(MAS 登录 B 服时由登录钩子完成启动);脚本资源支持切号且有账号时 `SwitchAccount` 紧跟启动、账号填进它
第一个 input 选项;`Close1999` 放队尾。用户排的顺序不影响这三项。账号取 `Info.Account`,但队列里
恰好残留一个有效切号时取它(与启动整理的结果一致)。支持切号且有效切号 ≥ 2 时写账号日志并抛
`M9ASplitRequiredError`——引擎把它当成运行计划构建失败,该账号记「异常」、发错误通知、不跑,其它
@@ -67,15 +67,17 @@
- `login.py`:`Run.LoginMode == "Mas"` 时的 B 服原生登录(`ensure_logged_in` 钩子)。MAS 启动模拟器
(连带拉起游戏)之后、第一个任务之前,用 `adb` 截图 + `app.tools.common.ocr` 识别哔哩哔哩登录面板:
点「隐私政策提示」里唯一的「同意」,在「登录记录」里按 `Info.Account`(登录记录里显示的完整昵称)
- 精确匹配、唯一命中后选中并点「登录」。登录面板退出后先运行 M9A 的 `StartUp`,
- 连续两帧识别到的游戏内 UID 与 `Info.GameUID` 一致才运行剩余任务,缺填、识别超时或不匹配均停止。
- 标题页使用 START(或 TART)、REVERSE(或 ERSE)识别。
+ 含汉字的昵称提取汉字后完整匹配,无汉字的昵称按 OCR 原文精确匹配,唯一命中才选中并点「登录」。
+ 登录面板退出后由 MAS 在标题页点 START,连续两帧识别到的 UID 与 `Info.GameUID` 一致才投递任务。
+ 如果一开始就在主页,UID 一致直接继续,确认不符时经设置 → 账号 → 退出 → 返回登录,然后重新选号。
+ 主页退出导航及 `login_assets/` 的两个模板来自 MAA1999/M9A(AGPL-3.0,原始提交
+ [e03c1c6](https://github.com/MAA1999/M9A/tree/e03c1c60e51bff35c909aad96c58fde58a117946/resource/base/image/SwitchAccount))。
游戏已登录停在标题页时,
MAS 不知道当前是哪个号,为保证切到目标账号,先点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」
出现再选号。`adb` 子进程走 `app/utils/common/process/runner.py` 的受管入口:任务被停止时命令随作用域
回收,截图分支保留 PNG 原始 bytes(不能按文本解码)。
**边界**:自动同意只复用全局 `Config.setting.Function.IfAgreeBilibili`(默认关),关着时不点、
- 提示手动处理并停止;既认不出登录记录也认不出标题页(例如停在别的账号的主界面)时明确停止,
+ 提示手动处理并停止;既认不出登录记录也认不出标题页且无法识别主页 UID(例如加载中)时明确停止,
不把"没认出登录页"当成功;这段等待的预算取 `Game.WaitTime`(冷启动加载画面同样认不出,不能按
固定 60 秒截断慢启动的客户端),总时长仍受登录主循环 240 秒约束;所有循环先查总时限,
`adb input tap` 失败直接抛错。
@@ -86,7 +88,7 @@
没填时按这个包名拉起)。脚本资源不是「B 服」或实际要启动的包名不是 B 服包时直接报错,
不拿错客户端去走哔哩哔哩登录面板。`Run.LoginMode` 默认 `Project`(M9A 项目自己登录),只在 M9A 脚本有。
这是**临时补位**:上游 M9A 的 interface 已声明 B 服资源(任务运行可用),但「切换账号」仍只声明
- 官服、没有按 B 站昵称选号的入口;上游补齐等价入口后删除本模块的选号逻辑(立项时未单独向上游提
+ 官服、没有按 B 站昵称选号的入口;上游补齐等价入口后移除这里的 B 服退出、选号及启动补位(立项时未单独向上游提
PR:本实现属于 MAS 侧运行前编排,上游入口补齐前无法复用)。
- `migration.py`:旧版 M9A 专项配置(`Run.IfPsychubeDailyOnce`、`Task.Queue`、`Data.LastPsychubeDate`…)
→ MaaFW 形状的一次性迁移,以及「通用 MaaFW 脚本指向 M9A 项目 → 换类型标签」。在
diff --git a/app/task/M9A/flavor.py b/app/task/M9A/flavor.py
index a4d9ac4f01..280bb0c389 100644
--- a/app/task/M9A/flavor.py
+++ b/app/task/M9A/flavor.py
@@ -244,14 +244,16 @@ def log(message: str) -> None:
return ids, options
added: list[str] = []
- startup = task_name_for_entry(interface_model, STARTUP_ENTRY)
- if startup is None:
- log(
- f"[M9A] interface 里没有 entry 为 {STARTUP_ENTRY} 的任务,未自动加入启动游戏"
- )
- else:
- ids.insert(0, startup)
- added.append(startup)
+ startup = None
+ if not self.manages_game_startup(script_config):
+ startup = task_name_for_entry(interface_model, STARTUP_ENTRY)
+ if startup is None:
+ log(
+ f"[M9A] interface 里没有 entry 为 {STARTUP_ENTRY} 的任务,未自动加入启动游戏"
+ )
+ else:
+ ids.insert(0, startup)
+ added.append(startup)
# 队列里恰好一个有效切号时以它为准,与启动整理的结果一致(整理会把它收进「账号」)
account = (
@@ -361,20 +363,8 @@ async def ensure_logged_in(
host_adb=host_adb,
)
- def account_verification_entry(self, script_config: Any) -> str | None:
- return STARTUP_ENTRY if _login_mode(script_config) == LOGIN_MODE_MAS else None
-
- async def verify_game_account(
- self, *, user_config: Any, adb_path: str | None, adb_address: str, host_adb: Any
- ) -> str:
- from .login import verify_game_uid
-
- return await verify_game_uid(
- user_config=user_config,
- adb_path=adb_path,
- adb_address=adb_address,
- host_adb=host_adb,
- )
+ def manages_game_startup(self, script_config: Any) -> bool:
+ return _login_mode(script_config) == LOGIN_MODE_MAS
async def ensure_game_updated(
self,
diff --git a/app/task/M9A/login.py b/app/task/M9A/login.py
index 92b66e1186..e43aa7265b 100644
--- a/app/task/M9A/login.py
+++ b/app/task/M9A/login.py
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with AUTO-MAS. If not, see .
-"""B 服(哔哩哔哩渠服)登录:MAS 原生实现,登录完成后交给 M9A 的启动任务进游戏。
+"""B 服(哔哩哔哩渠服)登录:MAS 原生实现,登录并在游戏主页核对 UID 后交给 M9A。
入口是 ``MaaFWFlavor`` 的可选钩子 ``ensure_logged_in``(契约见
``app/task/MaaFW/tools/embedded/flavor.py`` 模块说明):MAS 启动模拟器(连带拉起游戏)之后、
@@ -30,13 +30,13 @@
2. 循环截图 + OCR:
- 「隐私政策提示」弹层:找到唯一的「同意」按钮(中心点限定在屏幕中下部)就点它;
- - 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称精确匹配
- (兼容「ゞ」被 OCR 读成「>」,中心点限定在列表区域),唯一命中后点击,
+ - 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称匹配
+ (含汉字的昵称仅比对汉字,中心点限定在列表区域),唯一命中后点击,
复核「登录」按钮与仍可见的昵称,最后点「登录」;
- 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`):MAS 无法知道当前登录的是哪个
账号,为了保证切到目标账号,点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」面板出现后按
上一条选号;
-3. 登录后等面板消失,由 M9A 的 StartUp 进入游戏。核对游戏内 UID 后才运行剩余任务。
+3. 登录后等面板消失,点击标题页 START 并等待主页 UID;核对通过后才运行 M9A 任务。
沿用的是哔哩哔哩 SDK 登录面板与标题页的 UI 锚点;布局改版后这里要跟着改。脚本能自己登录的
项目照旧走 ``Run.LoginMode == "Project"``,本模块只在 MAS 原生登录模式下生效。
@@ -45,10 +45,8 @@
- 自动同意只复用全局 ``Config.setting.Function.IfAgreeBilibili``(默认关闭):开关没开时
不点「同意」,提示手动处理并停止本次登录,不新增第二份协议开关;
-- 既认不出登录记录、也认不出标题页(例如游戏停在别的账号的主界面)时,**明确停止**并让用户
- 把游戏放到标题页——不能把"没认出登录页"当成"目标账号已登录",否则任务会跑在错的账号上;
- 等待时长服从脚本的 `Game.WaitTime`(加载画面同样认不出,不能按固定 60 秒截断慢启动的客户端),
- 总时长仍受登录主循环的 240 秒约束;
+- 已在主页时连续两帧核对 UID;确认不匹配时经游戏设置退出,再回到登录面板选号。
+ 无法识别画面时按脚本的 Game.WaitTime 等待,受登录总时限约束;
- 登录后等待面板退出,进游戏后核对 UID,未确认身份时不运行后续任务;
- 所有循环都先检查总时限,``adb input tap`` 失败直接抛错,不让点击失败被静默吞掉;
- 只在 `Mas` 模式、资源是「B 服」、且实际要启动的客户端是 B 服包名时干活:配错客户端要立刻
@@ -72,8 +70,11 @@
import re
import subprocess
import time
+import unicodedata
from collections.abc import Awaitable, Callable
from contextlib import suppress
+from functools import lru_cache
+from pathlib import Path
from typing import Any
import cv2
@@ -96,6 +97,7 @@
BILIBILI_START_ACTIVITY = "com.ssgame.mobile.gamesdk.frame.AppStartUpActivity"
_PRIVACY_HINT = "隐私政策"
+_WELCOME_HINT = "欢迎回来"
#: 冷启动的健康警告页标题(「警告:请在游戏前阅读」),点一下继续。
_WARNING_HINT = "请在游戏前阅读"
_LOGIN_RECORD = "登录记录"
@@ -107,8 +109,8 @@
#: 标题页可读的 OCR 文字:「START」按钮(允许 OCR 漏掉首字母)与标题 logo 下的「REVERSE」
#: 副标题(实机常只读到 ERSE)。登录面板与主界面都读不到这两处。
_TITLE_HINTS = ("START", "TART", "REVERSE", "ERSE")
-#: 标题页右下角退出图标的中心(相对游戏画面宽高)。
-_EXIT_POINT = (0.963, 0.912)
+#: 标题页右下角退出图标的中心(2560×1440 实机验证)。
+_EXIT_POINT = (0.956, 0.91)
#: 登录后的标题页「START」按钮中心(相对游戏画面宽高)。
_START_POINT = (0.50, 0.745)
@@ -148,7 +150,7 @@
_ACCOUNT_X_RANGE = (0.40, 0.57)
_ACCOUNT_Y_RANGE = (0.37, 0.70)
#: 收起态账号卡片右侧的展开箭头(相对游戏画面宽高)。
-_EXPAND_POINT = (0.635, 0.413)
+_EXPAND_POINT = (0.608, 0.43)
Box = tuple[int, int, int, int]
@@ -163,9 +165,19 @@ def _mask(text: str) -> str:
def _account_matches(text: str, account: str) -> bool:
- """兼容实机 OCR 将昵称中的「ゞ」读成「>」;仍需唯一匹配并核对游戏 UID。"""
+ """含汉字的昵称仅比较汉字,其余昵称精确匹配 OCR 原文。"""
+
+ def han(value: str) -> str:
+ return "".join(
+ char
+ for char in value
+ if unicodedata.name(char, "").startswith(
+ ("CJK UNIFIED IDEOGRAPH-", "CJK COMPATIBILITY IDEOGRAPH-")
+ )
+ )
- return text.replace("ゞ", ">") == account.replace("ゞ", ">")
+ account_han = han(account)
+ return han(text) == account_han if account_han else text == account
def _center(box: Box) -> tuple[float, float]:
@@ -225,7 +237,10 @@ def _warning_like(items: list[tuple[str, Box]]) -> bool:
def _title_like(items: list[tuple[str, Box]], frame: np.ndarray) -> bool:
"""识别标题文字,或用左上版本号和左下适龄标识共同确认标题页。"""
- if any("".join(text.upper().split()) in _TITLE_HINTS for text, _ in items):
+ if any(
+ _WELCOME_HINT in text or "".join(text.upper().split()) in _TITLE_HINTS
+ for text, _ in items
+ ):
return True
version = _pick(
items,
@@ -555,7 +570,7 @@ async def _wait_panel_closed(
*,
progress: Progress | None,
) -> None:
- """等待登录面板消失,遇到标题页先点击 START,再交给 M9A。"""
+ """等待登录面板消失,遇到标题页先点击 START。"""
deadline = time.monotonic() + _LOGIN_TIMEOUT
closed_frames = 0
@@ -595,42 +610,114 @@ def _homepage_uid(
frame: np.ndarray,
*,
require_label: bool = False,
- expected_length: int | None = None,
) -> str:
height, width = frame.shape[:2]
matches = [
match[2]
for text, box in items
- if (match := re.fullmatch(r"(?:(UID|ID)\s*[::]?\s*)?([0-9]+)", text.strip(), re.I))
+ if (
+ match := re.fullmatch(
+ r"(?:(UID|ID)\s*[::]?\s*)?([0-9]+)", text.strip(), re.I
+ )
+ )
and (not require_label or match[1])
- and (expected_length is None or len(match[2]) == expected_length)
and 45 <= _center(box)[0] * 1280 / width <= 165
and 113 <= _center(box)[1] * 720 / height <= 145
]
return matches[0] if len(matches) == 1 else ""
-async def verify_game_uid(
- *, user_config: Any, adb_path: str | None, adb_address: str, host_adb: Any = None
-) -> str:
- """M9A 的 StartUp 完成后,在游戏主页核对 UID。"""
+async def _verify_game_uid(device: _Device, expected_uid: str) -> str:
+ """选号后只允许连续两帧核对通过的账号进入任务队列。"""
- expected_uid = user_config.Info.GameUID.strip()
- device = _Device(adb_path, adb_address, host_adb)
deadline = time.monotonic() + _LOGIN_PANEL_SECONDS
previous_uid = ""
while time.monotonic() < deadline:
frame, items = await _ocr(device)
- actual_uid = _homepage_uid(items, frame, expected_length=len(expected_uid))
+ actual_uid = (
+ ""
+ if _privacy_prompt(items) or _has(items, _LOGIN_RECORD, _SWITCH_OTHER)
+ else _homepage_uid(items, frame)
+ )
if actual_uid and actual_uid == previous_uid:
if actual_uid != expected_uid:
- raise MaaFWLoginRequired("B 服登录失败:游戏内 UID 与配置不一致,已停止运行")
+ raise MaaFWLoginRequired(
+ "B 服登录失败:游戏内 UID 与配置不一致,已停止运行"
+ )
return "B 服登录完成,已核对游戏内 UID,继续运行 M9A 任务"
previous_uid = actual_uid
await asyncio.sleep(_POLL_SECONDS)
raise MaaFWLoginRequired("B 服登录失败:未能确认游戏内 UID,已停止运行")
+@lru_cache(maxsize=2)
+def _menu_template(name: str) -> np.ndarray:
+ data = (Path(__file__).with_name("login_assets") / name).read_bytes()
+ return cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
+
+
+def _find_menu_button(frame: np.ndarray, name: str, roi: Box) -> Box | None:
+ height, width = frame.shape[:2]
+ x, y, w, h = roi
+ scaled = cv2.resize(frame, (1280, 720))
+ template = _menu_template(name)
+ th, tw = template.shape[:2]
+ if th > h or tw > w:
+ return None
+ scores = cv2.matchTemplate(
+ scaled[y : y + h, x : x + w], template, cv2.TM_CCOEFF_NORMED
+ )
+ _, score, _, (dx, dy) = cv2.minMaxLoc(scores)
+ if score < 0.8:
+ return None
+ return (
+ round((x + dx) * width / 1280),
+ round((y + dy) * height / 720),
+ round(tw * width / 1280),
+ round(th * height / 720),
+ )
+
+
+async def _logout_from_homepage(device: _Device, *, progress: Progress | None) -> None:
+ """按 M9A 的退出导航回到标题页或 B 服登录记录。"""
+
+ await _progress(progress, "游戏内 UID 与目标账号不同,正在退出当前账号")
+ deadline = time.monotonic() + _LOGIN_PANEL_SECONDS
+ # MAA1999/M9A resource/base/pipeline/switch_account.json (AGPL-3.0).
+ steps = (
+ (("设置",), (33, 457, 515, 214), ("MenuRight.png", (450, 484, 119, 125))),
+ (("账号", "账户"), (22, 78, 177, 630), None),
+ (("退出",), (796, 500, 161, 53), None),
+ (("返回登录",), (739, 404, 126, 33), None),
+ )
+ for index, (labels, (x, y, w, h), fallback) in enumerate(steps):
+ while time.monotonic() < deadline:
+ frame, items = await _ocr(device)
+ matches = _pick(
+ items,
+ lambda text: text.strip() in labels,
+ frame,
+ x_range=(x / 1280, (x + w) / 1280),
+ y_range=(y / 720, (y + h) / 720),
+ )
+ if len(matches) == 1:
+ await device.tap(matches[0])
+ break
+ if index == 0 and not matches:
+ button = _find_menu_button(frame, *fallback) if fallback else None
+ if button is None and _homepage_uid(items, frame):
+ button = _find_menu_button(
+ frame, "MainMenuEntry.png", (3, 460, 141, 151)
+ )
+ if button is not None:
+ await device.tap(button)
+ await asyncio.sleep(_POLL_SECONDS)
+ else:
+ raise MaaFWLoginRequired(
+ f"B 服切号失败:游戏设置中未识别到「{'/'.join(labels)}」"
+ )
+
+
async def _logout_to_login_panel(
device: _Device,
frame: np.ndarray,
@@ -732,6 +819,8 @@ async def ensure_logged_in(
unknown_budget = _unknown_screen_budget(script_config)
title_polls = 0
logout_tried = False
+ homepage_logout_tried = False
+ previous_uid = ""
while True:
if time.monotonic() > deadline:
raise MaaFWLoginRequired(
@@ -755,7 +844,25 @@ async def ensure_logged_in(
if _has(items, _LOGIN_RECORD):
await _select_account(device, frame, items, account, progress=progress)
await _wait_panel_closed(device, progress=progress)
- return "B 服登录面板已关闭,交给 M9A 启动游戏"
+ return await _verify_game_uid(device, expected_uid)
+
+ actual_uid = _homepage_uid(items, frame)
+ if actual_uid:
+ if actual_uid == previous_uid:
+ if actual_uid == expected_uid:
+ return "B 服登录完成,已核对游戏内 UID,继续运行 M9A 任务"
+ if homepage_logout_tried:
+ raise MaaFWLoginRequired(
+ "B 服切号失败:退出后仍停在其他账号的游戏主页"
+ )
+ await _logout_from_homepage(device, progress=progress)
+ homepage_logout_tried = True
+ previous_uid = ""
+ unknown_since = None
+ title_polls = 0
+ continue
+ unknown_since = None
+ previous_uid = actual_uid
if not logout_tried and _title_like(items, frame):
title_polls += 1
@@ -768,7 +875,7 @@ async def ensure_logged_in(
else:
title_polls = 0
- # 既不是登录界面、也认不出标题页(例如游戏停在别的账号的主界面):
+ # 既不是登录界面、也认不出标题页(例如加载中的画面):
# 无法确认当前账号,明确停止,不能把「没认出来」当成「目标账号已登录」。
# 冷启动的加载画面同样落在这里,所以预算服从 Game.WaitTime,不能按固定 60 秒截断。
if unknown_since is None:
diff --git a/app/task/M9A/login_assets/MainMenuEntry.png b/app/task/M9A/login_assets/MainMenuEntry.png
new file mode 100644
index 0000000000000000000000000000000000000000..ec9502943d002b4a701398dfc4cbb75e9ca44332
GIT binary patch
literal 3667
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
literal 0
HcmV?d00001
diff --git a/app/task/MaaFW/AGENTS.md b/app/task/MaaFW/AGENTS.md
index 353189dd31..c6f3e18857 100644
--- a/app/task/MaaFW/AGENTS.md
+++ b/app/task/MaaFW/AGENTS.md
@@ -823,6 +823,13 @@ MFW 不使用原生编辑器会话或 base 三态选择运行配置;已有配
登录/切号、按游戏语义组织的专属界面、对上游资源文件的动态读取等)可以立专项,MaaEnd 就是
这种情况。立专项时在专项目录写明它比通用 MaaFW 多控制了什么。
+## 运行前登录与启动
+
+- 特调可声明 `manages_game_startup(script_config)`,具体契约见 `tools/embedded/flavor.py`。
+ 声明生效时,特调的 `ensure_logged_in` 完成进入游戏及账号核对后才能下发任务。失败时下次尝试重新登录。
+- 计划装饰不加入启动任务。宿主按同一计划预建历史,单次 worker 加载资源和 agent、记录环境及任务结果。
+ 首项按普通任务处理,显式声明的关键任务保护仍生效。
+
## 测试与排障
- 改 `tools/core/` 先跑 `tests/task/test_maafw_core.py`:核心最小回归,进仓库、长期保留,只收
diff --git a/app/task/MaaFW/tools/core/runner/models.py b/app/task/MaaFW/tools/core/runner/models.py
index d6b6ada1d5..98cb827dd1 100644
--- a/app/task/MaaFW/tools/core/runner/models.py
+++ b/app/task/MaaFW/tools/core/runner/models.py
@@ -85,6 +85,8 @@ class MaaFWRunPlan(BaseModel):
pretasks: list[MaaFWPretaskRunPlan] = Field(default_factory=list)
piEnv: dict[str, str] = Field(default_factory=dict)
tasks: list[MaaFWTaskRunPlan] = Field(default_factory=list)
+ # 特调在下发计划前已完成启动时,首项按普通任务处理。
+ firstTaskStartupGuard: bool = True
skippedTasks: list[MaaFWSkippedTaskPlan] = Field(default_factory=list)
# 用户的全局选项表(已解密、叠过托管级键位),各任务覆盖里全局那一层的取值。任务的
# options 是给人看的合并结果,同一选项任务自己也有值时看不到这一层,宿主给日志打码时两处都收。
diff --git a/app/task/MaaFW/tools/core/runner/runner.py b/app/task/MaaFW/tools/core/runner/runner.py
index f26c0e141e..89a77877df 100644
--- a/app/task/MaaFW/tools/core/runner/runner.py
+++ b/app/task/MaaFW/tools/core/runner/runner.py
@@ -1673,7 +1673,7 @@ def _handle_task_deadline(
返回 None 表示没超时;``"next"`` 让调用方跳到下一个任务;``"skip_rest"`` 让调用方
结束本轮(不计入失败)。
- 计划里的第一个任务超时直接按失败结束本轮:它往往是前置任务(M9A 特调的
+ 启动保护启用时,计划里的第一个任务超时直接按失败结束本轮:它往往是前置任务(M9A 特调的
第一个任务是受管的「启动游戏」),前置都没做完,后面的任务没有意义。看的是
计划位置而不是「还没有任务完成」——第一个任务失败后第二个超时照样继续。
@@ -1729,7 +1729,7 @@ def _handle_task_deadline(
level=LEVEL_WARNING,
)
return "next"
- if index == 0 or task.abortRoundMessage:
+ if (index == 0 and self.plan.firstTaskStartupGuard) or task.abortRoundMessage:
reason = f"{TASK_TIMEOUT_MESSAGE}(限制 {limit_text})"
user_reason = f"{task.abortRoundMessage or display_name}:{reason}"
if task.abortRoundMessage:
@@ -1833,7 +1833,7 @@ def _handle_loop_guard(
) -> Literal["next", "skip_rest"] | None:
"""本任务被原地打转检测停掉时收尾;返回值同 ``_handle_task_deadline``。
- 口径与 ``_handle_task_deadline`` 完全一致:计划里的第一个任务或特调声明的关键
+ 口径与 ``_handle_task_deadline`` 完全一致:受启动保护的第一个任务或特调声明的关键
任务(``abortRoundMessage``)结束本轮,收尾任务(``nonFatalMessage``)放过(恰好
又被脚本侧强停时跳过剩余任务;排在中间且等满仍停不下来时结束本轮并计入失败),
其余记一条任务失败再继续。必须早于
@@ -1882,7 +1882,7 @@ def _handle_loop_guard(
f"{reason},{stopped}: {display_name};{task.nonFatalMessage}"
)
return "next"
- if index == 0 or task.abortRoundMessage:
+ if (index == 0 and self.plan.firstTaskStartupGuard) or task.abortRoundMessage:
user_reason = f"{task.abortRoundMessage or display_name}:{reason}"
if task.abortRoundMessage:
reason = f"{task.abortRoundMessage}:{reason}"
diff --git a/app/task/MaaFW/tools/embedded/flavor.py b/app/task/MaaFW/tools/embedded/flavor.py
index dfa2212b5a..0d917f5b7b 100644
--- a/app/task/MaaFW/tools/embedded/flavor.py
+++ b/app/task/MaaFW/tools/embedded/flavor.py
@@ -45,6 +45,15 @@
走重试(不特殊处理退出条件)。抛其它异常同样按失败处理,原因会附在异常原文后面。
- **取消**:用户停止(``CancelledError``)必须照常向上抛,不能吞掉。
+可选钩子 ``manages_game_startup(self, script_config) -> bool`` 的契约:
+
+- 建运行计划时通过 ``manages_game_startup`` 解析器探测,缺省为 False。该声明必须与特调
+ 的队列装饰规则一致,为 True 时不加入启动任务。
+- 声明为 True 的特调必须在 ``ensure_logged_in`` 成功返回之前完成进入游戏和必要的账号核对。
+ 失败时按登录失败结算,保留未完成状态供下次尝试。此模式的首项是普通任务,单任务超时和
+ 原地打转不触发首项启动保护,显式声明的关键任务规则仍生效。
+- 宿主和单次 worker 共用同一份任务计划,历史按该计划预建并记录任务事件。
+
可选钩子 ``sanitize_task_snapshot``(写账号任务快照前整理)的契约:
- **可选**:同样不进协议,``resolve_snapshot_sanitizer`` 按 ``getattr`` 探测。
@@ -342,6 +351,13 @@ def resolve_login_hook(script_config: Any) -> LoginHook | None:
return hook if callable(hook) else None
+def manages_game_startup(flavor: MaaFWFlavor, script_config: Any) -> bool:
+ """特调是否在运行前登录阶段完成进入游戏。"""
+
+ hook = getattr(flavor, "manages_game_startup", None)
+ return callable(hook) and bool(hook(script_config))
+
+
def resolve_game_update_hook(script_config: Any) -> GameUpdateHook | None:
"""脚本对应特调的游戏更新钩子;通用 MaaFW 或特调没实现时返回 None。"""
@@ -538,6 +554,7 @@ def _is_empty_json_object(value: Any) -> bool:
"resolve_flavor",
"resolve_game_update_hook",
"resolve_login_hook",
+ "manages_game_startup",
"resolve_snapshot_sanitizer",
"retype_notice_lines",
"sanitize_user_task_update",
diff --git a/app/task/MaaFW/tools/embedded/runner_task.py b/app/task/MaaFW/tools/embedded/runner_task.py
index 54a99e832c..0fa900a1b8 100644
--- a/app/task/MaaFW/tools/embedded/runner_task.py
+++ b/app/task/MaaFW/tools/embedded/runner_task.py
@@ -134,6 +134,7 @@
from .embedded_project import resolve_maafw_project_root
from .flavor import (
MaaFWLoginRequired,
+ manages_game_startup,
no_default_fallback_inputs,
off_day_scope,
resolve_flavor,
@@ -1362,6 +1363,8 @@ def _build_run_plan(
no_default_fallback_inputs=no_default_fallback_inputs(flavor),
excluded_task_reasons=dict.fromkeys(off_day_extra, off_day_reason),
)
+ if manages_game_startup(flavor, self.script_config):
+ plan.firstTaskStartupGuard = False
plan = _mark_nonfatal_tasks(_mark_abort_round_tasks(plan, flavor), flavor)
return _with_skipped_tasks(
_with_warnings(plan, stale_warnings), missing_skips
@@ -1412,6 +1415,7 @@ async def _build_device_config(
if plan.controllerType == "Adb":
address, device_info = await self._resolve_adb_address()
adb_path = await self._resolve_adb_path(address, device_info)
+ adb_path = await self._resolve_login_adb_path(address, adb_path)
await self._ensure_game_updated(address, adb_path)
await self._ensure_logged_in(address, adb_path)
adb_profile = await self._build_adb_control_profile()
@@ -1600,6 +1604,21 @@ async def _resolve_adb_path(
)
return None
+ async def _resolve_login_adb_path(
+ self, address: str, adb_path: str | None
+ ) -> str | None:
+ """在宿主统一解析登录钩子和 worker 使用的 ADB 路径。"""
+ if adb_path is None and address and resolve_login_hook(self.script_config):
+ adb_path = await asyncio.to_thread(
+ self._resolve_adb_path_by_toolkit, address
+ )
+ if adb_path:
+ self._append_log(
+ f"ADB 路径选择: MaaFW Toolkit 按地址发现; 路径={adb_path}"
+ )
+
+ return adb_path
+
def _resolve_adb_path_by_toolkit(self, address: str) -> str | None:
"""按设备地址问 MaaFW Toolkit 要 adb 路径(与 worker 的发现规则同一套)。
@@ -1679,17 +1698,6 @@ async def _ensure_logged_in(self, address: str, adb_path: str | None) -> None:
if hook is None or self.run_plan is None:
return
- if adb_path is None and address:
- # 登录钩子跑在 worker 之前,用不到 Runner 的按地址发现;宿主先把这条兜上,
- # 否则钩子只能拿 PATH 里的 ``adb``(通用模拟器自带的多半叫 HD-Adb.exe)。
- adb_path = await asyncio.to_thread(
- self._resolve_adb_path_by_toolkit, address
- )
- if adb_path:
- self._append_log(
- f"ADB 路径选择: MaaFW Toolkit 按地址发现; 路径={adb_path}"
- )
-
async def report(text: str) -> None:
self._append_log(text)
@@ -2108,45 +2116,15 @@ async def _run_maafw(self, device_config: MaaFWDeviceConfig) -> MaaFWRunResult:
# 截止时刻交给 worker:到点它自己停任务、截图、把已完成的任务带回来。
# 宿主这层只在 worker 没停下时才强杀,那时既没有截图也没有进度。
run_deadline_at = time.time() + timeout
- async def run_plan() -> MaaFWRunResult:
- flavor = resolve_flavor(self.script_config)
- get_entry = getattr(flavor, "account_verification_entry", None)
- entry = get_entry(self.script_config) if get_entry is not None else None
-
- async def run(part: MaaFWRunPlan) -> MaaFWRunResult:
- return await self._run_maafw_worker(
+ try:
+ return await asyncio.wait_for(
+ self._run_maafw_worker(
device_config,
- plan=part,
run_deadline_at=run_deadline_at,
task_time_limit_seconds=task_limit_seconds,
task_time_limit_overrides=task_limit_overrides,
loop_guard=loop_guard,
- )
-
- if entry is None:
- return await run(plan)
- if not plan.tasks or plan.tasks[0].entry != entry:
- raise MaaFWLoginRequired("无法核对游戏账号:运行计划缺少开头的启动游戏任务")
- startup = await run(plan.model_copy(update={"tasks": plan.tasks[:1]}))
- if not startup.success or startup.signal is not None:
- return startup
- message = await flavor.verify_game_account(
- user_config=self.cur_user_config,
- adb_path=device_config.adbPath,
- adb_address=device_config.address or "",
- host_adb=await resolve_host_adb(self),
- )
- self._append_log(message)
- if len(plan.tasks) == 1:
- return startup
- result = await run(plan.model_copy(update={"tasks": plan.tasks[1:]}))
- return result.model_copy(
- update={"completedTasks": startup.completedTasks + result.completedTasks}
- )
-
- try:
- return await asyncio.wait_for(
- run_plan(),
+ ),
timeout=timeout + _RUN_DEADLINE_GRACE_SECONDS,
)
except asyncio.TimeoutError as exc:
@@ -2162,13 +2140,12 @@ async def _run_maafw_worker(
self,
device_config: MaaFWDeviceConfig,
*,
- plan: MaaFWRunPlan | None = None,
run_deadline_at: float | None = None,
task_time_limit_seconds: int = 0,
task_time_limit_overrides: dict[str, int] | None = None,
loop_guard: bool = False,
) -> MaaFWRunResult:
- plan = self.run_plan if plan is None else plan
+ plan = self.run_plan
if plan is None:
raise RuntimeError("MaaFW 运行计划尚未初始化")
diff --git a/changelog.d/fix-m9a-bili-nickname.fix.md b/changelog.d/fix-m9a-bili-nickname.fix.md
index 419579f773..ba4f7d67c9 100644
--- a/changelog.d/fix-m9a-bili-nickname.fix.md
+++ b/changelog.d/fix-m9a-bili-nickname.fix.md
@@ -1,4 +1,4 @@
project: m9a
beta-only: true
-修复 B 服特殊昵称选号失败,并在代理前核对游戏 UID。
+修复 B 服特殊昵称选号并支持从主页切号,已有 MAS 登录账号需在账号配置中补填游戏 UID。
diff --git a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
index f44a7f0dc9..3a91cbf5da 100644
--- a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
+++ b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
@@ -48,14 +48,18 @@
-
+
@@ -67,6 +71,7 @@