Alex Gu 7d3f8bfa2d fix: 若干正确性问题修正(null check / race / clamp / retry 等 9 处) (#16332)
## 概述

代码 review 发现的一批正确性问题批量修正,都是 `src/MaaCore` 下单行 / 局部 logic 级别的
bug,彼此独立,按严重度从高到低逐条 commit。没有功能改动、没有 API 变化、没有 breaking behavior。

每个 commit 单独对应一条 bug,便于逐条 review 或按需
revert。下表也按严重度从高到低排序,附上「原问题」与「修法理由」。

### 发现来源

使用 Claude Code 新版 `/ultrareview` 多 agent review 扫了 `src/MaaCore` 源码(~58k
行),人工逐条核对后提 PR。另有 2 条(AvatarCacheManager 并发保护 / CopilotConfig
默认值语义)涉及设计决策或 breaking change,已单独提 issue 讨论,不在本 PR 内。

---

## Fix 列表(按严重度排序)

### 1. `Controller::back_to_home` 空指针解引用崩溃 — HIGH

**位置**:`src/MaaCore/Controller/Controller.cpp:137`

**原问题**:
```cpp
bool asst::Controller::back_to_home()
{
    m_controller->back_to_home();   // 无 null 检查
    return true;
}
```
未连接设备时 `m_controller == nullptr`,此处直接 segfault。同文件 `sync_params` /
`start_game` / `stop_game` / `click` 等所有公开方法都有 null 保护(前者用 `if
(!m_controller) return`,后者用 `CHECK_EXIST(m_controller, false)` 宏),**只有
`back_to_home` 漏了**。

**触发路径**:任务运行中 ADB 断连使 `m_controller` 被 reset 成 null → 调度走到"回主页"分支 → 崩溃。

**修法**:函数开头加 `CHECK_EXIST(m_controller,
false);`,与同文件其他方法保持一致。最小改动,零行为变化(未连接时 `back_to_home` 返回 `false` 而不是崩溃)。

---

### 2. `MumuExtras::screencap` reload 之后没有真正重试 — MEDIUM-HIGH

**位置**:`src/MaaCore/Controller/MumuExtras.cpp:54-83`

**原问题**:
```cpp
int ret = capture_display_func_(...);       // 第一次 capture
if (ret) {
    // Try reloading once before giving up.
    if (!reload()) { ... return std::nullopt; }
    if (ret) {                              // ← 检查的是同一个 ret,不是重试之后的
        LogError << "Failed to capture display, but reload before retrying capture was successful." ...;
        return std::nullopt;
    }
}
```
注释承诺"失败时 reload 后重试 capture",但第二个 `if (ret)` 检查的还是第一次调用写入的
`ret`,`capture_display_func_` **从未被重新调用过**。reload 成功或失败都必然返回
`std::nullopt`,注释里的"reload before retrying capture was
successful"日志其实**只是字面,retry 压根没发生**。

**后果**:MuMu 模拟器短暂 capture 失败(内存抖动 / 显示切换)时,原本 reload
应能恢复,但代码从不真正重试,上层看到的永远是「截图失败」。

**修法**:在两个 `if (ret)` 之间加上**真正的**重试:重新调用 `capture_display_func_` 并更新
`ret`。reload 可能改变设备状态,所以 `display_id` 也重新 `get_display_id()`
取一次。失败信息相应改为"Failed to capture display even after reload",语义准确。

---

### 3. `Controller` 中 `d_size` 被 `const static` 固化,分辨率变化后缩放错误 — MEDIUM

**位置**:`src/MaaCore/Controller/Controller.cpp:146` 和 `:438`(两处)

**原问题**:
```cpp
const static cv::Size d_size(m_scale_size.first, m_scale_size.second);
```
C++ 函数内 `static` 局部变量的初始化表达式**仅在第一次进入该函数时执行一次**。首次调用时 `m_scale_size`
的值被固化到 `d_size`,之后用户切换模拟器、改分辨率预设、连接新设备等导致 `m_scale_size` 变化时,**`d_size`
不会更新**。

**后果**:
- `cv::resize(..., d_size, ...)` 把图缩到错误的目标尺寸 → OCR / 模板匹配的坐标/尺寸都错
- 截图失败兜底路径 `m_cache_image = cv::Mat(d_size, CV_8UC3)` 也分配错误尺寸的空图
- 用户看到各种"识别不到 / 坐标偏移"但不崩溃,**只能通过重启 MaaCore 恢复**

**修法**:去掉 `static`,每次调用重新构造。`cv::Size` 就是两个
`int`,栈上构造基本无开销,不存在性能回归。两处都修。

---

### 4. `PlayToolsController::swipe` 起点钳制没有应用到 `toucher_down` —
MEDIUM-LOW

**位置**:`src/MaaCore/Controller/PlayToolsController.cpp:230`

**原问题**:
```cpp
int x1 = p1.x, y1 = p1.y;                          // 拷贝到局部变量
// ...
if (x1 < 0 || x1 >= width || y1 < 0 || y1 >= height) {
    x1 = std::clamp(x1, 0, width - 1);             // 只钳制了局部 x1/y1
    y1 = std::clamp(y1, 0, height - 1);
}
// ...
toucher_down(p1);                                  // ← 仍用原始的 const Point& p1
```
注释"起点不能在屏幕外,但是终点可以"已经说明了钳制意图,但钳制结果只存在局部 `x1/y1`,`toucher_down(p1)`
使用的还是未钳制的原始 `p1`。后续 `progressive_move` 用 `x1/y1` 所以中段 swipe 在屏幕内,**但首个
touch-down 事件发到屏幕外坐标**,iOS PlayTools 侧多半会被丢弃或忽略 → 整个手势无效。

**修法**:`toucher_down(p1)` → `toucher_down({ x1, y1 })`,用已钳制的坐标构造临时
`Point`。与同文件下方 `toucher_move({ x, y })` 的风格一致。

---

### 5. `Assistant::m_task_id` 非原子 static 导致跨实例数据竞争 — MEDIUM-LOW

**位置**:`src/MaaCore/Assistant.h:214`(声明)+
`src/MaaCore/Assistant.cpp:295-296`(使用)

**原问题**:
```cpp
// Assistant.h
inline static TaskId m_task_id = 0;                     // 进程级 static,非 atomic
mutable std::mutex m_mutex;                             // 实例级 mutex
inline static std::atomic<AsyncCallId> m_call_id = 0;   // 旁证:同文件另一个 static 是 atomic

// Assistant.cpp:295
std::unique_lock<std::mutex> lock(m_mutex);             // 锁的是当前实例的 mutex
int task_id = ++m_task_id;                              // 改的是进程共享的 static
```
`m_task_id` 是 static(**所有 Assistant 实例共享一个内存地址**),但 `m_mutex`
是每个实例各自的锁。两个不同实例并发 `++m_task_id` 时,各自 lock 各自的 mutex,彼此不互斥 ——
**数据竞争**,`++` 对非原子 `int` 是 RMW 三步,可能让两个实例拿到同一个 `task_id`。

单实例客户端(MaaWpfGui、MaaMacGui)触发不了;多实例 + 并发下任务的客户端(命令行批处理 / 自动化测试)会偶现任务 id
冲突,后续 `AsstSetTaskParams` / `AsstStop` 可能误中另一个实例的任务。

旁证:同文件 line 227 的另一个同类 static `m_call_id` 已经声明成
`std::atomic<AsyncCallId>`,一致性也对不上,显然是作者漏改。

**修法**:`TaskId m_task_id` → `std::atomic<TaskId>
m_task_id`。`++m_task_id` 靠 `std::atomic::operator++` 原子化,语法不变。`m_mutex`
原先是为紧跟的 `m_tasks_list.emplace_back` 服务的,继续保留。

---

### 6. `OcrPack::load` 复制粘贴导致 `rec_label` 变化检测用错字段 — LOW-MEDIUM

**位置**:`src/MaaCore/Config/Miscellaneous/OcrPack.cpp:61`

**原问题**:
```cpp
// 前两块都正确:当前保存路径 != 新路径 时才更新
if (exists(det_model_file) && m_det_model_path != det_model_file) { ... }
if (exists(rec_model_file) && m_rec_model_path != rec_model_file) { ... }
// 第三块:应该检查 m_rec_label_path,却写成了 m_rec_model_path
if (exists(rec_label_file) && m_rec_model_path != rec_label_file) {
    m_rec_label_path = rec_label_file;
    m_rec = nullptr;
}
```
模型路径以 `.onnx` 结尾,标签路径以 `.txt` 结尾,`m_rec_model_path != rec_label_file`
**恒为真**(只要标签文件存在)。结果每次调用 `load()` 都会:
- 无效地重新给 `m_rec_label_path` 赋同样的值
- 把 `m_rec` 置空 → 触发下一次 OCR 时 lazy 重载

不会导致正确性崩塌(`check_and_load` 路径会 lazy 重建
`m_rec`),但"路径没变则保留缓存"的设计目的完全失败,且多次 `load()` 会产生不必要的模型重载开销。

**修法**:`m_rec_model_path != rec_label_file` → `m_rec_label_path !=
rec_label_file`,与上面 `det` / `rec_model` 两块的模式对齐。

---

### 7. `RoguelikeStageEncounterConfig` `fallback_choices` 校验写错变量 — LOW


**位置**:`src/MaaCore/Config/Roguelike/RoguelikeStageEncounterConfig.cpp:77`

**原问题**:
```cpp
int option_num = pair_arr[0].as_integer();
if (option_num < 0) { ... return false; }               // 校验 option_num,OK
int choice = pair_arr[1].as_integer();
if (option_num < 0) {                                   // ← 重复检查 option_num
    Log.error(
        std::format(
            "RoguelikeEncounterConfig | callback choice for event {} with {} option(s) is less than zero",
            ...));
    return false;
}
```
错误信息本身已经说明是在校验 `choice`("callback choice ... is less than
zero"),但实际比较的是上面已经保证过非负的 `option_num`,这个 `if` 永远进不去。

**后果**:坏 JSON 中 `[3, -5]` 这类负数 `choice` 能被静默接受,后续
`static_cast<size_t>(-5)` 产生巨大无符号数,作为索引使用时 UB。正常配置不触发,但这条防御校验已名存实亡。

**修法**:第二个 `if` 判断变量改为 `choice`。错误消息中 `option_num` 作为上下文保留(用于打印 option
数量,语义正确)。

---

### 8. `AsstDestroy` 对值传入的形参赋 `nullptr` 是死代码 — TRIVIAL

**位置**:`src/MaaCore/AsstCaller.cpp:98`

**原问题**:
```cpp
void AsstDestroy(AsstHandle handle)
{
    if (handle == nullptr) return;
    delete handle;
    handle = nullptr;       // ← 改的是局部拷贝,对调用方的变量无影响
}
```
`AsstHandle` 按值传入,`handle = nullptr` 只修改本函数作用域的局部拷贝。`delete handle`
已正确销毁对象,这行既没有替调用方置空(要做到需接受 `AsstHandle*`),也不会影响 delete 行为,纯死代码。

**修法**:删掉这行,保持 `delete` 行为不变,避免给读者错误印象。

---

### 9. `AvatarCacheManager.h` 开头两行重复的 `#pragma once` — TRIVIAL

**位置**:`src/MaaCore/Config/Miscellaneous/AvatarCacheManager.h:1-3`

**原问题**:
```cpp
#pragma once

#pragma once        // ← 重复
#include "Config/AbstractResource.h"
```
pragma once 幂等,没有功能影响,但看起来是编辑时的残留。清理即可。

---

## Test plan

- [x] `git diff dev-v2..HEAD --stat`:8 文件 / +18/-11,只有目标修改,无无关改动
- [x] 每条 fix 都有对应的单独 commit,便于逐条 review 或按需 revert
- [x] **本地 macOS arm64 build 通过**:`cmake --preset macos-arm64 && cmake
--build build --config Release` → 203/203 targets 编译链接成功,0 error,修改的 8
个文件 0 warning(全局唯一 1 条 warning 是 `MaaUtils/Logger.cpp` 的 `#pragma
message` 版本信息打印,与本 PR 无关)
- [ ] 单元测试:macOS preset 默认没有编译 test target(`unit_test/MaaCore/` 下仅 1 个
`AlgorithmTest.cpp`,与本 PR 改动的函数无交集),本地 `ctest` 返回 "No tests were
found"。依赖上游 CI 完整矩阵(Linux / Windows / macOS + 各自测试)验证。

## Notes for reviewers

- 每条 fix 的 commit message 都附了原理说明,结合 PR 正文对应条目阅读
- **没有功能性增加**,也**不动任何 public API / ABI**
- 不涉及 resource / JSON 数据文件
- 合并之后建议开一个跟进 issue 讨论 ultrareview 的另外两条 finding(AvatarCacheManager
并发保护、CopilotConfig 默认值语义)—— 这两条需要设计决策,不适合塞进本 PR

---

_PR 发现来源:Claude Code `/ultrareview` 多 agent 扫描后人工复核。_

## Summary by Sourcery

在不改变公共 API 或行为的前提下,修复了控制器、OCR、肉鸽配置以及工具组件中的多处正确性问题。

Bug 修复:
- 在 `Controller::back_to_home` 中增加空指针检查,以防在底层控制器不可用时发生崩溃。
- 确保 `MumuExtras::screencap` 在成功重新加载后确实会重试屏幕捕获,并且能准确记录失败日志。
- 在 Controller 中为缩放后的图像尺寸使用非静态的 `cv::Size`,以便在无需重启的情况下正确响应分辨率变化。
- 对传递给 `PlayTools toucher_down` 的滑动起始点进行限制,确保初始触控事件始终在屏幕范围内。
- 将 Assistant 的进程级任务 ID 计数器改为原子类型,避免在多实例场景下产生数据竞争。
- 修正 `OcrPack` 标签路径变更检测逻辑,使 OCR 模型只在标签文件实际变更时才会重新加载。
- 修复 `RoguelikeStageEncounterConfig` 的回退选项校验逻辑,按预期拒绝负数选项。
- 移除 `AsstDestroy` 中无效的句柄重置代码,该代码试图将值传递参数置空但实际上无效。
- 移除 `AvatarCacheManager.h` 中多余的 `#pragma once`,以清理头文件。

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Fix multiple correctness issues across controller, OCR, roguelike
config, and utility components without changing public APIs or
behaviors.

Bug Fixes:
- Add a null check in Controller::back_to_home to prevent crashes when
the underlying controller is unavailable.
- Ensure MumuExtras::screencap actually retries display capture after a
successful reload and logs failures accurately.
- Use a non-static cv::Size for scaled image dimensions in Controller so
resolution changes are respected without restart.
- Clamp the swipe starting point passed to PlayTools toucher_down to
keep initial touch events within screen bounds.
- Make Assistant's process-wide task ID counter atomic to avoid data
races across instances.
- Correct OcrPack label path change detection so OCR models are only
reloaded when the label file actually changes.
- Fix RoguelikeStageEncounterConfig fallback choice validation to reject
negative choices as intended.
- Remove ineffective handle reset code in AsstDestroy that tried to null
a by-value parameter.
- Remove a redundant #pragma once in AvatarCacheManager.h to clean up
the header.

</details>
2026-04-27 00:46:12 +08:00
2026-04-07 02:19:13 +08:00
2026-04-24 22:21:04 +08:00
2026-03-16 21:01:39 +08:00
2026-03-07 19:08:15 +08:00
2025-12-11 12:22:22 +08:00

LOGO

MAA


C++ platform
license commit
stars GitHub all releases
Ask DeepWiki
MaaAssistantArknights%2FMaaAssistantArknights | Trendshift

简体中文 | 繁體中文 | English | 日本語 | 한국어

MAA 的意思是 MAA Assistant Arknights

一款明日方舟游戏小助手

基于图像识别技术,一键完成全部日常任务!

绝赞更新中 ✿✿ヽ(°▽°)ノ✿

下载与安装

请阅读 文档 后前往 官网Releases 下载,并参考 新手上路 进行安装。

亮点功能

  • 理智作战,掉落识别及上传 企鹅物流一图流
  • 智能基建换班,自动计算干员效率,单设施内最优解;同时也支持 自定义排班
  • 自动公招,可选使用加急许可,一次全部刷完!公招数据自动上传 企鹅物流一图流
  • 支持手动识别公招界面,方便对高星公招做出选择 (你的这个高资回费出的是推王呢还是推王呢)
  • 支持识别干员列表,统计已有和未有干员及潜能,并在公招识别显示
  • 支持识别养成材料,并导出至 企鹅物流刷图规划明日方舟工具箱ARK-NIGHTS 干员培养表
  • 访问好友、收取信用及购物、领取日常奖励等,一键全日常自动长草
  • 肉鸽全自动刷源石锭和等级,自动烧水和凹直升,智能识别干员及练度
  • 选择作业 JSON 文件,自动抄作业, 视频演示
  • 支持 C, Python, Java, Rust, Golang, Java HTTP, Rust HTTP 等多种接口,方便集成调用,自定义你的 MAA
话不多说,看图! zh1 zh2 zh3 zh4

使用说明

功能介绍

请参阅 用户手册

外服支持

目前国际服(美服)、日服、韩服、繁中服的绝大部分功能均已支持。但由于外服用户较少及项目人手不足,很多功能并没有进行全面的测试,所以请自行体验。
若您遇到了 Bug或对某个功能有强需求欢迎在 Issues讨论区 催更;或加入我们一起建设 MAA请参阅 外服适配教程

CLI 支持

MAA 支持命令行界面CLI操作支持 LinuxmacOS 和 Windows可用于自动化脚本或在无图形界面的服务器上使用。请参阅 CLI 使用指南

加入我们

主要关联项目

多语言 (i18n)

MAA 以中文(简体)为第一语言,翻译词条均以中文(简体)为准。

参与开发

请参阅 开发指南

API

外服适配

请参阅 外服适配教程,对于国服已支持的功能,绝大部分的外服适配工作仅需要截图 + 简单的 JSON 修改即可。

Issue bot

请参阅 Issue Bot 使用方法

致谢

开源库

数据源

贡献/参与者

感谢所有参与到开发/测试中的朋友们,是大家的帮助让 MAA 越来越好! (*´▽`)

Contributors

声明

  • 本软件使用 GNU Affero General Public License v3.0 only 开源,并附带额外 用户协议
  • 本软件 logo 并非使用 AGPL 3.0 协议开源,耗毛、vie 两位画师及软件全体开发者保留所有权利。不得以 AGPL 3.0 协议已授权为由在未经授权的情况下使用本软件 logo不得在未经授权的情况下将本软件 logo 用于任何商业用途。
  • 本软件开源、免费,仅供学习交流使用。若您遇到商家使用本软件进行代练并收费,可能是设备与时间等费用,产生的问题及后果与本软件无关。

DirectML 支持说明

本软件支持 GPU 加速功能,其在 Windows 平台上依赖于 Microsoft 提供的独立组件 DirectML。DirectML 并非本项目的开源部分,也不受 AGPL 3.0 的约束。为方便用户,我们随安装包附带了一个未经修改的 DirectML.dll 文件。如果您无需 GPU 加速功能,可安全删除该 DLL 文件,软件的核心功能仍可正常运行。

广告

用户交流 QQ 群:MAA 使用 & 粥游交流 QQ 群
Discord 服务器: 邀请链接
用户交流 TG 群:Telegram 群
自动战斗 JSON 作业分享:prts.plus
Bilibili 直播间:MrEO 直播间 直播敲代码 & MAA-Official 直播间 游戏/杂谈

技术群(舟无关、禁水):内卷地狱!(QQ 群)
开发者群:QQ 群

如果觉得软件对你有帮助,帮忙点个 Star 吧!~(网页最上方右上角的小星星),这就是对我们最大的支持了!

Description
MAA镜像
Readme AGPL-3.0 Cite this repository 1.2 GiB
Languages
C++ 46.2%
C# 39.2%
Python 5.5%
C 5%
CMake 0.8%
Other 3.2%