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..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` 与用户队列。标题页的正向信号有四种:OCR 到「START」(或漏首字母的 TART)、
- 标题 logo 下的完整「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 748edf8e6d..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,6 +363,9 @@ async def ensure_logged_in(
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 b7cb24ce98..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,14 +30,13 @@
2. 循环截图 + OCR:
- 「隐私政策提示」弹层:找到唯一的「同意」按钮(中心点限定在屏幕中下部)就点它;
- - 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称精确匹配
- (中心点限定在列表区域),唯一命中后点击,复核「登录」按钮与仍可见的昵称,最后点「登录」;
- - 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`,
- 或登录成功后顶栏的「<昵称> 欢迎回来」横幅;START 有时太淡读不到):MAS 无法知道当前登录的是哪个
+ - 「登录记录」面板:收起态先点右侧箭头展开列表,再按登录记录里显示的完整昵称匹配
+ (含汉字的昵称仅比对汉字,中心点限定在列表区域),唯一命中后点击,
+ 复核「登录」按钮与仍可见的昵称,最后点「登录」;
+ - 已登录停在标题页(OCR 到「START」,或标题 logo 下的「REVERSE」副标题——实机常只读到 `ERSE`):MAS 无法知道当前登录的是哪个
账号,为了保证切到目标账号,点标题页右下角退出图标 → 唯一的「确认」→ 等「登录记录」面板出现后按
上一条选号;
-3. 登录后等哔哩哔哩面板消失(游戏回到标题页),把这之后的进游戏与任务执行交给 M9A 的
- ``StartUp`` 与用户任务队列。
+3. 登录后等面板消失,点击标题页 START 并等待主页 UID;核对通过后才运行 M9A 任务。
沿用的是哔哩哔哩 SDK 登录面板与标题页的 UI 锚点;布局改版后这里要跟着改。脚本能自己登录的
项目照旧走 ``Run.LoginMode == "Project"``,本模块只在 MAS 原生登录模式下生效。
@@ -46,12 +45,9 @@
- 自动同意只复用全局 ``Config.setting.Function.IfAgreeBilibili``(默认关闭):开关没开时
不点「同意」,提示手动处理并停止本次登录,不新增第二份协议开关;
-- 既认不出登录记录、也认不出标题页(例如游戏停在别的账号的主界面)时,**明确停止**并让用户
- 把游戏放到标题页——不能把"没认出登录页"当成"目标账号已登录",否则任务会跑在错的账号上;
- 等待时长服从脚本的 `Game.WaitTime`(加载画面同样认不出,不能按固定 60 秒截断慢启动的客户端),
- 总时长仍受登录主循环的 240 秒约束;
-- 登录后必须连续若干帧认出标题页才算完成;过渡黑屏 / 加载 / OCR 漏字只算未知画面,继续
- 有界等待,到点仍未确认就失败;
+- 已在主页时连续两帧核对 UID;确认不匹配时经游戏设置退出,再回到登录面板选号。
+ 无法识别画面时按脚本的 Game.WaitTime 等待,受登录总时限约束;
+- 登录后等待面板退出,进游戏后核对 UID,未确认身份时不运行后续任务;
- 所有循环都先检查总时限,``adb input tap`` 失败直接抛错,不让点击失败被静默吞掉;
- 只在 `Mas` 模式、资源是「B 服」、且实际要启动的客户端是 B 服包名时干活:配错客户端要立刻
报错,不能拿官服客户端去走哔哩哔哩登录面板。
@@ -74,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
@@ -110,8 +109,10 @@
#: 标题页可读的 OCR 文字:「START」按钮(允许 OCR 漏掉首字母)与标题 logo 下的「REVERSE」
#: 副标题(实机常只读到 ERSE)。登录面板与主界面都读不到这两处。
_TITLE_HINTS = ("START", "TART", "REVERSE", "ERSE")
-#: 标题页右下角的退出图标位置(相对屏幕;实机 2560x1440 验证,点它弹「是否登出当前账号?」)。
+#: 标题页右下角退出图标的中心(2560×1440 实机验证)。
_EXIT_POINT = (0.956, 0.91)
+#: 登录后的标题页「START」按钮中心(相对游戏画面宽高)。
+_START_POINT = (0.50, 0.745)
#: ``dumpsys activity activities`` 里表示 Activity 在前台的几种写法(与模拟器应用启动同一口径)。
_FOREGROUND_MARKERS = (
@@ -131,8 +132,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,7 +149,7 @@
#: 登录记录里账号昵称中心点范围(列表区域)。
_ACCOUNT_X_RANGE = (0.40, 0.57)
_ACCOUNT_Y_RANGE = (0.37, 0.70)
-#: 收起态「切换其他账号」行右侧展开箭头的位置(相对屏幕)。
+#: 收起态账号卡片右侧的展开箭头(相对游戏画面宽高)。
_EXPAND_POINT = (0.608, 0.43)
Box = tuple[int, int, int, int]
@@ -163,6 +164,22 @@ def _mask(text: str) -> str:
return f"{value[0]}{'*' * (len(value) - 1)}"
+def _account_matches(text: str, account: str) -> bool:
+ """含汉字的昵称仅比较汉字,其余昵称精确匹配 OCR 原文。"""
+
+ def han(value: str) -> str:
+ return "".join(
+ char
+ for char in value
+ if unicodedata.name(char, "").startswith(
+ ("CJK UNIFIED IDEOGRAPH-", "CJK COMPATIBILITY IDEOGRAPH-")
+ )
+ )
+
+ account_han = han(account)
+ return han(text) == account_han if account_han else text == account
+
+
def _center(box: Box) -> tuple[float, float]:
x, y, width, height = box
return x + width / 2, y + height / 2
@@ -207,16 +224,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 +234,31 @@ 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(
+ _WELCOME_HINT in text or "".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 +527,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 +551,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,38 +569,153 @@ async def _wait_panel_closed(
device: _Device,
*,
progress: Progress | None,
-) -> str:
- """登录后等哔哩哔哩面板消失,并用标题页的正向信号确认已回到标题页。
-
- 「一帧没有登录记录文字」不算完成:过渡黑屏、加载动画、OCR 漏字都可能满足;
- 只有连续 ``_TITLE_CONFIRM_FRAMES`` 帧认出标题页才算成功,其余画面继续有界等待,
- 等满总时限仍没确认就失败。标题页的正向信号有两种:OCR 读到「START」,或读到登录成功
- 后的「<昵称> 欢迎回来」横幅(横幅有时会盖住或淡化 START)。
- """
+) -> None:
+ """等待登录面板消失,遇到标题页先点击 START。"""
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,
+) -> 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 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(device: _Device, expected_uid: str) -> str:
+ """选号后只允许连续两帧核对通过的账号进入任务队列。"""
+
+ deadline = time.monotonic() + _LOGIN_PANEL_SECONDS
+ previous_uid = ""
+ while time.monotonic() < deadline:
+ frame, items = await _ocr(device)
+ 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 与配置不一致,已停止运行"
+ )
+ 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(
@@ -649,6 +792,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(
@@ -673,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(
@@ -682,6 +830,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,25 +839,43 @@ 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 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
- # 标题页 = 已登录: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
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 0000000000..ec9502943d
Binary files /dev/null and b/app/task/M9A/login_assets/MainMenuEntry.png differ
diff --git a/app/task/M9A/login_assets/MenuRight.png b/app/task/M9A/login_assets/MenuRight.png
new file mode 100644
index 0000000000..dce6201e67
Binary files /dev/null and b/app/task/M9A/login_assets/MenuRight.png differ
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 6452afdead..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)
@@ -2096,7 +2104,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
@@ -2136,7 +2145,8 @@ async def _run_maafw_worker(
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:
raise RuntimeError("MaaFW 运行计划尚未初始化")
service = MaaFWRunnerService()
@@ -2234,9 +2244,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..ba4f7d67c9
--- /dev/null
+++ b/changelog.d/fix-m9a-bili-nickname.fix.md
@@ -0,0 +1,4 @@
+project: m9a
+beta-only: true
+
+修复 B 服特殊昵称选号并支持从主页切号,已有 MAS 登录账号需在账号配置中补填游戏 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..3a91cbf5da 100644
--- a/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
+++ b/frontend/src/views/EditView/User/MaaFWUserEdit/BasicInfoSection.vue
@@ -47,6 +47,22 @@
/>
+
+
+
+
+
@@ -55,6 +71,7 @@