better-genshin-impact Documentation

BetterGI · 更好的原神 - 自动拾取 | 自动剧情 | 全自动钓鱼(AI) | 全自动七圣召唤 | 自动伐木 | 自动刷本 | 自动采集/挖矿/锄地 | 一条龙 | 全连音游 | 自动烹饪 | 桌面分身 - UI Automation Testing Tools For Genshin Impact

RAW Doc

Technical Documentation: babalae/better-genshin-impact

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

English | 中文 | 繁體中文 | 日本語
BetterGI · 更好的原神, 一个基于计算机视觉技术,意图让原神变的更好的项目。

功能

  • 实时任务
    • 自动拾取:遇到可交互/拾取内容时自动按 F,支持黑白名单配置
    • 自动剧情:快速点击过剧情、自动选择选项、自动提交物品、关闭弹出书页等
    • 自动邀约:自动剧情开启的情况下此功能才会生效,自动选择邀约选项
    • 快速传送:在地图上点击传送点,或者点击后出现的列表中存在传送点,会自动点击传送点并传送
    • 半自动钓鱼:AI 识别自动抛竿,鱼上钩时自动收杆,并自动完成钓鱼进度
  • 独立任务
    • 全自动七圣召唤:帮助你轻松完成七圣召唤角色邀请、每周来客挑战等 PVE 内容
    • 自动伐木:自动 Z 键使用「王树瑞佑」,利用上下线可以刷新木材的原理,挂机刷满一背包的木材
    • 自动秘境:全自动秘境挂机刷体力,自动循环进入秘境开启钥匙、战斗、走到古树并领取奖励
    • 自动幽境危战:自动传送并前往幽境危战,主要用于难度三刷圣遗物
    • 全自动钓鱼:在出现钓鱼 F 按钮的位置面向鱼塘,然后启动全自动钓鱼,启动后程序会自动完成钓鱼,并切换白天和晚上
    • 自动地脉花:自动连续刷地脉花,支持绝大部分地脉花位置。
    • 自动音游:一键自动完成千音雅集的专辑,快速获取成就
    • 自动烹饪:自动在完美区域完成食物烹饪
    • 自动分解圣遗物:支持 快速分解 和 按匹配规则的圣遗物分解
  • 全自动
    • 一条龙:一键完成日常(使用历练点),并领取奖励
    • 自动采集/挖矿/锄地:通过左上角小地图的识别,完成自动采集、挖矿、锄地等功能
    • 键鼠录制:可以录制回放当前的键鼠操作,建议配合调度器使用
  • 操控辅助
    • 那维莱特转圈:设置快捷键后,长按可以不断水平旋转视角(当然你也可以用来转草神)
    • 快速圣遗物强化:通过快速切换“详情”、“强化”页跳过圣遗物强化结果展示,快速+20
    • 商店一键购买:可以快速以满数量购买商店中的物品,适合快速清空活动兑换,尘歌壶商店兑换等
  • ……

截图

下载

[!NOTE]
下载地址:⚡Github 下载

不知道下载哪个?第一次使用?请看:快速上手 , 遇到问题请先看:常见问题

最新测试版本可以从自动构建中获取:
或者从CNB上直接下载(速度快):CNB Releases

使用方法

由于图像识别比较吃性能,低配置电脑可能无法正常使用部分功能。

推荐的电脑配置至少能够中画质60帧流畅游玩原神,否则部分功能的使用体验会较差。

你的系统需要满足以下条件:

  • Windows 10 或更高版本的64位系统
  • .NET 8 运行时 (没有的话,启动程序,系统会提示下载安装)

⚠️注意:

  1. 窗口大小变化、切换游戏分辨率、切换显示器的时候请重启本软件。
  2. 不支持任何画面滤镜(HDR、N卡滤镜等)。游戏亮度请保持默认。
  3. 当前只支持 16:9 的分辨率,推荐在 1920x1080 窗口化游戏下使用。
  4. 模拟操作部分可能被部分安全软件拦截,请加入白名单。已知360或者自定义规则WD会拦截部分类型的模拟点击

打开软件以后,在“启动”页选择好截图方式,点击启动按钮就可以享受 BetterGI 带来的便利了!

详细使用指南请看:快速上手

具体功能效果与使用方式见:文档

FAQ

  • 为什么需要管理员权限?
    • 因为游戏是以管理员权限启动的,软件不以管理员权限启动的话没有权限模拟鼠标点击。
  • 会不会封号?
    • 理论上不会被封。 BetterGI 不会做出任何修改游戏文件、读写游戏内存等任何危害游戏本体的行为,单纯依靠视觉算法和模拟操作实现。 但是mhy是自由的,用户条款上明确说明第三方软件/模拟操作是封号理由之一。当前方案还是存在被检测的可能。只能说请低调使用,请不要跳脸官方。
  • 更多常见问题...

致谢

本项目的完成离不开以下项目:

另外特别感谢 @Lightczx 和 @emako 对本项目的指导与贡献

开发者

格式化:CodeMaid.config、Settings.XamlStyler;

如何编译项目?

许可证

问题反馈

提 Issue 或 QQ群761949201


Overview (`BetterGenshinImpact/README.md`)

如何编译并运行整个工程?

Rider (推荐)

  1. git clone https://github.com/babalae/better-genshin-impact.git
  2. 推荐使用 Rider 打开本项目。速度快且免费!

Visual Studio 2022

  1. git clone https://github.com/babalae/better-genshin-impact.git
  2. 需要使用 Visual Studio 2022 打开本项目。

请注意当前 /Asset 目录下的部分文件过大,比如地图特征数据(300M+),需要手动从 Release 包中获取并拷贝至对应的编译目录下,软件才能够正常运行对应的功能。(当前仅影响地图追踪、自动传送相关功能) 现在已经丢到 nuget 上可以直接编译构建了

如何通过 Github Action 直接构建完整包?

首先 fork 这个项目并启用 GitHub Actions。然后提交 <commit> 并且推送到 `` 。

最后前往这里 https://github.com/<your-user-name>/better-genshin-impact/actions/workflows/publish.yml 并点击 Run workflow:

  • Use workflow from 选择 ``
  • BetterGI Version 填写 <current-version>+<commit hash>
  • Kachina Installer Channel 选择 release (无需修改)
  • 不要勾选 创建 GitHub Release 草稿

点击绿色按钮 Run workflow,等待约10分钟。

刷新当前页面,点击最新的 Run,Artifacts 中的 BetterGI_7z 就是构建的完整包。

运行项目闪退?

可能是 Windows SDK 版本不够。使用 Visual Studio Installer 安装 10.0.22621.0 及以上版本的 Windows SDK,或者编辑项目文件的 TargetFramework 降低版本。

识别用的模板资源 Recognition.json 如何编写

参考:RecognitionJson编写说明


Overview (`Docs/README.md`)

文档

仓库文档按主题分类存放。

text
Docs/
├─ architecture/       # 架构设计
├─ development/        # 开发者文档
├─ technical/          # 具体技术实现
├─ api/                # API / SDK
├─ design/             # 设计文档 / ADR
├─ images/             # 文档图片
├─ archive/            # 废弃/历史方案
├─ tmp/                # AI 临时设计文件(git 忽略)
└─ i18n/               # README 多语言文本
目录 用途
architecture 整体架构、分层、模块边界与演进方案
development 编译、调试、贡献流程等开发者文档
technical 具体功能或子系统的技术实现说明
api 对外或对内的 API / SDK 说明
design 设计文档与架构决策记录(ADR)
images 文档引用的图片资源
archive 已废弃或仅作历史参考的方案
tmp AI 临时设计文件,内容不提交
i18n 根目录 README 的多语言文本

Overview (`Fischless.WindowsInput/README.md`)

Fischless.WindowsInput

Modify version implement https://www.nuget.org/packages/InputSimulator 1.0.4.

Get legacy version:

xml
<PackageReference Include="InputSimulator" Version="1.0.4" />

Overview (`Build/BetterGenshinImpact.I18nSync/README.md`)

BetterGenshinImpact.I18nSync

BetterGenshinImpact.I18nSync 用于扫描主项目 XAML 中的 {i18n:T 中文原文} 以及少量明确标记的 C# UI 文案,并自动同步 BetterGenshinImpact/User/I18n/*.json 中的多语言 Key。

中文原文同时作为 i18n Key 和默认文案,因此 XAML 设计器可以直接显示中文;当目标语言没有对应翻译,或翻译值为空时,程序运行时也会回退显示这个中文 Key。

快速开始

请在仓库根目录运行:

powershell
# 默认模式:补充缺少的 Key,并删除已经废弃的 Key
.\Build\Scripts\i18n-sync.ps1

# 只补充缺少的 Key,不删除废弃 Key
.\Build\Scripts\i18n-sync.ps1 -AddOnly

# 只检查默认同步模式是否会产生变更,不修改文件
.\Build\Scripts\i18n-sync.ps1 -Check

# 只检查“只补充”模式是否会产生变更,不修改文件
.\Build\Scripts\i18n-sync.ps1 -AddOnly -Check

脚本会自动把 BetterGenshinImpact 主项目目录传给同步工具,日常开发优先使用该脚本即可。

XAML 写法

直接将中文界面文本作为 Key:

xml
<TextBlock Text="{i18n:T 用户设置}" />
<Button Content="{i18n:T 保存}" />
<GroupBox Header="{i18n:T 自动战斗}" />

同步后,语言文件中会生成相同的 Key:

json
{
  "保存": "",
  "用户设置": "",
  "自动战斗": ""
}

新增 Key 的值会故意留空,方便搜索和判断哪些翻译尚未补充。空字符串不会让界面显示为空,I18nService 会回退显示中文 Key。

同步模式

调用方式 补充缺少 Key 删除废弃 Key 写入文件
默认 是 是 是
-AddOnly / --add-only 是 否 是
-Check / --check 按默认模式检查 按默认模式检查 否
-AddOnly -Check / --add-only --check 是 否 否

“废弃 Key”是指语言 JSON 中存在、但当前项目 XAML 或明确标记的 C# UI 文案已经不再引用的 Key。默认模式会删除它们;如果正在重构界面、暂时不希望清理旧 Key,请使用 -AddOnly。

Check 模式不仅检查缺失或废弃 Key,也会检查文件的排序、缩进、换行等是否符合工具的规范化输出。发现任何需要同步的内容时,工具不会修改文件,但会返回退出码 1。

直接运行工具

需要指定其他项目目录,或者不经过 PowerShell 脚本时,可以直接运行:

powershell
dotnet run --project .\Build\BetterGenshinImpact.I18nSync\BetterGenshinImpact.I18nSync.csproj -- --project .\BetterGenshinImpact

可用参数:

参数 说明
--project <目录> 指定要扫描的主项目目录,该目录下必须存在 BetterGenshinImpact.csproj
--add-only 只补充缺少的 Key,保留废弃 Key
--check 只检查是否需要同步,不写入文件
-h、--help 显示帮助信息

未传入 --project 时,工具会按以下顺序定位主项目:

  1. 当前目录包含 BetterGenshinImpact.csproj 时,使用当前目录。
  2. 否则尝试使用当前目录下的 BetterGenshinImpact 子目录。

语言文件约定

语言文件位于:

text
BetterGenshinImpact/User/I18n/

例如:

text
en-US.json
it-IT.json
ja-JP.json
ru-RU.json

同步规则如下:

  • 处理该目录下所有 *.json 文件。
  • 文件名以 _ 开头的 JSON 会被忽略,可用于保存不参与同步的辅助文件。
  • 已有 Key 的翻译值会原样保留。
  • 新增 Key 的翻译值固定写为空字符串 ""。
  • 默认模式会删除 XAML 中已经不存在的 Key;AddOnly 模式会保留它们。
  • 每次写入前都会使用 StringComparer.Ordinal 对全部 Key 排序。
  • 输出格式为两空格缩进、LF 换行、UTF-8 无 BOM。
  • 只有内容确实发生变化的语言文件才会被写入。

Key 提取规则

工具会递归扫描项目中的 *.xaml 和 *.cs 文件,并遵循以下规则:

  • 提取 XML 属性值中的 {i18n:T ...}。
  • 提取 I18nService.Instance.Translate("...") 和 new HotKeySettingModel("...") / new StatusItem("...") 中明确作为 UI 文案 Key 的字符串,并还原 C# 字符串转义。
  • 忽略 bin 和 obj 目录。
  • XAML 注释中的文本不会被提取。
  • C# 的行注释和块注释中的文本不会被提取。
  • 相同 Key 只保留一份。
  • JSON 格式错误、重复 Key 或非字符串翻译值都会使同步失败,避免覆盖异常数据。
  • 先验证并生成全部文件的同步计划,确认无错误后才开始写入,避免只更新一部分语言文件。

推荐工作流

  1. 在 XAML 中新增、修改或删除 {i18n:T 中文原文}。
  2. 运行 .\Build\Scripts\i18n-sync.ps1 同步所有语言文件。
  3. 在各语言 JSON 中搜索值为 "" 的项目并补充翻译。
  4. 提交前运行 .\Build\Scripts\i18n-sync.ps1 -Check。
  5. 检查 Git 差异,确认删除的废弃 Key 符合预期。

如果界面重构尚未完成,建议第 2 步临时使用 -AddOnly,等 XAML 调整完成后再运行默认模式统一清理废弃 Key。

CI 检查

可以在持续集成环境中运行:

powershell
pwsh .\Build\Scripts\i18n-sync.ps1 -Check

退出码约定:

退出码 含义
0 同步成功,或检查确认无需变更
1 Check 模式发现需要同步的内容
2 参数错误、路径错误、XAML/JSON 解析失败等执行错误

常见问题

新增 Key 后为什么翻译值是空字符串?

这是预期行为。空字符串用于标记待翻译内容,运行时会回退显示中文 Key,不会显示空白。

为什么 `Check` 模式返回失败,但没有缺少的 Key?

文件中可能存在废弃 Key,或者排序、缩进、换行等格式与工具的规范化输出不一致。先运行一次对应的同步模式,再检查 Git 差异。

默认同步删除了某个 Key,应该怎么办?

确认该 Key 是否仍在真实的 XAML 属性中使用。注释中的引用不会被视为有效引用。重构期间需要暂时保留旧 Key 时,请使用 -AddOnly。

修改了 JSON,但界面仍显示中文 Key

请确认当前语言文件中的 Key 与 XAML 中文原文完全一致,并且翻译值不是空字符串或纯空白。Key 比较区分大小写,也不会自动忽略前后空格。


Overview (`Docs/api/README.md`)

API / SDK

本目录存放对内或对外的 API、SDK 与接口约定说明。

文档列表

暂无。


Overview (`Docs/architecture/README.md`)

架构设计

本目录存放 BetterGI 的架构设计文档,包括整体分层、模块边界、依赖关系与演进方案。

文档列表

暂无。


Overview (`Docs/archive/README.md`)

废弃 / 历史方案

本目录存放已废弃、未采纳或仅作历史参考的方案,避免与现行文档混淆。

迁入时请在原文开头注明废弃原因和替代文档(如有)。

文档列表

暂无。


Overview (`Docs/design/README.md`)

设计文档 / ADR

本目录存放设计文档与架构决策记录(Architecture Decision Record)。

每份 ADR 建议记录:背景、备选方案、决策结果,以及取舍原因。

文档列表


Overview (`Docs/development/README.md`)

开发者文档

本目录存放面向贡献者的开发文档,包括编译、调试、代码规范与协作流程。

文档列表

暂无。


Overview (`Docs/images/README.md`)

文档图片

本目录存放文档引用的图片资源。正文中请使用相对路径引用,例如 ../images/example.png。


Overview (`Docs/technical/README.md`)

技术实现

本目录存放具体功能或子系统的技术实现说明,例如截图、识别、任务调度、输入模拟等。

文档列表


Overview (`Docs/tmp/README.md`)

AI 临时设计文件

本目录用于存放 AI 生成的临时设计稿、分析笔记和未定稿方案,不纳入版本库。

正式文档请整理后移到 architecture/、design/、technical/ 等对应目录。历史方案请移到 archive/。


Overview (`Test/BetterGenshinImpact.UnitTest/README.md`)

使用单元测试前准备

准备Assets

Assets目录下面是一个子模块(submodule)
为了避免不关注测试的用户下载不必要的数据,子模块没有保持最新,因此为了进行测试,要手动下载Assets目录下的最新数据资源:
git submodule update --remote
可能需要先init:
git submodule init

子模块被定义在 .gitmodules 文件中,因此上述git命令也应在该文件所在目录执行

Assets项目地址:https://github.com/huiyadanli/BetterGI.UnitTest.Assets

准备配置文件

有的单元测试要读取配置,目前采取读取主项目BetterGenshinImpact编译环境相同配置的方式
因此须要编译运行一次主项目BetterGenshinImpact,使得User/config.json被创建出来


Overview (`BetterGenshinImpact/GameTask/Common/StateMachine/README.md`)

StateMachineBase 状态机框架

StateMachineBase<TState, TContext> 是给游戏自动化任务使用的有限状态机基类。它把 UI 场景识别、场景处理、状态转移、重试和超时兜底放到统一框架里,避免任务里堆一长串 switch、while 和零散的超时判断。

推荐写法是:

  1. 用枚举定义所有 UI 状态。
  2. 用 [StateDetector] 标记状态检测方法。
  3. 用 [StateHandler] 标记状态处理方法,并在节点上声明重试策略。
  4. 在一个地方集中注册状态转移图。
  5. 调用 RunStateMachineUntil 运行到目标状态。

核心概念

概念 说明
State 一个明确的 UI 场景,比如 MainWorld、EventMenu、DomainEntrance
Detector 状态检测器,签名为 bool Method(ImageRegion ra),只判断当前截图是否匹配这个状态
Handler 状态处理器,签名为 Task<StateHandlerResult> Method(TContext context),负责在当前状态执行动作
Transition 状态图的有向边,声明某个状态执行成功后允许到达哪些状态
RetryPolicy 节点级兜底策略,可按次数或按时间限制当前状态的重试

TState 必须是枚举。建议第一个枚举值是 Unknown,因为 default(TState) 会被框架当作“未识别到状态”使用。

基本模板

csharp
public enum MyState
{
    Unknown,
    MainWorld,
    EventMenu,
    TargetPage,
}

public class MyTask : StateMachineBase<MyState, BvPage>, ISoloTask
{
    protected override ILogger Logger => TaskControl.Logger;

    public MyTask()
    {
        RegisterAllStateHandlers();
    }

    private void RegisterAllStateHandlers()
    {
        RegisterStateMethodsByAttribute();

        RegisterStateTransitions(
            (MyState.MainWorld, [MyState.EventMenu]),
            (MyState.EventMenu, [MyState.TargetPage])
        );
    }

    public async Task Start(CancellationToken ct)
    {
        Initialize(ct, MyState.Unknown);
        var page = new BvPage(ct);

        await RunStateMachineUntil(page, MyState.TargetPage);
    }
}

Detector

Detector 只负责“当前截图是不是这个状态”,不要在里面点击、等待或修改状态。

csharp
[StateDetector(MyState.MainWorld, Order = 10)]
private bool DetectMainWorld(ImageRegion ra)
{
    return ra.Find(ElementAssets.Instance.PaimonMenuRo).IsExist();
}

[StateDetector(MyState.EventMenu, Order = 20)]
private bool DetectEventMenu(ImageRegion ra)
{
    return ra.FindMulti(RecognitionObject.Ocr(125, 142, 113, 28))
             .Any(o => o.Text.Contains("活动一览"));
}

Order 表示全量检测时的检测顺序,数值越小越先检测。建议把快且稳定的模板匹配放前面,把大范围 OCR 放后面。

状态机每轮只截图一次并复用给多个检测器。若当前状态注册了邻接状态,则只检测邻接状态;若当前状态没有注册转移关系,则回退到全量检测。

Handler

Handler 只负责“在当前状态做一步动作”,动作结果用 StateHandlerResult 表达。除 Success 外,还可以用 SuccessTo(...) 将本次动作的等待目标收窄到当前状态图中的一个或多个合法邻接状态。

csharp
[StateHandler(MyState.MainWorld)]
private async Task<StateHandlerResult> HandleMainWorld(BvPage page)
{
    Simulation.SendInput.SimulateAction(GIActions.OpenTheEventsMenu);
    await Delay(500, _ct);
    return StateHandlerResult.Success;
}

[StateHandler(MyState.EventMenu, RetryTimeout = 30000)]
private async Task<StateHandlerResult> HandleEventMenu(BvPage page)
{
    var target = page.GetByText("目标活动").FindAll().FirstOrDefault();
    if (target == null)
    {
        return StateHandlerResult.Retry;
    }

    target.Click();
    await Delay(300, _ct);
    return StateHandlerResult.Success;
}

[StateHandler(MyState.TargetPage)]
private async Task<StateHandlerResult> HandleTargetPage(BvPage page)
{
    if (/* 当前页面业务条件成立 */)
    {
        Simulation.SendInput.SimulateAction(GIActions.OpenPaimonMenu);
        return StateHandlerResult.SuccessTo(MyState.MainWorld);
    }

    page.GetByText("前往").FindAll().First().Click();
    return StateHandlerResult.SuccessTo(MyState.TeleportMap, MyState.DomainEntrance);
}

SuccessTo(...) 的目标只表示真实的屏幕状态。当前屏幕中的业务条件不能伪造成一个新的状态;应先执行动作,再等待动作后的真实目标状态。

返回值 框架行为 典型场景
Success 动作完成,框架等待当前状态的全部合法邻接状态出现;如果源状态在动作生效窗口内持续可见,会触发当前状态 retry 点击按钮、发送交互键后等待页面切换
SuccessTo(targetStates...) 动作完成,框架只等待 Handler 指定的一个或多个合法邻接状态;目标仍需由 Detector 确认 同一动作可能进入多个已知页面,但不应接受当前状态的其他出边
Wait 不计 retry,直接进入下一轮检测 加载中、动画中、当前状态无需动作
Retry 当前状态 retry 次数加一,超限后抛异常 按钮没找到、OCR 暂时失败、动作没法确认
Fail 立即抛异常 配置错误、关键条件不满足、无法恢复

目标状态通常不会执行 Handler。RunStateMachineUntil 每轮会先检测当前状态,发现已到达目标状态就直接返回。

Unknown Handler

未知状态处理器用于兜底恢复,例如回主界面后重新识别。

csharp
[UnknownStateHandler]
private async Task<StateHandlerResult> HandleUnknownState(BvPage page)
{
    await new ReturnMainUiTask().Start(_ct);
    return StateHandlerResult.Wait;
}

未知状态处理器最多只能注册一个。它适合做“恢复到已知入口”的动作,不适合吞掉真正的业务错误。

状态转移图

状态转移关系必须集中注册,保持流程可读。

csharp
RegisterStateTransitions(
    (MyState.MainWorld, [MyState.EventMenu]),
    (MyState.EventMenu, [MyState.TargetPage])
);

状态图是严格的:

  • 当前状态有邻接关系时,框架只检测邻接状态。
  • 每个注册的状态转移必须至少有一个邻接状态;结束节点不要注册状态转移。
  • 邻接状态不能包含自身,注册时会直接抛异常。
  • 如果需要在同一页面重试,不要写自环,应该让 Handler 返回 Retry,或让 Success 后的源状态保持超时触发 retry。
  • Success 状态必须有合理的下一跳,否则等待邻接状态会失败并消耗 retry。

候选状态顺序会影响检测优先级。更具体、更不容易误判的状态应放在前面。

结束节点

结束节点用 RunStateMachineUntil 的目标状态表达,不需要也不应该注册空邻接。

csharp
RegisterStateTransitions(
    (MyState.MainWorld, [MyState.EventMenu]),
    (MyState.EventMenu, [MyState.TargetPage])
);

await RunStateMachineUntil(page, MyState.TargetPage);

上面这个例子里,TargetPage 没有出边,所以不注册 (MyState.TargetPage, [])。状态机每轮会先检测是否到达目标状态,命中后直接退出,不会执行目标状态的 Handler。

如果一个状态在某个阶段是结束节点,在另一个阶段还要继续流转,也可以注册它的出边。是否结束由本次 RunStateMachineUntil 的目标状态决定,不由状态本身永久决定。

Retry 策略

节点可以在 [StateHandler] 上声明 retry 策略:

csharp
[StateHandler(MyState.EventMenu, RetryTimeout = 30000, RetryInterval = 500)]
private async Task<StateHandlerResult> HandleEventMenu(BvPage page)
{
    ...
}

[StateHandler(MyState.TargetPage, RetryTimes = 10)]
private async Task<StateHandlerResult> HandleTargetPage(BvPage page)
{
    ...
}
属性 说明
RetryTimes 当前状态最多允许多少次 retry
RetryTimeout 当前状态最多允许在 retry 窗口内持续多久,单位毫秒
RetryInterval 发生 retry 后下一轮状态机循环的等待时间,单位毫秒
TransitionTimeout Handler 返回 Success 后等待邻接状态出现的超时时间,单位毫秒

RetryTimes 和 RetryTimeout 互斥,不能同时设置。

retry 预算会被两类事件消耗:

  • Handler 返回 Retry。
  • Handler 返回 Success 后,源状态持续可见并达到动作生效窗口上限。

状态变化后 retry 计数和 retry 计时都会重置。

什么时候用次数,什么时候用时间

用 RetryTimes:

  • Handler 能明确判断本次动作失败。
  • 每次 retry 的成本相近。
  • 失败次数比耗时更能表达问题。

用 RetryTimeout:

  • Handler 动作是幂等的。
  • 没法确认本次动作是否已经触发转场。
  • 可能卡在加载、动画、弹窗等中间态,按次数容易误杀。

例如“找不到活动入口”和“交互秘境入口后没法确认是否已经选中”属于同一类问题:动作可重复,但最终只能靠后续状态检测确认。这类节点更适合设置 RetryTimeout。

转场等待

DefaultTransitionTimeout 表示:Handler 返回 Success 后,源状态持续可见多久才认为本次动作没有生效。默认是 3000ms。达到这个窗口上限后,状态机会触发当前状态 retry。

DefaultIntermediateTransitionTimeout 表示:如果界面已经离开源状态,但还没到任何邻接目标,最多允许中间态持续多久。默认是 120000ms。这个设计是为了避免游戏加载、传送、黑屏等中间态被短动作窗口误判成当前状态 retry。

转场等待会按 DefaultDetectionInterval 持续轮询“邻接目标状态 + 源状态”,不会做全量状态检测。这样可以避免 MainWorld 这类宽泛状态在秘境入口、地图等场景误命中,导致当前节点本该 retry 的动作被误判成中间态。

等待期间按下面的规则计时:

  • 检测到邻接目标状态:立即转换成功。
  • 检测到源状态:说明动作还没有触发转场,动作生效窗口继续计时,中间态长超时重置。
  • 源状态和邻接目标都没检测到:说明可能处于加载、黑屏、动画等中间态,动作生效窗口重置,中间态长超时继续计时。
  • 中间态长超时:直接抛出中间态转换超时异常,不计入当前源状态的 retry 预算。

SuccessTo(targetStates...) 使用同一套转场等待机制,但将“邻接目标状态”收窄为 Handler 指定的目标集合。状态机在运行时校验每个目标确实是当前状态的合法邻接状态,并且已注册 Detector;校验失败属于状态图或 Handler 契约错误,不会静默回退到全部邻接状态。

例如同一个活动页可以按业务条件走不同真实屏幕节点:

csharp
return StateHandlerResult.SuccessTo(StygianState.MainWorld);

// 或
return StateHandlerResult.SuccessTo(
    StygianState.TeleportMap,
    StygianState.DomainEntrance);

前者适用于执行 Esc 后返回主界面,后者适用于点击“前往挑战”后可能进入传送地图或秘境入口。业务条件本身不需要注册为状态,只负责决定本次动作及其目标集合。

可以在节点上覆盖动作生效窗口:

csharp
[StateHandler(MyState.TeleportMap, TransitionTimeout = 10000)]
private async Task<StateHandlerResult> HandleTeleportMap(BvPage page)
{
    ...
}

Handler 内可读状态

Handler 内可以读取当前 retry 信息,用于调整动作强度或打印日志:

csharp
if (CurrentStateRetryCount > 3)
{
    Logger.LogWarning("当前状态已重试 {Count} 次", CurrentStateRetryCount);
}

可读属性:

属性 说明
CurrentState 当前状态
CurrentStateRetryCount 当前状态已累计 retry 次数
CurrentStateRetryLimit 当前状态的次数上限,使用时间策略时为 0
CurrentStateRetryTimeout 当前状态的时间上限,使用次数策略时为 null
CurrentStateRetryUsesTimeout 当前状态是否使用时间策略
CurrentStateRetryInterval 当前状态 retry 后的循环间隔
CurrentStateTransitionTimeout 仅在当前状态 Handler 返回 Success 后生效;从 Success 返回后开始计时,源状态持续可见超过此时长会触发 retry

这些属性都是只读保护属性,节点不能直接改框架内部计数。

完整示例

自动幽境危战任务是当前状态机的完整示例:

GameTask/AutoStygianOnslaught/AutoStygianOnslaughtTask.cs

简化状态图:

mermaid
flowchart LR
    Unknown --> MainWorld
    MainWorld --> EventMenu
    MainWorld --> StygianOnslaughtPage
    EventMenu --> StygianOnslaughtPage
    StygianOnslaughtPage --> MainWorld
    StygianOnslaughtPage --> TeleportMap
    StygianOnslaughtPage --> DomainEntrance
    TeleportMap --> DomainEntrance
    DomainEntrance --> DifficultySelect
    DifficultySelect --> DomainLobby
    DomainLobby --> BossSelect
    DomainLobby --> LeylineFlowerPrompt
    BossSelect --> BattleArena
    BattleArena --> BattleResultWin
    BattleArena --> BattleResultLose
    BattleResultWin --> DomainLobby
    BattleResultLose --> BossSelect
    LeylineFlowerPrompt --> ResinSelect
    ResinSelect --> ContinueOrExit
    ResinSelect --> DomainLobby
    ContinueOrExit --> BattleArena
    ContinueOrExit --> MainWorld

幽境危战里的两个典型兜底:

csharp
[StateHandler(StygianState.EventMenu, RetryTimeout = 30000)]
private async Task<StateHandlerResult> HandleEventMenuState(BvPage page)
{
    ...
}

[StateHandler(StygianState.StygianOnslaughtPage, RetryTimes = 10)]
private async Task<StateHandlerResult> HandleStygianOnslaughtPageState(BvPage page)
{
    ...
}

EventMenu 使用时间预算,因为找不到活动入口时无法确认是否已经进入右侧详情页,只能交给后续状态检测兜底。StygianOnslaughtPage 使用次数预算,因为找不到“前往挑战”按钮是当前页内的明确失败。

常见坑

  • 不要把自身放到邻接状态里。需要重复当前页面动作时,使用 Retry 或节点 retry 策略。
  • 不要在 Detector 里点击或等待。Detector 只做识别。
  • 不要让 Handler 自己写无限循环。循环、retry、超时交给状态机。
  • 不要把所有状态都塞进邻接状态。邻接表越精确,误判越少。
  • Wait 不消耗 retry。只在确实可预期等待时使用,否则可能靠 maxIterations 兜底才退出。
  • Success 表示“动作已发出”,不是“已经到达下一页”。下一页确认由状态机的邻接状态检测完成。
  • 如果节点动作可重复但无法确认效果,优先考虑 RetryTimeout。
  • 如果节点失败很明确,优先考虑 RetryTimes。

迁移旧任务

旧代码如果手动调用 RegisterStateHandlers 和 RegisterStateDetectors,可以按下面步骤迁移:

  1. 保留原来的状态枚举。
  2. 在检测方法上加 [StateDetector(State, Order = n)]。
  3. 在处理方法上加 [StateHandler(State)]。
  4. 未知状态处理方法加 [UnknownStateHandler]。
  5. 注册入口改成先调用 RegisterStateMethodsByAttribute()。
  6. 保留并整理 RegisterStateTransitions(...)。
  7. 把节点级 retry 策略从集中注册迁到对应 [StateHandler] 上。

迁移后,状态的检测逻辑、处理逻辑和节点策略都贴在方法声明处;状态图仍集中维护。


Docs Design Automation System (`Docs/design/automation-system.md`)

Pulonia 任务系统设计总览

状态:讨论稿,尚未实施。2026-10-02 按主题拆分;当前进入开发计划的步骤 0:设计验收。

目标:普通玩家选择要培养的角色、要采集的物品并一键托管;高级玩家使用任务树组合能力;开发者使用同一个 C#/JS 执行入口。新菜单和页面最终替代配置组、一条龙、地图追踪与 JS 管理的旧实现,复用可靠的底层能力。

从哪里开始

先读用户故事与边界,再看开发与逐步验收计划。只讨论某个功能时,直接打开对应专题;原来的大文档内容已经移入下列文件。

文档 内容
用户故事与设计边界 12 条故事、范围约束、故事到设计和开发步骤的对应关系
调研依据 23 个 GitHub issues、10 个 JS 脚本、本体与 d-v3 代码依据
实体、任务树与配置 Pulonia 命名、ER 图、树 JSON、配置优先级、可执行树取舍
执行、恢复与 API 串行执行、资源生命周期、取消、异常恢复、续跑和 C#/JS 接口
触发、托管与多账号 定时/热键/空闲、自动启动、目标规划、账号切换
结果、CD 与文件存储 成功证据、周期额度、去重、JSON 状态与故障恢复、依赖选择
界面与任务库 新菜单、树表、简洁视图、配置编辑、搜索和整目录添加
重构与迁移 旧代码解耦、d-v3 复用、数据导入、旧入口替代边界
开发与逐步验收计划 每一步的交付物、用户验收重点、验收记录和关键场景

核心决策

  1. 使用 BetterGenshinImpact.Pulonia 命名空间和 PuloniaTask 相关实体;保留 d-v3 的 root_task / children 树格式。
  2. 一条龙是同一份任务计划的简洁视图;托管生成计划,树编辑器手工编辑计划,所有入口复用同一执行语义。
  3. 主链保持“入口 → 任务服务 → 类型执行器”,文件存储旁路提供支持。允许集中构建只读执行树;具体形式在模型阶段用样例验收,不再禁止第二种运行时表示,也不照搬层层转发的转换/调度链。
  4. 触发、空闲准入与资源 CD 分开;开始执行前固定版本、解析有效配置,运行状态与编辑模型分开。
  5. 游戏会话与截图按需获取,Shell 和纯计算任务不强制启动截图器;首版串行执行,共同管理输入所有权。
  6. 完成结果必须附带证据,执行结束不自动等于采集或领奖成功;支持节点级续跑和有限异常恢复。
  7. 不引入 Quartz、SQLite、ORM 或通用工作流框架。使用 .NET 调度循环、现有热键和 JSON 文件;当前不要求新增第三方库。

开发约定

每一步完成实现与自查后,提交给用户验收并停止;收到明确通过后才能开始下一步。 子步骤遵守同样规则。编译成功不能代替用户验收;当前整理文档不代表开始实施。

基础任务系统先可用,再补可靠调度、迁移、多账号和目标托管。完整 box、库存与圣遗物识别按能力分步验收,缺失能力明确标记。具体步骤与验收记录只维护在开发计划,其他文档不另存一套进度。

依据与维护方式

代码依据:本体 893777b4a、本地 d-v3@b7aa7fc66;脚本依据固定到远端提交 6be613e7dbb3debf7b242146fbee43ddd8a1b4a1。调研采用静态源码和文档阅读,未切换分支或运行游戏;issue 状态为核查当日状态,详见调研依据。

新增需求先更新用户故事与来源,再修改所属专题;涉及已验收合同或开发范围变化时同步更新计划并重新提交相关部分验收。本轮只整理 Markdown,不修改业务代码。


Docs Design Config Persistence (`Docs/design/config-persistence.md`)

配置持久化链路(AllConfig 保存)优化

状态:已实施(第 1~3 项) · 2026-09-30

1. 背景

AllConfig 是全局唯一的配置对象,保存在 User/config.json 中。读取入口有两个,拿到的是同一个实例:

  • 静态入口 TaskContext.Instance().Config,转发到 ConfigService.Config。有 296 处调用,分布在 120 个文件。
  • DI 入口 IConfigService.Get(),约 27 个文件使用。

"全局静态、任何地方都能读写"这种用法本身不变。本次只改写入之后的链路:变更感知、触发器刷新和落盘。

2. 现状问题

2.1 每次属性变更的代价过高

AllConfig.OnAnyPropertyChanged 会在"调用方线程"上同步执行下面两步:

  1. GameTaskManager.RefreshTriggerConfigs():
    • 重新 Init() 7 个触发器。其中 AutoPick 会重读 3~4 个名单文件,AutoSkip 会重读 3 个 JSON。
    • SkillCd 的 Init() 会清空所有 CD 计时,所以改任何一个无关配置都会让 CD 提示归零。
    • 最后调用 DrawContent.ClearAll(),同步 Dispatcher.Invoke 到 UI 线程。
  2. ConfigService.Save():全量序列化后执行 File.WriteAllText。

XAML 里约有 44 处配置绑定使用了 UpdateSourceTrigger=PropertyChanged,每输入一个字符就会把上面两步完整走一遍。

File.WriteAllText 采用截断后覆盖写入。如果写到一半进程退出,文件就会损坏。现在的 BackupConfigFile 就是为这种情况兜底的。

2.2 自动保存的订阅不完整

InitEvent() 手写了 32 行 PropertyChanged +=,存在以下遗漏:

  • 未订阅的一级配置:OtherConfig、RecordConfig、AutoComboBuildConfig、AutoGeniusInvokationConfig、GetGridIconsConfig。
  • 所有嵌套对象,例如 OtherConfig.AutoRestartConfig、AutoFightConfig.FinishDetectConfig、PathingConditionConfig 中的 Condition、SkillCdConfig.CustomCdList 中的规则。
  • 所有 ObservableCollection 的增删。

这些改动只有在托盘退出时由 NotifyIconViewModel.Exit() 顺带保存。进程被杀、崩溃,或者走 Environment.Exit 的重启路径时,改动都会丢失。

2.3 线程模型与注释不一致

ConfigService.Config 的注释写着"写入只有 UI 线程会调用",但下面这些地方都会在非 UI 线程写配置:

  • 任务线程:AutoFishingTask、AutoWoodTask、AutoLeyLineOutcropTask、TurnAroundMacro。
  • 截图线程:SkillCdTrigger 检测到联机时,会在 OnCapture 内部关闭自身。
  • 其他:WebView 桥和热键回调。

后果:

  • 整份配置会在这些线程上序列化。如果 UI 线程同时在修改 List,就可能抛出 "Collection was modified",异常最终会变成一个错误弹窗。
  • SkillCdTrigger 这条路径会在持有 _triggerListLocker 的情况下同步写盘。

3. 目标与非目标

目标:

  1. 保存做防抖和原子写,触发器只按变更来源定向刷新。
  2. 自动覆盖整个配置对象图,新增配置类时不需要再去登记。
  3. 任何线程都可以写配置,保存侧不再产生竞争。

非目标(留给后续):

  • 不改动 296 处调用点,也不引入新的访问入口。
  • 不处理运行时状态混在配置中的问题:NextScriptGroupName、AutoLeyLineOutcropTask 临时替换 AutoFightConfig、临时打开 DisplayRecognitionResultsOnMask。
  • 不处理多个实例(主实例与桌面分身)同时写同一个 config.json 时"后写者覆盖"的问题。

4. 设计

4.1 变更追踪:`ConfigChangeTracker`

新增 Core/Config/ConfigChangeTracker.cs。它从 AllConfig 根节点开始,递归订阅:

节点类型 处理方式
INotifyPropertyChanged 订阅 PropertyChanged,并继续遍历其公开实例属性
INotifyCollectionChanged 订阅 CollectionChanged
IEnumerable / IDictionary(不含 string) 遍历元素(字典遍历 Value)
其他 POCO、值类型、委托 跳过

遍历属性时,跳过以下几类:有索引参数的属性、没有公开 getter 的属性、标注 [JsonIgnore](Condition = Always)的属性、值类型、string 和委托。每个 getter 都包在 try/catch 中。

订阅是增量且幂等的:

  • 已访问的对象记录在 ConditionalWeakTable 中。同一对象只订阅一次,环也不会导致无限递归。弱引用不会阻止对象被 GC。
  • 某个属性变更时,如果新值是可追踪对象(例如子配置被整体替换),就补订阅这个新值。PropertyName 为空时,重新遍历该对象的所有子节点。
  • 集合发生 Add/Replace 时,补订阅新元素;发生 Reset 时,重新遍历整个集合。
  • 被替换掉的旧对象不主动退订。它们以后如果再触发事件,只会多产生一次保存请求,没有副作用。

不做全量重扫,原因是 AutoLeyLineOutcropTask 会在战斗期间把 AllConfig.AutoFightConfig 临时换成另一个实例。如果此时全量重扫,追踪就会挂到这个临时对象上,而原对象丢失订阅。增量订阅不会去读根节点上没有通知能力的自动属性,所以不受这次临时替换的影响。

每次变更都会回调 onChanged(sender),回调在触发事件的线程上同步执行。

4.2 触发器定向刷新

已被 实时触发器生命周期与启停设计 取代:RefreshTriggerConfigs 已删除,tracker 回调只负责保存(sender 为 MaskWindowConfig 时额外清画布),触发器改为每帧读取配置。下文保留为历史记录。

AllConfig.InitEvent() 改为创建 tracker,回调中做两件事:

  1. GameTaskManager.RefreshTriggerConfigs(sender)(新增重载)。
  2. OnAnyChangedAction?.Invoke(),也就是 ConfigService.RequestSave。

NotificationConfig 仍然单独订阅,用来调用 NotificationService.RefreshNotifiers()。

各触发器的 Init() 只依赖自身对应的那份子配置(已逐一核对),映射如下:

sender 类型 重新 Init() 的触发器
AutoPickConfig AutoPick
AutoSkipConfig AutoSkip
AutoFishingConfig AutoFish
QuickTeleportConfig QuickTeleport
AutoEatConfig AutoEat
MapMaskConfig MapMask
SkillCdConfig SkillCd

当有触发器被刷新,或者 sender 是 MaskWindowConfig 时,额外执行一次 DrawContent.ClearAll()。这样可以保留两种原有行为:关闭触发器时擦掉它的残留绘制,以及切换识别结果显示开关时清掉过期的图形。其他配置变更不再刷新触发器,也不再清空画布。

刷新仍然是同步执行的,不做防抖,因此"改开关后触发器立即生效"的语义保持不变。

显式调用无参 RefreshTriggerConfigs() 的地方保持全量刷新,比如 AutoPick 名单编辑窗口:它只改了文件,配置里的 bool 值可能没有变化。

4.3 保存调度

ConfigService 内部维护两个版本号:

  • _changeSeq:每次保存请求时加 1。
  • _persistedSeq:已经落盘的快照对应的 _changeSeq。

两者不相等时,表示有未保存的改动。

防抖(RequestSave,任意线程可调用):

  1. _changeSeq++。如果当前没有待执行的保存,就启动一个 500ms 的定时器。窗口内的多次请求合并为一次,最大延迟有上限。
  2. 定时器触发后,在 UI 线程上以 DispatcherPriority.Background 生成快照:先读取 _changeSeq,再序列化得到 JSON。
  3. 回到线程池执行原子写。写入前检查:如果快照的版本号不大于 _persistedSeq,说明已经有更新的内容落盘,本次直接跳过。

快照放在 UI 线程生成,是因为绝大多数集合(包括所有 WPF 绑定的 ObservableCollection)都只在 UI 线程修改,在这里序列化就不会与它们竞争。任务线程改的是标量或引用,读取本身是安全的。

兜底策略:

  • UI 线程 3 秒内没有执行,或者 Dispatcher 已经开始关闭时,改在当前线程生成快照。
  • 快照生成抛异常(多半是其他线程正在改集合)时,稍后重试。连续失败 3 次后弹窗提示,并停止自动重试,等到下一次改动或退出时再尝试。

读写互不阻塞:

  • 代码中读写配置(Config.Xxx)操作的是内存对象,全程不加锁,写完立即可读。保存链路只登记改动,不拦截任何读写。
  • 生成快照时持有的锁只在快照之间互斥,不影响属性读写。其他线程在快照期间照常读写;期间发生的改动版本号更大,会进入下一次保存。
  • 读文件(Read())不加锁。原子替换保证读取方只会看到完整的旧文件或新文件。文件内容比内存最多落后一个防抖窗口,需要最新文件内容的地方先调用 Flush()。
  • 反过来,如果读取方打开文件时没有开放删除共享,替换会短暂失败。这时由写入方重试,读取方不受影响。

原子写:

  1. 写入临时文件 config.json..<guid>.tmp,内容为 UTF-8 无 BOM,然后 Flush(true)。
  2. 用 File.Move(tmp, config.json, overwrite: true) 替换正式文件。这是同卷 rename,读取方只会看到旧文件或新文件,不会看到写了一半的文件。
  3. 替换时如果遇到共享冲突,最多重试 5 次,每次间隔 50ms,最后仍失败则弹窗,与现有行为一致。
  4. 临时文件名带上进程号和 GUID,避免主实例和桌面分身互相踩到同一个临时文件。
  5. 启动时清理 1 分钟前残留的临时文件。

4.4 立即保存与退出落盘

  • Save():先执行 _changeSeq++,再同步生成快照并写盘,语义与现在一致(立即写)。不在 UI 线程时,用 Dispatcher.Invoke 生成快照,超时后退回当前线程。
  • Flush()(IConfigService 新增):只在有未保存改动时执行 Save()。

调用点:

场景 处理
App.OnExit Flush()
AppDomain.ProcessExit(覆盖 Environment.Exit) Flush(),不弹窗
SystemControl.RestartApplication 启动新进程前 Flush(),避免新进程读到旧配置
ChildSessionProcessLauncher.LaunchBetterGiAsync 启动分身前 Flush()
RepoWebBridge.GetUserConfigJson 读取文件前 Flush()

Process.Kill 和崩溃时,最多丢失最近约 500ms 内的改动。

5. 行为变化

场景 之前 之后
输入框每输入一个字符 全量刷新 7 个触发器,同步写盘 只刷新相关触发器(如果有),500ms 合并写一次
修改与触发器无关的配置 SkillCd 的 CD 计时归零,AutoPick 重读名单,画布被清空 不影响触发器
脚本通过 AddTrigger 强制启用的 AutoPick 任意配置变更都会把它重置为全局开关状态 只有 AutoPickConfig 变更时才会重置
修改 OtherConfig 和嵌套对象、增删集合 只在托盘退出时保存 自动保存
写盘中途进程退出 可能留下损坏的 config.json 旧文件保持完整
非 UI 线程写配置 在该线程上序列化和写盘 只登记改动,序列化在 UI 线程完成

6. 改动清单

  • 新增 Core/Config/ConfigChangeTracker.cs。
  • Core/Config/AllConfig.cs:InitEvent() 改用 tracker,删除 OnAnyPropertyChanged。
  • GameTask/GameTaskManager.cs:新增 RefreshTriggerConfigs(object? changedConfig)。
  • Service/ConfigService.cs、Service/Interface/IConfigService.cs:实现保存调度、原子写和 Flush()。
  • App.xaml.cs、GameTask/SystemControl.cs、Service/ChildSession/ChildSessionProcessLauncher.cs、Core/Script/WebView/RepoWebBridge.cs:在对应位置调用 Flush()。

Docs Design Game Runtime (`Docs/design/game-runtime.md`)

BetterGI 游戏运行环境 GameRuntime 设计

状态:P1、P2 已实现(第 6 版) · 2026-09-30 · 前置:InputHub 设计

第 6 版变更(维护者要求与 feat/yys 的目标对齐,正文已同步,汇总见第 11 节):

  • 实例名参数改为 --instance-name,并登记到根实例的端点。
  • 数据目录改为 WebView2Data/CloudGame/<实例名>。
  • 页面地址固定为 https://ys.mihoyo.com/cloud/?autobegin=1,删除 CloudWebConfig。
  • WebView2 禁止缩放、关闭状态栏,开发者工具只在 Debug 下可用。
  • 页面固定按 100% 渲染,不跟随系统缩放;宿主改为直接托管 CoreWebView2Controller,不再使用 WPF WebView2 控件(7.2)。
  • 宏回放改走 InputHub;网页版跳过本地原神设置检查。

第 5 版变更(维护者要求,正文已同步):

  • 网页版截图改用 WGC(GraphicsCapture),不再用 GraphicsCaptureV2。
  • 宿主客户区锁定为 1920×1080 物理像素,不能调整大小或最大化。
  • 网页版实例不显示主界面,只显示宿主窗口;关闭宿主即退出实例。原先为网页版准备的主界面改动随之删除。

第 4 版变更:P2 落地,与设计有出入的地方已同步到正文,汇总见第 11 节"P2 实施说明"。

第 3 版变更:落实第 15 节的确认结果。

  • 页面默认地址为 https://ys.mihoyo.com/cloud/。
  • 网页版实例不响应任何热键。
  • 宿主禁用 Chromium 的遮挡与后台降频。
  • RTC 断线只停止截图器。
  • 文字输入只加注释,不做适配。
  • 配置共享问题由维护者另行安排。

第 2 版变更:

  • P1 优化:
    • Provider 的两个启动方法合并为 AcquireAsync。
    • WinEventHook 改为只订阅游戏进程,并去掉 SKIPOWN 标志。
    • WebPageGameWindow 改为继承 Win32GameWindow。
    • 启动统一切到 UI 线程执行。
    • 截图参数改为集中构建。
  • P2 补充:
    • 网页版实例改为"命名多实例",WebView 数据按实例名存放。
    • 补充宿主窗口、截图、输入精简与接入的设计。

1. 背景

BetterGI 目前只支持一种运行环境:本机上的一个 Win32 游戏窗口(本地原神或 Windows 云原神客户端)。代码里没有"运行环境"类型,整个程序用一个 IntPtr GameHandle 表示它,相关职责分散在四处:

位置 承担的职责
SystemControl(静态) 找窗口、启动和关闭游戏、前台判断、激活与焦点、窗口几何
TaskContext.Init(hWnd) 写死 new Win32InputBackend(hWnd) 和 new SystemInfo(hWnd)
TaskTriggerDispatcher.Start(hWnd, mode, interval) 创建截图器、激活窗口、挂 WinEventHook;Tick 中判断存活、前台、最小化
HomePageViewModel.OnStartTriggerAsync 启动编排:关 HDR → 找窗口 → 关联启动 → Start(hWnd)

云原神网页版运行在 WebView2 中,和 Win32 窗口有四点不同,现有的 hWnd 模型接不进去:

  1. 游戏"进程"就是 BetterGI 自己。GameProcess.HasExited 永远为 false;按进程名判断前台时,BetterGI 主窗口也会被当成游戏。
  2. 输入经 RTC 数据通道发往云端,不需要前台。但 TaskControl 的每次 Sleep / Delay 都会检查前台并抢焦点。
  3. 宿主窗口和调度器在同一进程。Dispatcher 的 WinEventHook 带 WINEVENT_SKIPOWNPROCESS,收不到宿主窗口的事件。
  4. 截图方式应该由运行环境决定,但现在只能来自用户配置。

启动链路上还有几处反 DI 的写法:

  • ScriptService.StartGameTask 通过 App.GetService<HomePageViewModel>() 反向调用 ViewModel;
  • TaskTriggerDispatcher 自己创建截图器;
  • TaskContext 可以在任何地方被写入。

2. 目标与非目标

目标:

  • 把"窗口 + 截图 + 输入"收成一个可替换的单元 GameRuntime,由各环境的 Provider 产出。
  • 实现两种运行环境:Win32Window(现有行为)和 WebPage(云原神网页版)。
  • 网页版以命名实例运行,可以同时开多个,每个实例使用独立的 WebView 数据目录。
  • 运行环境由 DI 管理。TaskContext、SystemControl、InputHub 保留为静态入口并改为转发,500+ 调用点不改。
  • 精简 TaskContext 与现有的 WebSdk 输入实现。

非目标:

  • 一个进程同时驱动多个游戏。一个进程同一时刻只有一个运行环境,多开靠多进程。
  • 主实例与网页版实例之间的任务下发和状态回传(task.* IPC)。
  • 按实例拆分 User/config.json。由维护者另行安排,见第 15 节。
  • 网页版的文字输入适配,见 7.5。
  • 游戏画面没有铺满 WebView 时的子区域裁剪。
  • 静态调用点改为构造注入(P3 渐进进行)。

3. 术语

术语 含义
运行环境(GameRuntime) 一次绑定的产物:游戏窗口 + 截图器 + 输入后端。启动截图器即绑定,停止即解绑
游戏窗口(IGameWindow) 游戏画面所在的顶层 Win32 窗口;网页版就是宿主窗口
画面区域(GameViewport) 游戏画面在屏幕上的物理像素矩形,即现在的 CaptureAreaRect
Provider(IGameRuntimeProvider) 负责某种运行环境的获取和关闭,产出 GameRuntime
网页版实例 以 --instance webview --instance-name <实例名> 启动的独立 BetterGI.exe 进程
实例名 网页版实例启动前由用户指定,用于区分实例和 WebView 数据目录
宿主窗口(CloudWebHostWindow) 网页版实例中承载云原神页面的 WPF 窗口

实例类型与运行环境一一对应,不需要配置项:

实例类型 BetterGiInstanceType 运行环境 GameRuntimeKind 游戏
Primary / ChildSession Win32Window 本地原神、Windows 云原神
WebView WebPage 云原神网页版

Wine 不算运行环境:它仍然是 Win32 窗口,保留现有的 WinePlatformAddon 分支。

4. 实例拓扑

text
Primary(BetterGI.exe)
├─ GameRuntimeService ─ Win32RuntimeProvider
├─ 调度器 / 任务 / 脚本 / 遮罩
└─ 首页「云原神网页版」卡片 ─ WebViewInstanceStore(实例名列表)
      │ WebViewInstanceLauncher.Launch("小号A")
      ▼
网页版实例「小号A」(BetterGI.exe --instance webview --instance-name 小号A start)
├─ GameRuntimeService ─ WebPageRuntimeProvider
├─ 调度器 / 任务 / 脚本 / 遮罩(功能完整,由命令行驱动)
├─ 不创建 MainWindow
└─ CloudWebHostWindow ─ WebView2(唯一界面,即 Application.MainWindow;数据目录 WebView2Data/CloudGame/小号A)

网页版实例「小号B」……(结构相同,与「小号A」互不影响)
  • 每个进程各有一套运行环境、调度器和任务。TaskContext、InputHub、TaskTriggerDispatcher 都是进程级单例,天然隔离。
  • 网页版实例通过已有的根管道登记到 Primary(WebViewConnectionsByProcessId),本期不新增 IPC 操作。
  • 同名实例同一时刻只能运行一个,靠实例名互斥体保证(7.1)。

5. 运行环境抽象(L2)

5.1 层级与依赖规则

text
L4 使用方    Task / Trigger / JS 脚本 / Region / 遮罩 / HomePageViewModel / ScriptService
               │ 只依赖 L3 和 L2
L3 编排层    GameRuntimeService(DI 单例);TaskContext(静态入口,只读转发)
L2 抽象层    GameRuntime = IGameWindow + IGameCapture + IInputBackend;IGameRuntimeProvider
L1 实现层    Win32   :Win32GameWindow、Win32RuntimeProvider
             WebPage :WebPageGameWindow(继承 Win32GameWindow)、WebPageRuntimeProvider、CloudWebHostWindow
L0 原始层    SystemControl 的 Win32 函数 / Fischless.GameCapture / InputHub 各后端 / WebView2
  • 只允许上层依赖下层。L4 唯一可以直接用 L1 的地方是手动选窗:Win32RuntimeProvider.AttachTo(hWnd),而且这个入口只在 Win32 实例中显示。
  • IGameCapture 和 IInputBackend 直接复用,不再包一层。
  • 目录:
    • 抽象与实现放在 GameTask/Runtime/{Win32,WebPage},命名空间为 BetterGenshinImpact.GameTask.Runtime;
    • 宿主窗口放在 View/Windows/CloudWebHostWindow.xaml;
    • 实例名存储与启动器放在 Service/Instance/。

5.2 类图

text
/* Detailed source-code truncated for AI context efficiency. */

5.3 接口定义

text
/* Detailed source-code truncated for AI context efficiency. */

GameRuntimeService 的公开成员:

成员 说明
Kind 由实例类型决定,进程内不变
Current / IsRunning 当前运行环境
StartAsync(ct) 切到 UI 线程串行执行;已在运行时直接返回 true;停止请求会等待启动完成后再解绑
Start(GameRuntime) 用外部构造的运行环境启动,只用于手动选窗
StopAsync() / CloseGame() 等待启动完成后停止截图器 / 关闭游戏(转发给 Provider)
Started / Stopped 供 HomePageViewModel 管理遮罩和 MouseKeyMonitor
IsStarting / StartingChanged 正在执行 AcquireAsync(找窗、关联启动、网页版等待登录)。首页据此把启动按钮显示为"停止",网页版实例显示等待提示

6. Win32 实现(P1)

6.1 Win32GameWindow

csharp
/// <summary>在 UI 线程创建(WinEventHook 需要消息循环)</summary>
public class Win32GameWindow : IGameWindow
{
    public Win32GameWindow(nint hWnd);

    public nint Handle { get; }
    public int ProcessId { get; }                       // GetWindowThreadProcessId(Handle)
    public GameViewport Viewport =>                     // 沿用现有算法
        new(SystemControl.GetCaptureRect(Handle), DpiHelper.GetScale(Handle).Y);
    public virtual bool IsAlive => !_process.HasExited;
    public bool IsForeground => User32.GetForegroundWindow() == Handle;
    public bool IsMinimized => User32.IsIconic(Handle);
    public virtual bool RequiresForeground => true;
    public virtual void Activate();                     // 原 SystemControl.ActivateWindow(hWnd)
    public event EventHandler? ViewportChanged;
    public virtual void Dispose();                      // 解除钩子,释放 Process
}

WinEventHook 从 Dispatcher 搬进这里,同时做两处调整:

  • idProcess 传 ProcessId,不再订阅全系统。现在的钩子会收到所有进程的 LOCATIONCHANGE 事件(光标、插入符移动也算),全部在 UI 线程上过滤。
  • 去掉 WINEVENT_SKIPOWNPROCESS | WINEVENT_SKIPOWNTHREAD。钩子已经限定到游戏进程,不再需要这两个标志;网页版宿主和 BetterGI 同进程,也因此能收到事件。回调仍然按 hwnd == Handle && idObject == 0 过滤。

6.2 Win32RuntimeProvider

成员 行为
AcquireAsync 按配置关闭 HDR,然后 FindGenshinImpactHandle。找到就 AttachTo;没找到时,开启了关联启动则 StartFromLocalAsync 后 AttachTo,否则提示"未找到原神窗口"并返回 null
AttachTo(hWnd) Activate,确认窗口没有最小化,GameCaptureFactory.Create(配置的 CaptureMode) 并 Start,然后组装 GameRuntime。输入为 new Win32InputBackend(hWnd)
CloseGame 调用 SystemControl.CloseGameProcesses()(原 CloseGame 的实现):按进程名结束同 Session 的游戏进程

截图参数由 GameCaptureSettings.From(AllConfig) 集中构建,生成的就是现在 Dispatcher 里那个字典:autoFixWin11BitBlt、MinUpdateIntervalMs、UseCpuConvert。两个 Provider 都用它。

原 TaskContext.GetGenshinGameProcessNameList 移到 SystemControl.GetGenshinGameProcessNameList(),因为找窗函数 FindGenshinImpactHandle 仍在那里,DialogueOptionVoiceDiagnosticService 也直接使用它:

  • 已绑定 Win32 窗口时,返回 Win32GameWindow.ProcessName;
  • 否则返回候选进程名。

StartFromLocalAsync 同样留在 SystemControl,作为 L0 函数由 Provider 调用。

7. 网页版实现(P2)

7.1 命名实例

实例名规则由 WebViewInstanceStore 统一校验:

  • 去掉首尾空白后长度为 1~32;
  • 不含 \ / : * ? " < > | 和控制字符,不以 . 结尾(因此也排除了 . 和 ..);
  • 不能是 Windows 保留名(CON、PRN、AUX、NUL、COM19、LPT19),按第一个 . 之前的部分判断,CON.txt 同样被拒绝;
  • 不区分大小写,不能重名。

这套规则与 feat/yys 的 WebViewInstanceName 等价,唯一的区别是末尾空格:feat/yys 直接拒绝,这里去掉后接受。

WebViewInstanceStore 同时负责存储:

成员 说明
Root <exe目录>/WebView2Data/CloudGame,与 feat/yys 一致。WebView2Data 本身是 HtmlMask 等功能共用的用户数据目录,它们不会使用 CloudGame 子目录,各实例的用户数据互相独立
List() 列出 Root 下名称合法的子目录,目录就是实例名的唯一存储,不另建索引文件
GetDataFolder(name) Root/<name>,作为该实例的 WebView2 用户数据目录
Create(name) / Delete(name) 新建目录 / 删除目录。删除只在实例未运行时可用,UI 需要二次确认
IsRunning(name) Mutex.TryOpenExisting(MutexName(name))
MutexName(name) Local\BetterGI.WebView.<小写实例名的 SHA-256 前 16 位>。用哈希是为了避开非法字符和大小写差异

启动参数与实例身份:

  • WebViewInstanceLauncher.Launch(name) 的参数为 --instance webview --instance-name <name> start,路径取 Environment.ProcessPath。以 dotnet dll 方式运行时,参考 ChildSessionProcessLauncher.CreateBetterGiStartInfo。参数名与 feat/yys 一致。
  • CommandLineOptions 像处理 --instance 那样剥离 --instance-name,存为 InstanceName。InstanceContext 增加 InstanceName,只有 WebView 实例有值。
  • InstanceBootstrap 对 WebView 实例额外做两件事:
    • 没有 --instance-name 或名称不合法时提示并退出;
    • 获取实例名互斥体(进程内一直持有),已被占用时提示"实例「name」已在运行"并退出。带 --restart-from-pid 时最多等待 15 秒,让应用内重启的旧进程先退出;旧进程未释放就退出时按已获取处理(AbandonedMutexException)。
    • 提示使用 WinForms MessageBox,和 App 启动失败时的兜底方式一样。
  • WebViewInstanceLauncher.Launch 只启动已存在的实例(目录存在)且未在运行的实例,启动前先 IConfigService.Flush(),与桌面分身一致。
  • SystemControl.RestartApplication 对 WebView 实例追加 --instance-name。
  • 日志标识 BgiInstance 带上实例名,例如 WebView(小号A):S1:P1234:T…。
  • 实例名随 connection.open(ConnectionOpenRequest.InstanceName)登记到根实例,写入 InstanceEndpoint.InstanceName,webview.list 按实例名排序。Primary 和其他实例因此能按名称识别网页版实例。
    • 根实例不按名称判重,同名互斥只由互斥体保证。应用内重启时,旧进程的连接可能还没被根清理,在根上判重会误拒新进程。

网页版实例不创建 MainWindow,ApplicationHostService 改走 HandleWebViewActivation:

  • 总是调用 HomePageViewModel.HandleActivation → GameRuntimeService.StartAsync,与有没有 start 参数无关(例如应用内重启)。宿主窗口在 StartAsync 的同步阶段创建,并设为 Application.MainWindow,之后等待绑定。
  • 一条龙:依次调用 OneDragonFlowViewModel.OnNavigatedTo()(加载配置列表)和 LoadedCommand(读取命令行后执行),与页面被导航到时的顺序一致。
  • 配置组 / 任务进度:直接调用 ScriptControlViewModel 的对应方法。
  • 这些命令都由 StartGameTask 自行启动截图器,与上面的自动启动走同一个串行的 StartAsync,不会重复绑定。

7.2 宿主窗口 CloudWebHostWindow

约束:

  • 使用系统标题栏的普通 WPF Window,不用自绘标题栏(例如 FluentWindow + ExtendsContentIntoTitleBar)。自绘标题栏属于 Win32 客户区,会被截进画面,坐标也会偏。
  • WebView2 铺满客户区,不留边距,也不放工具栏。
  • 页面固定按 100% 渲染,不跟随系统缩放:
    • WebView2 默认按显示器 DPI 设置 RasterizationScale。150% 缩放时页面视口只有 1280×720 CSS 像素,页面自己的悬浮菜单、登录框等都会跟着放大,不同机器上的页面布局也不一样。
    • 宿主关闭 ShouldDetectMonitorScaleChanges,把 RasterizationScale 固定为 1,BoundsMode = UseRawPixels、Bounds 为客户区物理像素。页面视口因此始终是 1920×1080 CSS 像素,devicePixelRatio 为 1。
    • WPF 的 WebView2 控件不公开 CoreWebView2Controller,做不到这一点,所以宿主不用该控件,而是用 CoreWebView2Environment.CreateCoreWebView2ControllerAsync(Handle) 直接托管在窗口句柄上。宿主自己负责:客户区尺寸变化时更新 Bounds、窗口移动时 NotifyParentWindowPositionChanged、窗口激活时 MoveFocus(否则登录时键盘输入不进页面)、关闭时 Close。
    • 标题栏和边框属于非客户区,仍由系统按 DPI 绘制,不影响截图。
  • 客户区锁定为 1920×1080 物理像素,不随 DPI 缩放,截图和识图直接工作在 1080P 下:
    • ResizeMode="CanMinimize":不能拖动调整大小,也不能最大化;
    • SourceInitialized 中用 SetWindowPos 设置外框尺寸(当前边框 + 1920×1080),不走 DIP 换算,避免舍入误差;首次设置时按 Screen.FromHandle 的物理像素工作区居中,工作区放不下时记 warn,窗口会超出屏幕;
    • OnDpiChanged(拖到 DPI 不同的显示器)中重新锁定,因为 WPF 会按 DIP 缩放窗口。
  • 不设 Topmost。现有 WGC 对 Topmost 窗口不做客户区裁剪。
  • 网页版实例没有主界面,宿主就是唯一的窗口:由 WebPageRuntimeProvider 设为 Application.MainWindow,供对话框 Owner、UIDispatcherHelper.MainWindow 等使用。
  • 标题为"云原神 · {实例名}"。关闭宿主窗口即退出实例(Closed 中 BeginInvoke(Application.Shutdown)),CloseGame 也会走到这里。

初始化:

  1. 创建 CoreWebView2Environment,用户数据目录为 WebViewInstanceStore.GetDataFolder(实例名),并通过 AdditionalBrowserArguments 禁用遮挡与后台降频:
    text
    --disable-features=CalculateNativeWinOcclusion
    --disable-backgrounding-occluded-windows
    --disable-renderer-backgrounding
    --disable-background-timer-throttling
    • 前两项让宿主在被遮挡时照常渲染,否则 WGC 会一直拿到旧帧(7.4)。
    • 后两项让页面在后台不降低优先级,也不限流定时器。SDK 的 tapKey / click 用 setTimeout 控制按住时长,限流后按键时序会被拉长。
    • 每个实例的数据目录都是独立的,这些参数不会和 HtmlMask 等功能的 WebView2 环境冲突。
  2. controller = await environment.CreateCoreWebView2ControllerAsync(Handle),然后设置(ConfigureController)。页面必须按 1:1 铺满 1920×1080 客户区,截图与输入坐标才一致:
    • ShouldDetectMonitorScaleChanges = false,RasterizationScale = 1,BoundsMode = UseRawPixels:不跟随系统缩放(见上);
    • IsZoomControlEnabled = false,ZoomFactor = 1:禁止 Ctrl + 滚轮 / Ctrl + +/- 缩放(与 feat/yys 一致,下同);
    • IsStatusBarEnabled = false:左下角的链接状态栏会被截进画面;
    • AreDevToolsEnabled = RuntimeHelper.IsDebug:开发者工具只在 Debug 下可用;
    • IsWebMessageEnabled = true:输入桥依赖 WebMessage,显式开启。
  3. bridge = await WebView2InputBridge.InstallAsync(core):注入 SDK 和分发脚本,然后返回桥(7.5)。
  4. 导航到固定地址 https://ys.mihoyo.com/cloud/?autobegin=1(常量 CloudGameUrl,与 feat/yys 一致)。autobegin=1 让页面加载后自动开始游戏,不需要手动点"开始游戏"。不提供配置项:config.json 由所有实例共享,没法按实例区分。

就绪检测:

  • 用 DispatcherTimer 每秒调用一次 bridge.GetStatusAsync()。rtcDataChannelState == "open" 且 gameDataStarted == true 时,IsGameReady 为 true。
  • 已就绪之后,连续 3 次未就绪(约 3 秒)才判定为断开,避免单次查询失败就停掉任务。
  • 断开后只让截图器停止(Tick 发现 IsAlive == false),不自动刷新页面,也不自动重新绑定。网页版实例没有主界面、不响应热键,无法手动重新启动截图器,需要关闭宿主窗口(即退出实例)后从 Primary 重新启动。这条限制写在 CloudWebHostWindow 和 WebPageGameWindow.IsAlive 的代码注释里,避免以后有人误以为这里会自动重连。
  • IsGameReady 用 volatile 字段保存。Tick 线程通过 WebPageGameWindow.IsAlive 读取它。
  • WaitGameReadyAsync(ct) 等到就绪为止,不设短超时,因为登录和排队可能要几分钟。就绪返回 true,宿主被关闭返回 false,取消时抛 OperationCanceledException。未就绪的原因(例如页面更新导致 ClientCore 模块找不到)只在变化时记一条 Debug 日志。
  • 初始化失败(例如未安装 WebView2 Runtime)时弹出错误提示并关闭窗口,等待中的启动随之返回。

7.3 运行环境:WebPageGameWindow 与 WebPageRuntimeProvider

宿主是一个真实的顶层 Win32 窗口,而且 WebView 铺满客户区,所以 WebPageGameWindow 继承 Win32GameWindow,只覆写三个成员:

csharp
public sealed class WebPageGameWindow(CloudWebHostWindow host) : Win32GameWindow(host.Handle)
{
    /// <summary>宿主关闭后 IsGameReady 为 false</summary>
    public override bool IsAlive => host.IsGameReady;

    public override bool RequiresForeground => false;

    /// <summary>只从最小化还原,不抢前台</summary>
    public override void Activate()
    {
        if (IsMinimized)
        {
            User32.ShowWindow(Handle, ShowWindowCommand.SW_RESTORE);
        }
    }
}

以下成员直接继承,不需要单独实现:

  • Viewport、IsForeground、IsMinimized、ProcessId(即当前进程);
  • ViewportChanged:钩子限定到当前进程即可收到宿主事件。

WebPageGameWindow 还订阅宿主的 Closing 事件,在其中调用 InputHub.ReleaseAll()(input-hub.md 5.7.5 第 5 步)。Closing 在 UI 线程上触发,桥会直接投递 releaseAll(7.5),能在页面销毁前送达。

AcquireAsync 在就绪后先调用一次 window.Activate():宿主最小化时 WGC 拿不到帧,画面尺寸也不满足 SystemInfo 的要求。

WebPageRuntimeProvider.AcquireAsync:

text
1. host = 已有宿主 ?? 新建 CloudWebHostWindow,设为 Application.MainWindow 并 Show()   Provider 持有宿主
2. await host.WaitGameReadyAsync(ct)                           日志:等待云原神就绪(登录 / 排队)
3. window  = new WebPageGameWindow(host)
   capture = new GraphicsCapture(); capture.Start(host.Handle, GameCaptureSettings.From(config))
             与 Win32 路径一样在 UI 线程上创建(帧池用 Direct3D11CaptureFramePool.Create)
   input   = new WebSdkInputBackend(host.Bridge, () => window.Viewport.ScreenRect)
4. return new GameRuntime(GameRuntimeKind.WebPage, window, capture, input)

CloseGame 关闭宿主窗口(经 Dispatcher 切到 UI 线程),宿主关闭即退出实例。GameRuntime.Dispose 不关闭宿主,和"停止截图器不关闭本地游戏"一致。

7.4 截图

  • 固定使用 WGC(GraphicsCapture,不是 GraphicsCaptureV2),对宿主窗口句柄截图,参数取 GameCaptureSettings.From(config),不读 CaptureMode。
    • 不读 CaptureMode 是因为 config.json 由所有实例共享,网页版实例改了它会影响 Primary。
    • 网页版实例没有主界面,截图模式等 Win32 专属设置本来就看不到。
  • Fischless.GameCapture 不改。现有 WGC 已经会从窗口裁出客户区,而 7.2 的约束保证客户区就是 1920×1080 的游戏画面。
  • 宿主和 BetterGI 同进程,WGC 可以用 CreateForWindow 截取本进程的窗口。WebView2 的内容在宿主的子 HWND 中渲染,由 DWM 合成到宿主窗口,需要实测确认能截到(13.2 #3)。
  • WGC 在没有新帧时会返回缓存帧。如果 Chromium 在宿主被遮挡时停止渲染,任务会拿到旧画面而不自知。因此宿主通过浏览器参数禁用了遮挡降帧(7.2),测试见 13.2 #5。
  • 扩展方式:以后如果需要其他截图方式(例如 CoreWebView2.CapturePreviewAsync),新增一个 IGameCapture 实现,由 WebPageRuntimeProvider 选择,上层不受影响。

7.5 输入:接入与精简

Resources/JavaScript/ys-input-inject.js 与 E:\HuiTask\云原神\_analysis\ys-input-inject.js 的内容一致,只是换行符不同。但 csproj 里没有任何打包项,发布后找不到这个脚本。

现有 Core/Input/Backends/WebSdk/ 有 5 个文件,精简为 4 个:

文件 调整
WebSdkChannel.cs 删除,内容并入 WebSdkInputBackend
WebSdkInputBackend.cs 改为 WebSdkInputBackend : InputChannelBase, IInputBackend。自身就是通道,Foreground 和 Background 都返回 this;ReleaseAll() 同时实现两个接口。保留 Sdk 属性作为扩展口
WebView2InputBridge.cs 新增 static Task<WebView2InputBridge> InstallAsync(CoreWebView2 core),负责注入嵌入资源中的 SDK 和 BootstrapScript;新增 GetStatusAsync();投递改为非阻塞;删除 InvokeFailed
IWebInputBridge.cs 保留单个 Invoke 方法,修正注释里过时的脚本路径
WebKeyCodes.cs 不变

桥的实现要点:

  • 注入:ys-input-inject.js 设为 EmbeddedResource(LogicalName BetterGenshinImpact.Resources.JavaScript.ys-input-inject.js),InstallAsync 用 AddScriptToExecuteOnDocumentCreatedAsync 注入它和 BootstrapScript。宿主只需要调用这一个方法,构造函数和 BootstrapScript 改为私有。
    • 注入脚本也会在子 frame 中执行,而子 frame 没有 chrome.webview:SDK 原文不改,外面包一层 if (window.top === window);分发脚本同样只在顶层文档生效。
  • 调用:其他线程由 Dispatcher.Invoke 改为 Dispatcher.BeginInvoke,UI 线程上直接投递。
    • 同优先级的调用按 FIFO 执行,同一线程内顺序不变;
    • 任务线程不再阻塞,也不会在 UI 线程同步等待任务时死锁;
    • UI 线程直接投递,保证宿主 Closing 中发出的 releaseAll 能在页面销毁前送达;
    • 投递失败(例如页面已关闭)在 UI 线程内捕获并限频打 warn;桥已释放时 Invoke 同步抛 InvalidOperationException,与原契约一致。
  • 状态:GetStatusAsync() 执行 ExecuteScriptAsync,在页面内用 try/catch 包住 window.__ysInputInject?.status(),返回 WebSdkStatus(RtcDataChannelState, GameDataStarted, Error),其中 IsReady => RtcDataChannelState == "open" && GameDataStarted == true。
  • 错误:删除 InvokeFailed 事件。它现在没有订阅者,页面断开改由状态轮询发现。页面回报的调用错误限频打 warn。

扩展方式:

  • 键鼠接口表达不了的能力,直接调用 WebSdkInputBackend.Sdk.Invoke(方法名, 参数),不用改接口。
  • 文字输入本期不适配,只在 GlobalMethod.InputText 上加注释。注释写明三点:
    • 网页版下,写本机剪贴板对云端无效;
    • 需要时可以把"写本机剪贴板"换成 Sdk.Invoke("sendClipboard", text),或者直接调用 Sdk.Invoke("sendIme", text);
    • 两种方式都没有实测过。
  • WebSdkInputBackend 里相对移动和滚轮的系数 TODO 保留,P2 测试时一起标定。

接入:由 WebPageRuntimeProvider 创建后端,GameRuntimeService 负责 InputHub.Attach。落地后同步更新 input-hub.md 的 5.7.5 和 5.9。

7.6 界面

Primary 的首页新增「云原神网页版」卡片,只在 IsRoot 时显示:

  • 实例名下拉框,数据来自 WebViewInstanceStore.List(),并用 IsRunning 标出运行中的实例("名称(运行中)");
  • 刷新、启动、新建(PromptDialog 输入名称并校验)、删除(ThemedMessageBox.QuestionAsync 二次确认)四个按钮。
  • 实例正在运行时禁用启动和删除。运行状态在进入首页、启动实例 3 秒后、点击刷新时更新。

Primary 的首页:截图器启动过程中(GameRuntimeService.IsStarting),启动按钮显示为"停止";停止按钮提交停止请求,在本次启动结束后解绑(获取完成后直接放弃,不再绑定)。

网页版实例:

  • 不显示主界面,只显示宿主窗口(7.1、7.2)。任务由启动参数决定:默认只启动截图器(实时触发器照常工作),一条龙、配置组、任务进度由对应的命令行参数触发。
  • 因为没有主界面,原先为网页版准备的界面改动都已去掉:标题栏徽章、首页隐藏 Win32 专属设置、"等待云原神就绪"提示、热键页 InfoBar。
    • "同时启动原神"卡片里的配置仍按共享配置生效,例如"自动进入游戏"对网页版同样有效。
  • 要结束等待或结束实例,关闭云原神窗口即可。
  • 不响应任何热键,全局热键和键鼠监听都算在内:
    • HotKeyPageViewModel.IsHotKeyEnabled 在网页版实例中为 false,跳过全部 RegisterHotKey。热键原本在 HomePage 构造时初始化,网页版实例不创建页面,所以这里只是兜底;
    • 热键配置由所有实例共享,只能在 Primary 中编辑;
    • 这样也避开了同一 Session 中全局热键只能被先启动的实例注册的问题。

8. 生命周期、所有权与 DI

text
启动(首页按钮 / ScriptService.StartGameTask / 命令行 start)
  GameRuntimeService.StartAsync(ct)             切到 UI 线程;串行;已在运行时返回 true
    1. runtime = await provider.AcquireAsync(ct)    期间 IsStarting = true;返回 null 时整体返回 false
       获取期间收到停止请求(stopVersion 变化):释放 runtime,返回 false,不绑定
    2. TaskContext.Instance().Bind(runtime)         由 Viewport 构建 SystemInfo,记录 DpiScale 快照
    3. InputHub.Attach(runtime.Input)
    4. dispatcher.Start(runtime, interval)          加载触发器,订阅 ViewportChanged,启动定时器
    5. 触发 Started                                  HomePageViewModel 显示遮罩;Win32 实例订阅 MouseKeyMonitor
    第 2~4 步任一步抛异常:回滚已完成的步骤,然后 runtime.Dispose()

停止(按钮 / Tick 发现 !IsAlive 或截图器停止 / 切换截图模式)
  GameRuntimeService.StopAsync()
    0. 等待 _startLock;停止请求之前排队的 StartAsync 跳过绑定
    1. CancellationContext.Instance.Cancel()
    2. dispatcher.Stop()
    3. InputHub.ReleaseAll(),然后 Attach 未绑定后端     显式释放旧后端输入,再切换
    4. TaskContext.Instance().Bind(null)
    5. runtime.Dispose()
    6. 触发 Stopped                                  HomePageViewModel 隐藏遮罩,取消订阅
资源 创建方 释放方
GameRuntime Provider GameRuntimeService.StopAsync
IGameCapture、IGameWindow Provider GameRuntime.Dispose
IInputBackend Provider InputHub(被替换时)
CloudWebHostWindow WebPageRuntimeProvider 用户关闭 / CloseGame,关闭后退出实例
WebView2InputBridge CloudWebHostWindow 宿主关闭时
实例名互斥体 InstanceBootstrap 进程退出
csharp
services.AddSingleton<Win32RuntimeProvider>();
services.AddSingleton<IGameRuntimeProvider>(sp => sp.GetRequiredService<Win32RuntimeProvider>());
services.AddSingleton<IGameRuntimeProvider, WebPageRuntimeProvider>();
services.AddTransient<CloudWebHostWindow>();
services.AddSingleton<Func<CloudWebHostWindow>>(sp => () => sp.GetRequiredService<CloudWebHostWindow>());
services.AddSingleton<GameRuntimeService>();
services.AddSingleton<WebViewInstanceStore>();
services.AddSingleton<WebViewInstanceLauncher>();

GameRuntimeService 通过构造函数注入:

  • IEnumerable,按 InstanceType == WebView ? WebPage : Win32Window 从中选定;
  • InstanceBootstrap、TaskTriggerDispatcher、IConfigService、ILogger<GameRuntimeService>。

HomePageViewModel 在启动时一定会被创建:Primary 中 ApplicationHostService 总是先导航到首页;网页版实例没有页面,由 HandleWebViewActivation 直接从 DI 取出它。所以遮罩交给它管理,GameRuntimeService 不依赖任何 View。

9. TaskContext 拆分

使用次数按 TaskContext.Instance().成员 统计。

成员 使用情况 处理 去向
Init(IntPtr) 1 处(Dispatcher.Start) 删除 internal Bind(GameRuntime?),只由 GameRuntimeService 调用
IsInitialized { set; } 读 17 处 / 16 个文件;外部写 1 处(HomePageViewModel.Stop) 改为只读 => Runtime is not null
GameHandle { set; } 34 / 24 改为 private set,兼容保留 Bind 时记录,解绑后保留最近一次的句柄(与改造前一致);新代码用 Runtime.Window
DpiScale { set; } 27 / 22 改为 private set Bind 时记录快照
SystemInfo { set; } 119 / 64 改为 private set Bind 时由 Viewport 构建
GetGenshinGameProcessNameList() 3 处,全在 SystemControl 移出 见 6.2
LinkedStartGenshinTime 写 1 处;唯一的读取在 GameLoading.cs:80,已被注释 删除 这个状态无人读取
CurrentScriptProject 写 1 处,读 3 处(Http、Notification、HtmlMaskWindow) 移出 RunnerContext.CurrentScriptProject,生命周期不变
Config 296 / 120 暂留 只是转发配置。不加 Obsolete,避免产生 296 条警告;P3 迁移
Instance() 506 / 173 保留 静态入口
csharp
public class TaskContext
{
    public static TaskContext Instance() { /* 不变 */ }

    /// <summary>当前运行环境。截图器未启动时为 null</summary>
    public GameRuntime? Runtime { get; private set; }

    public bool IsInitialized => Runtime is not null;

    /// <summary>最近一次绑定的窗口句柄,解绑后保留。兼容旧调用点,新代码请使用 Runtime.Window</summary>
    public IntPtr GameHandle { get; private set; }

    /// <summary>绑定时的快照。解绑后保留最后一次的值,与现状一致</summary>
    public float DpiScale { get; private set; }

    /// <summary>绑定时由 Viewport 构建。解绑后保留最后一次的值,与现状一致</summary>
    public ISystemInfo SystemInfo { get; private set; }

    /// <summary>只是转发 ConfigService.Config。新代码请注入 IConfigService</summary>
    public AllConfig Config => /* 不变 */;

    internal void Bind(GameRuntime? runtime)
    {
        if (runtime is not null)
        {
            var viewport = runtime.Window.Viewport;
            SystemInfo = new SystemInfo(viewport); // 分辨率不合规时抛异常,Runtime 保持不变
            DpiScale = viewport.DpiScale;
            GameHandle = runtime.Window.Handle;
        }

        Runtime = runtime;
    }
}

ISystemInfo 同步删除 GameProcess、GameProcessName、GameProcessId。这三个成员共有 5 处使用,分别改为 Window.IsAlive、Win32GameWindow 和 Window.ProcessId。SystemInfo 的构造函数改为 SystemInfo(GameViewport),原来的最小化检查移到 AttachTo。

10. 调用点改造

位置 改造后 期
HomePageViewModel 启动 / 停止 / 手动选窗 调用 GameRuntimeService;遮罩和 MouseKeyMonitor 放在 Started / Stopped 中处理;删除 _hWnd 和 OnUiTaskStartTick(UiTaskStartTickEvent 只在注释代码里被触发) P1
ScriptService.StartGameTask、ArtifactOcrDialog 改为 App.GetService<GameRuntimeService>() 并调用 StartAsync(),不再反向获取 ViewModel。StartGameTask 是静态方法,调用方里还有直接 new 出来的 TaskRunner,无法构造注入,P3 再处理 P1
SystemControl.CloseGame() 保留为静态入口,转发到 GameRuntimeService.CloseGame()。调用点(ScriptService:419、OneDragonFlowViewModel 3 处)不改,因为 OneDragonFlowViewModel 也会被 TaskSettingsPageViewModel 直接 new,无法构造注入 P1
TaskTriggerDispatcher.Start Start(GameRuntime, interval):只负责加载触发器、订阅 ViewportChanged、启动定时器;截图器和钩子移出 P1
TaskTriggerDispatcher.Tick 每帧检查 Window.IsAlive;最小化和前台判断改用 Window 的成员;RequiresForeground == false 时,失焦也执行全部触发器;遮罩白名单改为"前台属于本进程或游戏进程,或者是 Idle" P1
TaskTriggerDispatcher.SyncMaskWindowPosition / GlobalGameCapture 分别改读 Window.Viewport 和 Runtime.Capture P1
OverlayMetricsService:411 改用 Window.IsAlive / Window.ProcessId P1
SystemControl.ActivateWindow() 转发到 Runtime.Window.Activate(),未启动时照旧抛出"请先启动BetterGI" P1
SystemControl.IsGenshinImpactActive / Minimized 不改,继续按 TaskContext.GameHandle 判断。绑定期间与 Window 的结果一致;解绑后 GameHandle 保留旧值,键鼠监听类热键的行为与改造前相同 —
SystemControl.IsGenshinImpactActiveByProcess RequiresForeground == false 时返回 true,否则保持原逻辑。它的 9 个调用点实际是在问"现在能不能操作游戏" P1
SystemControl.StartFromLocalAsync / CloseGameProcesses(原 CloseGame 的实现) 保留为 L0 函数,由 Win32RuntimeProvider 调用 P1
TaskControl.CheckAndActivateGameWindow RequiresForeground == false 时,只在最小化时调用 Activate() P1
CommandLineOptions / InstanceContext / InstanceBootstrap 支持 --instance-name,获取实例名互斥体 P2
ConnectionOpenRequest / InstanceEndpoint / InstanceRequestHandler / InstanceService 登记与展示实例名,webview.list 按实例名排序 P2
KeyMouseMacroPlayer 改走 InputHub.Foreground,网页版中也能回放(input-hub.md 第 6 版) P2
MaskWindow / HomePageViewModel 网页版跳过本地原神的注册表设置检查和安装目录读取 P2
SystemControl.RestartApplication、App.xaml.cs 日志标识 带上实例名 P2
BetterGenshinImpact.csproj 把 ys-input-inject.js 设为 EmbeddedResource P2
GlobalMethod.InputText 只加注释,说明网页版的替代方式(7.5) P2
HotKeyPageViewModel 网页版实例中不注册任何热键(兜底) P2
ApplicationHostService 网页版实例不创建 MainWindow,改走 HandleWebViewActivation(7.1) P2
HomePage Primary 的「云原神网页版」卡片;启动按钮改绑 IsTriggerButtonChecked(7.6) P2

以下调用点不用改:

  • MaskWindow / HtmlMaskWindow 定位;
  • DpiHelper、AssertUtils.CheckGameResolution、各任务的 LogScreenResolution;
  • KeyMouseHook;
  • GameCaptureRegion → DesktopRegion 点击链路;
  • 画中画定位。

它们只用 GameHandle 做几何计算。网页版的 Handle 也是真实的顶层窗口,并且 WebView 铺满客户区,所以算出来的结果与 Viewport 一致。网页版的坐标换算只是 0~65535 与屏幕像素之间的算术往返,不受多显示器影响。

11. 迁移步骤

P1:纯重构,只有 Win32 实现,除 12.1 所列差异外行为不变。

  1. 新增 L2 类型:GameRuntimeKind、GameViewport、IGameWindow、GameRuntime、IGameRuntimeProvider。
  2. 新增 Win32GameWindow(包含 6.1 的钩子调整)、Win32RuntimeProvider、GameCaptureSettings。
  3. 新增 GameRuntimeService 并完成 DI 注册。首页、ScriptService、OneDragonFlowViewModel 改为调用它。
  4. 按第 9 节收缩 TaskContext 和 ISystemInfo。
  5. 按第 10 节改造 Dispatcher、SystemControl、TaskControl。
  6. 编译,然后按 13.1 回归。

P1 实施说明:

  • 已完成步骤 1~5,解决方案编译通过。13.1 的手动回归尚未进行。
  • P1 只注册了 Win32RuntimeProvider。GameRuntimeService 找不到 WebView 实例对应的 WebPage Provider 时,会记录警告并回退到 Win32,行为与改造前相同。这个回退标了 TODO(P2),P2 注册 WebPageRuntimeProvider 后删除。

P2:网页版。

  1. 实例命名:--instance-name 参数、InstanceName、互斥体、重启参数、日志标识。
  2. WebViewInstanceStore、WebViewInstanceLauncher,以及 Primary 首页卡片。
  3. 按 7.5 精简输入,并把脚本改为嵌入资源。
  4. CloudWebHostWindow:初始化、注入、就绪检测。
  5. WebPageGameWindow、WebPageRuntimeProvider,并完成 DI 注册。
  6. 网页版实例界面(7.6)。
  7. 按 13.2 测试,并标定相对移动和滚轮系数。

P2 实施说明:

  • 已完成步骤 1~6,解决方案编译通过,命令行解析的单元测试(InstanceIpcProtocolTests)通过。步骤 7 的手动测试与系数标定尚未进行。
  • P1 的 Win32 回退已删除:找不到对应的 Provider 时 GameRuntimeService 直接抛异常。
  • 与设计的出入(正文已同步):
    • 获取期间收到停止请求时,获取完成后直接放弃,不绑定(原设计是绑定后再解绑,效果相同,但不会闪一下遮罩、也不会加载触发器)。网页版要立即结束等待,关闭宿主窗口即可。
    • 桥在 UI 线程上直接投递,其他线程才用 BeginInvoke(7.5),否则 Closing 里的 releaseAll 送不到页面。
    • SDK 注入外包 if (window.top === window),分发脚本也只在顶层文档生效(7.5)。
    • 网页版实例没有 start 参数也会自动打开宿主(7.1)。
  • 第 5 版调整(正文已同步):
    • 截图由 GraphicsCaptureV2 改为 GraphicsCapture(7.3、7.4)。
    • 宿主由"锁定 16:9、最小 1280×720"改为客户区固定 1920×1080 物理像素,ResizeMode="CanMinimize",DPI 变化后重新锁定(7.2)。删除了 WindowAspectRatioBehavior 的使用、WS_MAXIMIZEBOX 处理和 StateChanged 兜底。
    • 网页版实例不创建 MainWindow,宿主成为 Application.MainWindow,关闭即退出实例;命令行任务由 ApplicationHostService.HandleWebViewActivation 驱动(7.1、7.2)。原来的"随主窗口关闭"逻辑删除。
    • 删除了看不到的网页版界面改动:MainWindow 徽章(MainWindowViewModel.IsWebViewInstance / InstanceName)、首页隐藏 Win32 设置和"等待云原神就绪"提示、热键页 InfoBar。
    • RTC 断开后没有界面可以重新启动截图器,需要关闭宿主后从 Primary 重新启动(7.2)。
  • 新增文件:
    • GameTask/Runtime/WebPage/{WebPageGameWindow,WebPageRuntimeProvider}.cs
    • View/Windows/CloudWebHostWindow.xaml(.cs)
    • Service/Instance/{WebViewInstanceStore,WebViewInstanceLauncher}.cs
    • Model/CloudWebInstanceItem.cs
  • 删除文件:Core/Input/Backends/WebSdk/WebSdkChannel.cs。
  • 第 6 版调整(与 feat/yys 的目标对齐,正文已同步):
    • 启动参数 --name 改为 --instance-name;实例名登记到根实例的端点(7.1)。
    • 数据目录由 WebView2Instances/<name> 改为 WebView2Data/CloudGame/<name>(7.1)。旧目录不迁移,里面的登录态需要重新登录。
    • 地址改为固定的 https://ys.mihoyo.com/cloud/?autobegin=1,删除 CloudWebConfig(7.2)。已保存到 config.json 的 CloudWebConfig 字段在读取时被忽略。
    • 补上 WebView2 设置:禁止缩放、关闭状态栏、开发者工具只在 Debug 下可用(7.2)。
    • 宏回放改走 InputHub;网页版跳过本地原神设置检查(第 10 节)。

P3:渐进收敛。

  • 新代码改为注入 GameRuntimeService 并从 GameRuntime 取依赖;GlobalGameCapture 和 TaskContext.GameHandle 逐步下线。
  • TaskContext.Config 的调用点改为注入 IConfigService。
  • CurrentScriptProject 改为在 EngineExtend.InitHost 创建 Http / Notification / HtmlMask 时传入所属项目的权限。

12. 行为变化

12.1 Win32(P1 之后)

  • 触发频率的检查(必须大于 0)提前到获取运行环境之前,配置不合法时不会先去启动游戏。
  • (P2)获取运行环境期间(例如关联启动游戏时)首页启动按钮显示为"停止"。点击后提交停止请求,游戏仍会启动,但截图器不会绑定。原来这段时间按钮显示为"启动",也无法中止。
  • 停止后,InputHub 换回一个未绑定窗口的 Win32 后端:前台输入照常可用,后台输入只打 warn。
  • 前台是另一个 BetterGI 实例时,本实例的遮罩会隐藏。原来按进程名 "BetterGI" 放行,多个实例的置顶遮罩会叠在一起。
  • WinEventHook 只接收游戏进程的事件,UI 线程上的回调次数明显减少。
  • Tick 每帧检查 IsAlive(原来只在失焦或截图器停止时才检查)。
  • DPI 取值不变,仍然是绑定时的快照。

12.2 网页版与 Win32 的差异

场景 网页版
宿主失焦 截图和全部触发器照常运行,遮罩隐藏
任务中的 Sleep / Delay 不检查前台,不抢焦点;宿主最小化时自动还原
ActivateWindow() 只从最小化还原,不置前
截图方式 固定 WGC(GraphicsCapture),忽略截图模式配置
画面尺寸 客户区固定 1920×1080 物理像素,不能调整大小;页面固定按 100% 渲染,不跟随系统缩放
界面 没有主界面,只有宿主窗口;关闭宿主即退出实例
游戏退出判定 宿主窗口关闭,或 RTC 通道连续约 3 秒未就绪。之后只停止截图器,不自动刷新页面或重新绑定;要继续运行需关闭宿主,从 Primary 重新启动
热键 不响应任何热键
文字输入 inputText 未适配,写本机剪贴板对云端无效
遮罩 FPS 指标 不反映游戏帧率,因为页面在 WebView2 自己的进程中渲染
本地原神设置检查 跳过(亮度、灵敏度、小地图等注册表检查,以及安装目录读取)。网页版的游戏设置保存在云端
登录、排队、进入游戏 由用户在宿主窗口中完成;绑定之后,游戏内的流程与 Win32 相同

13. 手动测试

13.1 Win32 回归(P1)

# 场景 关注点
1 游戏已运行,启动 / 停止截图器 遮罩显示和隐藏,触发器正常
2 游戏未运行,分别开启、关闭关联启动 开启时启动并绑定游戏;关闭时提示"未找到原神窗口"
3 手动选窗 绑定到选中的窗口
4 运行中切换截图模式 自动重启,新模式生效
5 移动、缩放游戏窗口 遮罩跟随
6 运行中关闭游戏 截图器自动停止
7 任务中切走焦点,分别开关 RestoreFocusOnLostEnabled 开启时抢回焦点,关闭时暂停并重试
8 未手动启动截图器,直接运行调度组 StartGameTask 自动启动截图器,遮罩正常显示
9 一条龙结束后"关闭游戏" 游戏被关闭,截图器随之停止
10 在桌面分身实例中启动截图器 行为与改造前一致

13.2 网页版(P2)

# 场景 关注点
1 新建实例名:非法字符、保留名、与已有名称仅大小写不同 都被拒绝,并给出原因
2 启动实例「A」 只显示宿主窗口,没有主界面;页面自动开始游戏,排队后自动绑定;数据写入 WebView2Data/CloudGame/A;遮罩启动时日志里没有本地原神的亮度、灵敏度等检查结果
3 100% 与 150% 缩放的显示器,以及在两者之间拖动宿主 客户区始终为 1920×1080 物理像素;截图尺寸、遮罩位置与画面一致,截到的是游戏画面而不是黑屏
4 识图后点击、键盘移动、视角转动 坐标准确,按键不卡住
5 宿主失焦、被其他窗口完全遮挡 任务继续,截图画面不冻结
6 宿主最小化 任务的下一次 Sleep 自动还原窗口
7 任务运行中关闭宿主窗口 没有卡住的按键,实例进程退出
8 同时运行实例「A」「B」和 Primary 各自登录不同账号,任务、遮罩、输入互不干扰
9 实例「A」运行时再次启动「A」 Primary 禁用启动按钮;手动用命令行启动时提示已在运行
10 在实例「A」中通过设置页重启 重启后仍然是「A」,登录态保留
11 删除未运行的实例「B」 二次确认后,目录被删除,列表中不再出现
12 在网页版实例的宿主窗口处于前台时按热键 网页版实例没有反应;Primary 的热键照常工作
13 运行中断开 RTC(例如断网或长时间挂机被踢) 约 3 秒后截图器停止,页面保持原样,不自动刷新
14 用命令行启动网页版实例并附带一条龙 / 配置组参数 进入游戏后自动绑定,并执行对应任务
15 在宿主中按 Ctrl + 滚轮、Ctrl + +/-、F12 页面不缩放;Release 构建打不开开发者工具
17 100%、150% 缩放下分别打开宿主,并在两块缩放不同的显示器之间拖动;Debug 下在开发者工具里查看 devicePixelRatio、innerWidth 始终为 1 和 1920,页面布局与 100% 时相同;登录框能用键盘输入;移动窗口后下拉框位置正确
16 网页版中运行含键鼠脚本的配置组、AutoBoss 宏路线 输入落到页面,桌面前台窗口收不到按键

14. 决策记录

议题 决定 原因
命名 GameRuntime / IGameWindow / GameViewport / IGameRuntimeProvider / GameRuntimeService,实现按载体命名 仓库中 Session、Host、Instance、Launcher 已被占用;按载体命名能涵盖 Windows 云原神
运行环境的粒度 Win32Window 与 WebPage 两种 本地原神与 Windows 云原神的绑定方式相同
GameRuntime 用类还是接口 sealed 组合类 差异都在 Provider 和 IGameWindow 里
Provider 的启动接口 只保留一个 AcquireAsync "找不到就启动"的策略属于各个环境,编排层不需要按环境分支
网页版窗口 WebPageGameWindow 继承 Win32GameWindow 宿主是真实顶层窗口,几何、前台、钩子都可以复用,只覆写三个成员
WinEventHook 按游戏进程订阅,去掉 SKIPOWN 标志 一套实现同时适用于两种环境,也不再接收全系统的事件
启动在哪个线程执行 统一切到 UI 线程 宿主窗口和 WinEventHook 都依赖消息循环,而 StartGameTask 可能从后台线程调用
运行环境如何选择 由实例类型决定 网页版一定运行在独立实例中
多实例 按实例名运行多个进程,每个进程一个运行环境 进程级单例天然隔离;WebView2InputBridge 需要和 WebView2 同进程
实例名存储 每个实例一个目录,不建索引文件 数据目录本身就是实例名的唯一来源,不会出现不一致
同名互斥 命名互斥体,不走 IPC 不依赖 Primary 是否在线,也不需要改协议
截图方式 网页版固定 WGC(GraphicsCapture),由 Provider 决定 维护者指定用 WGC 而不是 V2;截图模式配置由所有实例共享,不能被网页版实例修改
宿主尺寸 客户区固定 1920×1080 物理像素 维护者确认;识图直接工作在 1080P 下,不需要缩放
页面缩放 固定 RasterizationScale = 1,不跟随系统;为此不用 WPF WebView2 控件,直接托管 CoreWebView2Controller 维护者要求;WPF 控件不公开 Controller。用 ZoomFactor = 1/DPI 抵消也能做到,但缩放比例会有舍入误差,也不影响右键菜单、滚动条等;反射取私有字段则会随 SDK 升级失效
网页版界面 不显示主界面,宿主即主窗口,关闭即退出 维护者确认;实例只用于自动化,任务由启动参数决定
画面裁剪 不裁剪,约定 WebView 铺满客户区 复用现有 WGC 的客户区裁剪,只做几何计算的旧调用点不用改
失焦策略 由 IGameWindow.RequiresForeground 声明 是否依赖前台是输入通道的属性,不是任务的属性
WebSdk 后端 后端兼任通道,脚本注入和状态查询收进桥 文件从 5 个减到 4 个,宿主只需调用一个方法
SDK 调用投递 BeginInvoke 非阻塞 调用本身就是 fire-and-forget;FIFO 保证顺序,还能避免死锁
页面断开如何发现 状态轮询,删除 InvokeFailed 事件 该事件没有订阅者;轮询还能覆盖长时间没有输入的情况
静态入口 保留,改为只读转发 500+ 调用点,一次性迁移风险大
TaskContext.DpiScale 绑定时快照 与现状一致
输入后端所有权 交给 InputHub;停止时 Attach 一个未绑定后端 沿用 InputHub"替换即释放"的规则
宿主窗口所有权 由 Provider 持有,GameRuntime 不关闭它 停止截图器不应关闭游戏
页面地址 固定 https://ys.mihoyo.com/cloud/?autobegin=1,不提供配置 维护者要求与 feat/yys 对齐;自动开始游戏,配置文件又是所有实例共享的
实例名参数与数据目录 --instance-name,WebView2Data/CloudGame/<name> 维护者要求与 feat/yys 对齐
实例名登记 写入根实例的端点,但根不判重 名称对 Primary 可见;判重交给互斥体,避免应用内重启时被误拒
宏回放 走 InputHub.Foreground,不区分后端 维护者要求;宏回放是对游戏的模拟操作
网页版实例的热键 全部不响应 维护者确认;同时避开全局热键的跨实例冲突
遮挡与后台降频 通过浏览器参数禁用 降频会影响自动化:WGC 会拿到旧帧,SDK 的按键时序会被拉长
RTC 断线 只停止截图器,并在代码注释中写明 维护者确认;自动重连涉及页面状态,本期不做
文字输入 只加注释 这个功能不一定会用到

15. 遗留事项

第 2 版的待确认事项都已落实,结论见第 14 节。剩下一项由维护者另行安排,本设计不处理:

  • 配置共享:所有实例读写同一个 User/config.json,没有跨进程锁,后写入的会覆盖先写入的。多个网页版实例同时运行时,这种情况更容易发生。

Docs Design Input Hub (`Docs/design/input-hub.md`)

BetterGI 输入层 InputHub 设计

状态:已实现(第 6 版,Win32 + WebSdk 输入层;手柄未实现) · 2026-09-30

第 6 版变更:宏回放(KeyMouseMacroPlayer)移出 L0 白名单,改走 InputHub.Foreground,网页版实例中也能回放。见 5.1 和第 8 节。

第 5 版变更:网页版宿主已由 GameRuntime 设计 实现,WebSdk 后端随之精简。

  • WebSdkChannel 并入 WebSdkInputBackend,后端兼任通道。
  • WebView2InputBridge 只保留 InstallAsync 入口,新增 GetStatusAsync,删除 InvokeFailed。
  • SDK 改为嵌入资源。
  • 后端改由 GameRuntimeService 在绑定运行环境时 Attach,不再经过 TaskContext.Init。
  • 相关段落(3、5.1、5.2、5.7、5.9、9.3)已同步。

1. 背景

BetterGI 需要在三个平台上模拟操作:

平台 可用输出
Windows 原神 a. SendInput 前台、b. PostMessage 后台、c. 手柄(本期只预留)
Windows 云原神 a、b、c(c 本期只预留)
云原神网页版 d. JS SDK,接口与键鼠一致

Windows 平台上前台和后台总是同时使用,不存在整体从前台切到后台的场景。需要整体切换的是 (a,b)、c、d 三组输出。

现状(main):业务代码直接调用 Simulation.SendInput 和 TaskContext.PostMessageSimulator,没有统一入口,因此无法整体切换到手柄或网页。

本设计基于 main。从 feat/yys 分支只取一样东西:页面注入脚本 ys-input-inject.js,也就是 d 的 JS SDK。C# 侧对 SDK 的调用按本设计重新实现。

2. 目标与非目标

目标:

  • 以 Windows 原神键鼠操作为基准,定义统一的输入接口。
  • 支持 (a,b) / c / d 三组输出整体切换。
  • 保留“同一任务里前台、后台混用”的写法。
  • 层级彻底分开:新增入口 InputHub,Simulation 降为原始层,业务代码不再直接引用。
  • 本期实现 Win32 和 WebSdk 两个后端;Gamepad 只预留类和接口,不实现。

非目标:

  • 网页版宿主:承载云原神页面的 WebView2 窗口、页面生命周期、截图。本设计只约定宿主接入输入层的方式(5.7.5)。
  • 游戏运行环境的整体抽象。TaskContext 保持 main 的形态,输入层只依赖窗口句柄。
  • 手柄的全部实现:驱动、映射表、视角标定。
  • 1080P 和游戏区域的坐标换算。这部分留在 Region 体系,不进入输入层。
  • ys-input-inject.js 本身的改造。

3. 术语

术语 含义
后端(Backend) 一组可以整体切换的输出:Win32 (a,b)、WebSdk (d)、Gamepad (c)
通道(Channel) 后端内部的前台、后台两路。只有 Win32 后端的两路不同,其余后端两路是同一个实例
原始层 直接调用系统 API、驱动或 SDK 的类,不感知后端切换
宿主 承载云原神网页的 WebView2 窗口,负责注入 SDK 并接入 WebSdk 后端

平台与后端的对应关系:

平台 后端 由谁接入
Windows 原神 / Windows 云原神 Win32(默认)、Gamepad(预留) Win32RuntimeProvider 创建,GameRuntimeService Attach
云原神网页版 WebSdk WebPageRuntimeProvider 创建,GameRuntimeService Attach

4. 改造前

4.1 层级

text
业务调用   Task / Trigger / JS 脚本 / Region 点击 / BvPage / Music / AutoFishing
             │                                  │
             │  每个调用点自己选择走哪条路,也常绕过映射层直接调用原始实现
             ├──────────────────────────────────┐
动作映射   InputSimulatorExtension            PostMessageSimulatorExtension
             │                                  │
原始实现   Simulation.SendInput               TaskContext.PostMessageSimulator
           Fischless.InputSimulator           PostMessageSimulator
             │                                  │
系统接口   SendInput                          PostMessage(hWnd)

4.2 类图

text
/* Detailed source-code truncated for AI context efficiency. */

注:ScriptPostMessage 即 Core.Script.Dependence.Simulator.PostMessage,也就是 JS 里的 new PostMessage()。

4.3 问题

  1. 没有统一入口。前台还是后台由调用点直接选原始类,无法整体切换到手柄或网页。
  2. 动作映射写了两份,行为不一致:前台支持中键和侧键,后台静默忽略;前台 SimulateAction 返回 void,后台返回自身。
  3. Win32 细节外泄:扩展键判断散落在扩展方法、GlobalMethod、Music 和宏回放里;是否发送 WM_ACTIVATE 要由调用方选择 *Background 变体。
  4. ReleaseAllKey 依赖 GetAsyncKeyState,只能覆盖 SendInput,经 PostMessage 按下的键释放不了。
  5. DesktopRegion、AutoSkipTrigger、JS PostMessage 都在字段里缓存了输入实例,一旦后端可以切换,这些引用就会过期。
  6. 已知缺陷:MouseSideButton2 和 JS 的 VK_XBUTTON2 发的都是 XButton(0x0001);PostMessageSimulator 的 lParam 扫描码写死为 0x1E。

5. 改造后

5.1 层级与依赖规则

text
L4 业务层    Task / Trigger / JS 脚本 / Region / BvPage / Music / AutoFishing
               │ 只依赖 L3 InputHub 和 L2 接口
L3 入口层    InputHub
               │ Backend / Foreground / Background / Attach / ReleaseAll
L2 抽象层    IInputBackend -> IInputChannel -> IKeyboardInput / IMouseInput
               │ InputChannelBase:按下状态、默认动作映射、warn 限频
L1 后端层    Win32InputBackend     Foreground: SendInputChannel   Background: PostMessageChannel
             WebSdkInputBackend    Foreground = Background = 自身(后端兼任通道)
             GamepadInputBackend   Foreground = Background: GamepadChannel(预留)
               │
L0 原始层    Simulation / Fischless.WindowsInput / PostMessageSimulator
             IWebInputBridge <- WebView2InputBridge -> ys-input-inject.js(页面内)
             手柄驱动(预留,本期不定义)

接入         IGameRuntimeProvider 创建后端 -> GameRuntimeService 绑定时 InputHub.Attach(runtime.Input)
             停止时 InputHub.Attach(new Win32InputBackend(IntPtr.Zero))

规则:

  • 只允许上层依赖下层。L4 只能看到 L3 和 L2。
  • L0 只被 L1 引用。白名单:
    • RelativeMouseMessageHandler:把根实例上用户的真实鼠标位移转发到桌面分身的桌面,不是对游戏的模拟操作,不应跟随后端切换。
  • 宏回放(KeyMouseMacroPlayer)是对游戏的模拟操作,和其他业务代码一样走 InputHub.Foreground,不区分后端。原来它作为"Win32 键鼠回放"在白名单里,网页版实例中会把按键发到桌面前台,第 6 版移出白名单。
  • 命名空间:BetterGenshinImpact.Core.Input,后端放在 Core/Input/Backends/{Win32,WebSdk,Gamepad}。

5.2 类图

text
/* Detailed source-code truncated for AI context efficiency. */

注:

  • IMouseInput 里的 XxxButtonDownUpClick 是类图里的简写,实际是 Down / Up / Click 三个方法,名字与 Fischless 相同。
  • <<reserved>> 表示本期只有预留实现,见 5.8。
  • YsInputInject 即 ys-input-inject.js 暴露在页面上的 window.__ysInputInject,图中只列出本设计用到的函数。

5.3 接口定义

text
/* Detailed source-code truncated for AI context efficiency. */

接口方法名沿用 Fischless 和现有扩展方法。迁移时只需要换调用对象,方法名不用改。

以下 Fischless 成员不收进新接口,C# 侧已检索确认没有业务调用:扩展键重载 KeyDown(bool?, VK) 等(改由通道内部处理)、TextEntry、ModifiedKeyStroke、*DoubleClick、HorizontalScroll、MoveMouseToPositionOnVirtualDesktop,以及 Keyboard 与 Mouse 之间的互相跳转。

5.4 InputChannelBase

  • 一个类同时实现 IInputChannel、IKeyboardInput、IMouseInput,Keyboard 和 Mouse 都返回 this。三个接口里的 Sleep 用显式接口实现。
  • 按下状态:加锁维护 HashSet<VK>,鼠标键记为 VK_LBUTTON 等。ReleaseAll 按这份记录逐个抬起(单个按键失败不影响其余按键),IsKeyDown 默认也查这份记录。
  • 鼠标键 VK:Keyboard.KeyDown/KeyUp/KeyPress 收到 VK_LBUTTON、VK_RBUTTON、VK_MBUTTON、VK_XBUTTON1、VK_XBUTTON2 时按对应鼠标键处理。GlobalMethod 和 Avatar 的字符串按键因此不再各自判断鼠标键。
  • 默认 SimulateAction:GIActions 经 SimulateKeyHelper.ToActionKey 得到 KeyId,再落到键盘或鼠标的原子操作。现有两套扩展方法的逻辑合并到这里,侧键 2 改为 XButton2。
  • OnKeyPress / OnMouseClick 默认实现为 Down + Up。子类可以覆写,保持各自的时序:SendInput 在一次调用里发送按下和抬起;PostMessage 发送 WM_CHAR,点击间隔 100 ms;WebSdk 交给页面按住。
  • KeyType.Hold 为按下、等待 1000 ms、抬起,与现有一致。
  • 遇到不支持的操作时调用 WarnUnsupported(operation),然后直接返回 this。同一种操作 10 秒内只打一次 warn。
  • 鼠标键枚举命名为 InputMouseButton { Left, Right, Middle, X1, X2 },避免和 WPF 的 System.Windows.Input.MouseButton、Fischless 的 MouseButton 重名。

5.5 Win32 通道行为

操作 SendInputChannel(前台) PostMessageChannel(后台)
键盘 包装 Simulation.SendInput.Keyboard。扩展标志按真实键盘判断(L0 的 ExtendedKeys):方向键、Insert / Delete / Home / End / PageUp / PageDown、右 Ctrl / 右 Alt、小键盘 /、NumLock、Break、PrintScreen、左右 Win、菜单键带扩展标志;左 Alt、左 Ctrl、泛指的 Alt / Ctrl 及其余按键不带。不使用 Fischless 的 IsExtendedKey,它把左 Alt 与泛指的 Alt / Ctrl 也算作扩展键,见第 8 节 包装 PostMessageSimulator。游戏不在前台时先补发 WM_ACTIVATE;lParam 按 5.6 计算
SimulateAction 默认实现 默认实现
IsKeyDown 覆写为 GetAsyncKeyState,与现状一致 按记录
MoveMouseBy 原样发送 warn
MoveMouseTo 原样发送(0~65535) 换算成客户区坐标,记为后续点击的 lParam,不移动真实光标
左键 / 右键 原样发送 PostMessage;没调用过 MoveMouseTo 时沿用 (16,16)
中键 / 侧键 / 滚轮 原样发送 warn
ReleaseAll 按记录抬起,再用 Simulation.ReleaseAllKey() 兜底 按记录发送 WM_KEYUP / WM_*BUTTONUP

hWnd 为 IntPtr.Zero(未 Attach)时,后台通道的所有操作都 warn。

Region.BackgroundClick 需要借用真实光标,这类组合操作由外部组合两个通道完成(前台移动光标,后台点击),不进入 PostMessageChannel。

5.6 PostMessage 键盘 lParam 修正

现状:所有按键的 lParam 都写死为 0x1E0001(按下)和 0xC01E0001(抬起),也就是扫描码固定为 0x1E(A 键)。

修正后:

text
scan       = MapVirtualKey(vk, MAPVK_VK_TO_VSC) & 0xFF
WM_KEYDOWN = 1 | (scan << 16)                           // 重复次数 1
WM_CHAR    = 1 | (scan << 16)
WM_KEYUP   = 1 | (scan << 16) | (1 << 30) | (1 << 31)   // 之前为按下 + 状态转换
  • 改在 L0:PostMessageSimulator 新增 MakeKeyLParam(VK vk, bool keyUp),替换所有写死的常量。
  • 扩展键位(bit 24)按 ExtendedKeys 设置,与前台通道的扩展标志规则相同:WM_KEYDOWN = ... | (1 << 24)(扩展键时)。
  • 只改 lParam。WM_CHAR 的 wParam 仍是 VK 值而不是字符码,本次不动。鼠标消息不受影响。

5.7 WebSdk 后端

5.7.1 组成

层 文件 说明
L1 Core/Input/Backends/WebSdk/WebSdkInputBackend.cs 后端兼任通道(继承 InputChannelBase),前后台都返回自身;把通道操作翻译成 SDK 函数调用
L1 Core/Input/Backends/WebSdk/WebKeyCodes.cs VK 到 KeyboardEvent.code 的映射表,内容取自 feat/yys 的 ToCode
L0 Core/Input/Backends/WebSdk/IWebInputBridge.cs 通用调用接口:Invoke(method, args)
L0 Core/Input/Backends/WebSdk/WebView2InputBridge.cs WebView2 实现:注入 SDK 与页面侧分发脚本、投递调用、查询 SDK 状态
L0 Resources/JavaScript/ys-input-inject.js 从 feat/yys 原样复制,csproj 设为 EmbeddedResource,由 InstallAsync 读取注入

SDK 的工作方式:脚本通过 webpack 拿到云原神页面内部的 ClientCore 模块,直接调用它的键鼠方法,经 RTC 数据通道发给云端。它不派发 DOM 事件,也不需要 Pointer Lock。SDK 自己保存指针位置(setAbsPosition),鼠标按键和相对移动默认使用这个位置。

5.7.2 调用协议

C# 侧只有一个通用方法:按函数名调用 window.__ysInputInject 上的任意函数。

csharp
public interface IWebInputBridge
{
    /// <summary>
    /// 调用页面 SDK window.__ysInputInject[method](...args)。
    /// 不等待执行结果,页面侧按到达顺序串行执行;失败通过实现类的事件回报。
    /// 可选参数请直接省略,不要传 null:JSON 的 null 不会触发 JS 的参数默认值。
    /// </summary>
    void Invoke(string method, params object[] args);
}

消息格式:

text
C# -> 页面   { "channel": "bgi.input", "method": "tapKey", "args": ["KeyF"] }
页面 -> C#   { "channel": "bgi.input.error", "method": "tapKey", "error": "rtc data channel is not open: closed" }   // 只在失败时发送

页面侧分发脚本(WebView2InputBridge.BootstrapScript):

js
(() => {
  if (window.__bgiInputBridge) return;
  window.__bgiInputBridge = true;
  let queue = Promise.resolve();
  window.chrome.webview.addEventListener("message", ({ data }) => {
    if (!data || data.channel !== "bgi.input") return;
    const { method, args = [] } = data;
    // 串行执行:tapKey、click 等是异步函数,并发执行会打乱按键顺序
    queue = queue
      .then(() => {
        const sdk = window.__ysInputInject;
        if (!sdk || !Object.hasOwn(sdk, method) || typeof sdk[method] !== "function") {
          throw new Error(`unknown sdk method: ${method}`);
        }
        return sdk[method](...args);
      })
      .catch(e => window.chrome.webview.postMessage({
        channel: "bgi.input.error", method, error: String(e?.message ?? e)
      }));
  });
})();

WebView2InputBridge:

  • 入口:static Task<WebView2InputBridge> InstallAsync(CoreWebView2 core),在 UI 线程、首次导航前调用。它注入嵌入资源中的 SDK(外包 if (window.top === window),只在顶层文档执行)和分发脚本,然后返回桥。构造函数与分发脚本都是私有的。
  • Invoke:序列化成上面的 JSON,调用 PostWebMessageAsJson,不等页面执行。UI 线程上直接投递,其他线程用 Dispatcher.BeginInvoke(同优先级 FIFO,任务线程不阻塞)。投递失败限频打 warn;桥已释放时同步抛 InvalidOperationException。
  • GetStatusAsync():在页面内执行 window.__ysInputInject?.status()(异常转成 error 字段),返回 WebSdkStatus(RtcDataChannelState, GameDataStarted, Error),IsReady 表示 RTC 为 open 且已进入游戏。宿主每秒轮询一次,用来判断就绪和断开。
  • 收到 bgi.input.error 时限频打 warn 日志。原来的 InvokeFailed 事件没有订阅者,已删除,页面断开改由状态轮询发现。

灵活性:

  • 新增或调整 SDK 函数时,C# 桥接和分发脚本都不用改。

  • WebSdkInputBackend.Sdk 对外暴露桥接。遇到键鼠接口表达不了的需求,可以直接调用 SDK 的其他函数,例如 sendIme(文字输入)、setDefaults(调整按住时长)、look:

    csharp
    if (InputHub.Backend is WebSdkInputBackend web)
    {
        web.Sdk.Invoke("setDefaults", new { keyHoldMs = 80 });
    }

5.7.3 操作映射

通道操作 SDK 调用 说明
KeyDown(vk) keyDown(code) code 由 WebKeyCodes 映射,表外的键 warn
KeyUp(vk) keyUp(code)
KeyPress(vk) tapKey(code) 按住时长用 SDK 的 keyHoldMs,默认 60 ms
MoveMouseBy(dx, dy) mouseMove(dx * sx, dy * sy) 位置用 SDK 当前位置;sx、sy 为偏差系数,见 5.7.4
MoveMouseTo(absX, absY) setAbsPosition(x, y),再 mouseMove(0, 0) x、y 为画面内 0~1
左 / 右 / 中键 Down、Up mouseDown(button) / mouseUp(button) button 为 "left"、"right"、"middle"
左 / 右 / 中键 Click click(button) 按住时长用 SDK 的 clickHoldMs,默认 35 ms
侧键 不调用,warn SDK 不支持
VerticalScroll(n) scroll(n * 120) 方向和步长未实测,见 5.7.4
ReleaseAll() releaseAll() 同时清空本地按下记录
SimulateAction 默认实现 落到上面的操作
IsKeyDown 不调用 查本地按下记录

5.7.4 实现要点

text
/* Detailed source-code truncated for AI context efficiency. */
  • 坐标:MoveMouseTo 收到的是桌面 065535 坐标。先按 PrimaryScreen.WorkingArea 换算成物理像素(与 DesktopRegion 的归一化方式一致),再相对 canvasRect() 归一化,截断到 01。canvasRect 由宿主提供,是游戏画面在屏幕上的物理像素矩形,应与截图区域一致。1080P 换算仍由 Region 体系完成,和桌面端一样。
  • 时序:Invoke 返回时页面还没执行。页面串行执行,tapKey / click 会在页面内按住一段时间,后续指令排队等待。按键后接识图时,延时要把页面内的按住时长算进去。
  • 异常:映射表以外的按键、侧键 warn 后忽略。SDK 执行失败(如 RTC 数据通道未打开)由页面回报,桥接限频打 warn。桥已释放时 Invoke 直接抛 InvalidOperationException,ReleaseAll 会吞掉这个异常。

5.7.5 宿主接入(已由 GameRuntime 实现)

宿主即 CloudWebHostWindow,接入由 game-runtime.md 第 7 节实现,输入层不依赖宿主的其他实现:

  1. CloudWebHostWindow 在 CoreWebView2 初始化后、首次导航前调用 bridge = await WebView2InputBridge.InstallAsync(core)。
  2. WebPageRuntimeProvider 在游戏就绪后创建 new WebSdkInputBackend(host.Bridge, () => window.Viewport.ScreenRect),画面区域与截图区域一致。
  3. GameRuntimeService 绑定时调用 InputHub.Attach(runtime.Input)。
  4. 页面断开由宿主轮询 GetStatusAsync() 发现,截图器随之停止,不自动重连。
  5. WebPageGameWindow 在宿主 Closing 时调用 InputHub.ReleaseAll();Closing 在 UI 线程上触发,releaseAll 会被直接投递。

5.7.6 SDK 已知问题

ys-input-inject.js 的 getLocationFromCode 把所有以 Left / Right 结尾的 code 都当作左右修饰键,ArrowLeft、ArrowRight、BracketLeft、BracketRight 的 location 会是 1 或 2,而不是 0。按非目标约定本次不改脚本,是否影响云端识别需要实测。

5.8 预留后端:Gamepad

GamepadInputBackend + GamepadChannel 本期只建类,不实现:

  • 实现 L2 接口,Foreground 和 Background 返回同一个通道实例。
  • 所有操作都走 WarnUnsupported("手柄后端未实现"),然后返回 this。ReleaseAll 和 Dispose 为空实现。
  • 本期没有任何地方创建它,也不加配置项。

实现时要遵守的约定(接口层面已经留好位置,本期不做):覆写 SimulateAction,把 GIActions 直接映射成手柄输入;键盘按键按键位配置反查成 GIActions 再映射;MoveMouseBy 映射到右摇杆,是阻塞调用;原始鼠标按键和 MoveMouseTo 都 warn 后忽略。最后一条的原因是坐标点击由“移动 + 左键”两步组成,只忽略移动的话,左键会被映射成普攻。

5.9 生命周期与切换

text
进程启动
   └─ InputHub 持有 Win32InputBackend(IntPtr.Zero)      // 前台可用,后台 warn

GameRuntimeService 绑定运行环境(见 game-runtime.md 第 8 节)
   ├─ Win32:   InputHub.Attach(new Win32InputBackend(hWnd))
   └─ 网页版:  InputHub.Attach(new WebSdkInputBackend(bridge, () => window.Viewport.ScreenRect))

GameRuntimeService 解绑运行环境
   ├─ InputHub.ReleaseAll()
   └─ InputHub.Attach(new Win32InputBackend(IntPtr.Zero))

InputHub.Attach(backend)
   1. 替换当前后端
   2. 旧后端 Dispose()
  • 切换:(a,b)、c、d 之间的切换就是 Attach 一个新后端,不另设 Switch。只在没有任务运行时调用。
  • 所有权:InputHub 持有当前后端,替换时释放旧后端。桥接等外部资源由创建者(宿主)持有,后端的 Dispose 不释放它们。
  • 未 Attach 时:与现状对应。现在 Simulation.SendInput 随时可用,而 PostMessageSimulator 在 Init 之前为 null;改造后前台照常可用,后台 warn,不再抛空引用异常。
  • 引用规则:单个任务内可以持有通道引用。跨任务存活的对象不能缓存,必须每次从 InputHub 取,包括静态字段、DI 单例、Region 树、Trigger。
  • 线程:后端引用用 volatile 加 Interlocked.Exchange,按下状态加锁。
  • 多实例:每个 BetterGI 进程各有一份 InputHub 状态,互不影响。

6. 调用方式对照

场景 改造前 改造后
前台动作 Simulation.SendInput.SimulateAction(GIActions.Jump) InputHub.Foreground.SimulateAction(GIActions.Jump)
前台长按 Simulation.SendInput.SimulateAction(GIActions.ElementalSkill, KeyType.Hold) InputHub.Foreground.SimulateAction(GIActions.ElementalSkill, KeyType.Hold)
前台按键 Simulation.SendInput.Keyboard.KeyPress(VK.VK_ESCAPE) InputHub.Foreground.Keyboard.KeyPress(VK.VK_ESCAPE)
扩展键 Simulation.SendInput.Keyboard.KeyDown(false, VK.VK_CONTROL) InputHub.Foreground.Keyboard.KeyDown(VK.VK_CONTROL)
视角 Simulation.SendInput.Mouse.MoveMouseBy(dx, 0) InputHub.Foreground.Mouse.MoveMouseBy(dx, 0)
桌面绝对定位 Simulation.SendInput.Mouse.MoveMouseTo(x, y).LeftButtonClick() InputHub.Foreground.Mouse.MoveMouseTo(x, y).LeftButtonClick()
键盘跳到鼠标 Simulation.SendInput.Keyboard.Mouse.MiddleButtonClick() InputHub.Foreground.Mouse.MiddleButtonClick()
按键状态 Simulation.IsKeyDown(vk) InputHub.Foreground.IsKeyDown(vk)
后台动作 TaskContext.Instance().PostMessageSimulator.SimulateAction(GIActions.OpenMap) InputHub.Background.SimulateAction(GIActions.OpenMap)
后台动作(激活) _postMessageSimulator.SimulateActionBackground(GIActions.PickUpOrInteract) InputHub.Background.SimulateAction(GIActions.PickUpOrInteract)
后台按键(激活) _postMessageSimulator.KeyPressBackground(VK.VK_SPACE) InputHub.Background.Keyboard.KeyPress(VK.VK_SPACE)
后台点击 TaskContext.Instance().PostMessageSimulator.LeftButtonClickBackground() InputHub.Background.Mouse.LeftButtonClick()
前后台混用 PostMessageSimulator.SimulateAction(MoveLeft, KeyDown) 加 SendInput.Mouse.MiddleButtonClick() InputHub.Background.SimulateAction(MoveLeft, KeyDown) 加 InputHub.Foreground.Mouse.MiddleButtonClick()
释放 Simulation.ReleaseAllKey() InputHub.ReleaseAll()
1080P 点击 GameCaptureRegion.GameRegion1080PPosClick(x, y) 不变;内部的 DesktopRegion 改走 InputHub.Foreground.Mouse
JS 脚本 keyPress("VK_E")、new PostMessage().KeyPress("VK_E") 脚本不变;GlobalMethod 走 Foreground,JS PostMessage 走 Background
JS BvPage page.Keyboard 类型为 IKeyboardSimulator,page.Mouse 类型为 IMouseSimulator 分别换成 IKeyboardInput、IMouseInput,指向 InputHub.Foreground;常用方法同名,未收录的成员见第 8 节

网页版下 Foreground 和 Background 是同一个通道,上表的写法不用改,后台调用会自动落到 SDK。

7. 迁移步骤

基于 main,直接替换入口,不保留转发。

  1. 新增 Core/Input:L3、L2、Win32 后端、WebSdk 后端(5.7.1 的全部文件),以及 Gamepad 的预留类。从 feat/yys 复制 ys-input-inject.js,在 csproj 中设为 PreserveNewest。Microsoft.Web.WebView2 在 main 上已经引用,不新增依赖。

  2. 从下往上改基础设施:

    位置 改法
    PostMessageSimulator 按 5.6 修正键盘 lParam
    TaskContext Init(hWnd) 中把 PostMessageSimulator = Simulation.PostMessage(GameHandle) 改为 InputHub.Attach(new Win32InputBackend(hWnd)),删除 PostMessageSimulator 属性。签名不变
    DesktopRegion 去掉 IMouseSimulator 字段和构造参数,静态和实例方法都改走 InputHub.Foreground.Mouse
    Region.BackgroundClick 前台移动真实光标,后台点击,再移回原位
    GlobalMethod 走 InputHub.Foreground,去掉扩展键分支,修正 VK_XBUTTON2。InputText 加注释,内容见下方
    JS PostMessage 宿主 每次调用时取 InputHub.Background,去掉构造时的缓存
    BvFlow 服务、BvPage 走 InputHub.Foreground;BvPage.Keyboard / Mouse 直接换成新接口类型
    Music 两个 transport 分别包装 InputHub.Foreground / Background,每次调用时取
    AutoFishing 构造注入从 IInputSimulator 改为 IInputChannel;单测里的 FakeInputSimulator 改为 FakeInputChannel
    AutoSkipTrigger 去掉 _postMessageSimulator 字段,每次调用时取 InputHub.Background
    MouseKeyMonitor 长按空格 / F 连发的定时器从 Simulation.PostMessage(_hWnd) 改为 InputHub.Background
    ClickExtension、BvSimpleOperation.FindFAndPress、GridScroller、AutoArtifactSalvageTask.OpenInventory 参数与返回值类型换成 IInputChannel / IKeyboardInput / IMouseInput
    缓存 Simulation.SendInput 的字段 private readonly InputSimulator input = Simulation.SendInput; 改为属性 private IInputChannel input => InputHub.Foreground;,每次访问都取当前通道

    InputText 的注释:

    csharp
    // 通过剪贴板 + Ctrl+V 输入文字,只面向键鼠后端。
    // 手柄场景用不到文字输入,这里不做手柄适配;将来如果需要,请单独实现,不要依赖 Ctrl 的映射。
  3. 用正则批量替换业务调用点(数量是文本匹配数,包含定义和注释,仅供估算工作量):

    查找 替换 约数
    Simulation.SendInput.SimulateAction( InputHub.Foreground.SimulateAction( 318
    Simulation.SendInput.Keyboard.Mouse. InputHub.Foreground.Mouse. 少量
    Simulation.SendInput.Keyboard. InputHub.Foreground.Keyboard. 105
    Simulation.SendInput.Mouse. InputHub.Foreground.Mouse. 208
    Simulation.IsKeyDown( InputHub.Foreground.IsKeyDown( 3
    TaskContext.Instance().PostMessageSimulator.SimulateAction( InputHub.Background.SimulateAction( 20
    TaskContext.Instance().PostMessageSimulator.Key(Press|Down|Up)Background( InputHub.Background.Keyboard.Key$1( 27
    Simulation.ReleaseAllKey() InputHub.ReleaseAll() 37

    通过局部变量或字段持有 PostMessageSimulator 的调用需要手工修改,比如 AutoSkipTrigger 的大部分调用。扩展键重载 KeyDown(false, vk) 替换后会编译失败,按第 6 节改成单参数即可。

  4. 删除 InputSimulatorExtension、PostMessageSimulatorExtension 和 DesktopRegion(IMouseSimulator) 构造函数。Simulation 保留,作为 L0 使用。

  5. 守住层级(可选):加一个源码扫描单测,要求 Simulation、PostMessageSimulator、Fischless.WindowsInput 只出现在 Core/Input/Backends/Win32/** 和白名单文件中。

实施结果:步骤 1~4 已完成,步骤 5 未做。业务代码中 Simulation、PostMessageSimulator、Fischless.WindowsInput 类型已只剩白名单文件和注释。OverrideController、KeyBindingTextBox 里原本就有未使用的 using Fischless.WindowsInput;,与模拟输入无关,未改动。

8. Windows 下的行为变化

  • 后台通道在游戏不在前台时自动补发 WM_ACTIVATE。之前只有 *Background 变体会发。

  • InputHub.ReleaseAll() 会额外释放经 PostMessage 按下的键。

  • 后台通道遇到中键、侧键、移动、滚轮时,从静默忽略改为 warn(限频)。

  • 前台 SimulateAction 的返回值从 void 改为通道本身,源码兼容。

  • 修复侧键 2 实际发送 XButton1 的问题。

  • PostMessage 键盘消息的 lParam 扫描码改为按键计算,之前固定为 0x1E。

  • DesktopRegion(IMouseSimulator) 构造函数移除。JS 里的无参构造和 (w, h) 构造不受影响。

  • JS 可见的 BvPage.Keyboard / Mouse 换成新接口,脚本能用的成员变少:扩展键重载、TextEntry、ModifiedKeyStroke、*DoubleClick、HorizontalScroll,以及键盘和鼠标之间的互相跳转。已确认接受。

  • 前台扩展标志改为按真实键盘判断。原来有两套不一致的规则,各错一半:

    • SimulateAction、GlobalMethod、Music 对 IsExtendedKey 列表里的键一律不带扩展标志。左 Alt 因此正确,方向键、Insert 等则可能被识别成小键盘按键。
    • 其他直接发送的调用点(拾取键、秘境移动键、Avatar.KeyDown(string) 等)走 Fischless 自动判断。方向键等因此正确,左 Alt、泛指的 Alt / Ctrl 则会被识别成右 Alt / 右 Ctrl。

    现在两条路径统一使用 ExtendedKeys,行为与真实键盘一致。默认键位中只有“呼出鼠标”的左 Alt 在原列表里,它走 SimulateAction,前后都不带扩展标志,因此默认配置下没有变化;只有改绑到上述按键的用户会受影响。

  • 后台 PostMessage 按键消息对扩展键设置 lParam 第 24 位,之前恒为 0。

  • 键盘接口收到鼠标键 VK 时按鼠标键处理。TaskControl.TrySuspend 暂停时会释放所有按下的键,用户此时按住的鼠标键现在也会被释放;之前对鼠标键发的是无效的键盘消息。

  • InputHub.Attach 不执行全局按键释放;运行环境停止或绑定失败后的回滚会在替换后端前显式 ReleaseAll。

  • (第 6 版)宏回放改走 InputHub.Foreground:

    • 扩展标志改用 ExtendedKeys。原来对 IsExtendedKey 列表里的键强制不带扩展标志,其余键也不带,相当于所有键都不带;现在方向键、Insert 等带扩展标志,左 Alt 仍然不带。只有录制里包含这些扩展键的宏受影响。
    • 侧键仍然忽略,与原来一致。

9. 手动测试

9.1 PostMessage 扫描码修正

只影响经 PostMessage 发送的键盘消息,鼠标不受影响。以下每项在 Windows 原神和 Windows 云原神上各跑一遍,因为云原神客户端对按键消息的处理方式和本地客户端不同。

# 场景 覆盖的按键 关注点
1 自动剧情(AutoSkipTrigger),分别在开启和关闭后台运行时测,游戏窗口不在前台时也要测 空格、交互键(默认 F 或自定义)、W、S 对话能推进;“选第一个选项”“随机选项”和默认策略都能选中;没有气泡时交互键能触发
2 自动演奏,输入模式选 PostMessage 后台 演奏会用到的 21 个键 每个音都正确,没有漏音;结束后没有卡住的键
3 领取尘歌壶奖励(GoToSereniteaPotTask) M、W / A / S 按住与松开、X、Esc 地图能打开;按住移动后能停下;至少跑一个带移动调整的洞天
4 领取邮件、历练点、纪行、前往冒险家协会 Esc、F1、F4 界面能正常打开和关闭
5 对话选项(ChooseTalkOptionTask) 空格 对话能推进
6 自定义键位:把“打开地图”改绑到 Tab、F 区键、一个符号键(如 ;),再跑第 3 项 非字母键 后台能触发。之前扫描码固定为 A 键,非字母键最容易暴露差异
7 JS 脚本 new PostMessage() KeyPress、KeyDown → sleep → KeyUp 单击和长按都生效,松开后不卡键

9.2 其他行为变化(Windows)

# 场景 关注点
1 运行尘歌壶任务时切到其他窗口 后台按键在补发 WM_ACTIVATE 后仍然生效,也不会抢走焦点
2 JS 脚本 new PostMessage().KeyDown("VK_W") 后直接停止脚本 角色停止移动,确认 ReleaseAll 释放了经 PostMessage 按下的键
3 把某个动作绑定到鼠标侧键 2,再用 keyPress("VK_XBUTTON2") 测一次 游戏里收到的是侧键 2,不是侧键 1
4 JS 脚本调用 BvPage 的 Keyboard.KeyPress、Mouse.LeftButtonClick、Mouse.MoveMouseBy 行为与改造前一致
5 开启“长按空格 / F 连发”宏,在游戏内长按空格和 F 连发正常,松开后停止
6 扩展标志:① 把拾取键改绑到方向键或 Insert,跑自动拾取;② 把“向前移动”改绑到方向键上,跑一段路径追踪;③ 默认左 Alt 呼出鼠标的任务(如需要点击界面的一条龙任务);④ 脚本 keyPress("VK_LEFT") 与 new PostMessage().KeyPress("VK_LEFT") 游戏识别为对应的按键,而不是小键盘按键或右 Alt
7 任务运行中用快捷键暂停,暂停时按住鼠标左键 暂停后左键被释放,恢复后任务正常继续
8 回放一段包含 WASD、左 Alt、方向键和视角转动的键鼠宏 与改造前一致;方向键识别为方向键而不是小键盘按键

9.3 云原神网页版(宿主接入后)

# 场景 关注点
1 键盘:WASD 移动、空格、F、Esc、数字键切人、Shift 冲刺、方向键 响应正确,松开后不卡键;方向键同时验证 5.7.6 的 location 问题
2 视角:moveMouseBy(500, 0),或跑一段依赖 CameraRotateTask 的路径追踪,与桌面端对比转过的角度 用来标定 RelativeMoveScaleX / RelativeMoveScaleY
3 坐标点击:GameRegion1080PPosClick 点击界面按钮,如派蒙菜单项、背包格子 点击位置准确
4 滚轮:在背包、商店列表里滚动 方向和步长与桌面端一致,用来确认 5.7.4 的滚轮 TODO
5 原后台调用:自动剧情、尘歌壶任务 经 Background 通道落到 SDK,行为与前台调用一致
6 断开网络或刷新页面后发送输入 日志里有 SDK 报错(限频);约 3 秒后宿主判定断开,截图器停止
7 侧键 只打 warn,不中断任务
8 宏回放:配置组里的键鼠脚本、JS keyMouseScript.run、AutoBoss 的宏路线 输入落到页面,桌面前台窗口收不到任何按键

10. 决策记录

议题 决定 原因
切换单位 后端:(a,b) / c / d;前后台是后端内部的通道 Win32 的前后台总是同时使用
入口 新类 InputHub,Simulation 降为 L0 层级彻底分开
旧入口 直接替换,不做转发 避免名不副实,例如 SendInput.SimulateAction 实际走了手柄或网页
迁移基线 main feat/yys 只提供 JS SDK
运行环境 不引入运行环境抽象,TaskContext 保持 main 的形态;输入层只依赖窗口句柄,网页版由宿主接入 整体运行环境设计尚未对齐
切换方式 InputHub.Attach(IInputBackend),不设 Switch(kind) 各后端构造参数不同(句柄、桥接),直接传实例最简单
坐标 MoveMouseTo 保留桌面绝对坐标;1080P 换算留在 Region 体系 键鼠层不感知游戏分辨率,输入也会用在游戏之外
不支持的操作 warn(限频)后忽略,不抛异常 所有后端统一
方法名 沿用 Fischless 和现有扩展方法名 迁移只换调用对象
未采用的入口名 GameInput、Simulation.Foreground、VirtualInput、SimInput 等 GameInput 与微软 GDK 的 API 重名;Simulation.Foreground 会让原始层和抽象层挤在同一个类里
BvPage 的 JS 可见类型 直接换成新接口 接受脚本侧可用成员变少
PostMessage 扫描码 本次一起修 扫描码写死为 0x1E 是明确的缺陷
扩展标志 按真实键盘判断(ExtendedKeys),前台 SendInput 与后台 PostMessage 共用 原来的“一律不带”和 Fischless 自动判断各错一半:前者弄错方向键等,后者弄错左 Alt
InputCaps 删除 不支持的操作统一 warn,没有调用方需要查询能力
手柄 只预留类和接口,不实现 本期只落地 Win32 和 WebSdk
手柄上的原始鼠标键 warn 后忽略 否则“移动 + 左键”的坐标点击会变成普攻
InputText 不做手柄适配,加注释提醒 这个场景和手柄无关
SDK 调用协议 通用 Invoke(method, args),页面侧串行分发 C# 和分发脚本不随 SDK 函数增减而改动;特殊需求可直接调用 SDK 其他函数
网页版指针位置 由 SDK 保存(setAbsPosition),C# 不重复维护 SDK 的按键和相对移动默认使用它
按住时长 用 SDK 默认值(keyHoldMs 60 ms、clickHoldMs 35 ms),需要时通过 setDefaults 调整 不在 C# 侧重复定义
网页版相对移动、滚轮 预留系数,暂按 1:1 透传,加 TODO 注释 尚未实测

11. 待确认

  1. 网页版的文字输入。GlobalMethod.InputText 用本机剪贴板加 Ctrl+V 实现,云端能不能拿到本机剪贴板没有验证。SDK 提供了 sendIme,可以通过 WebSdkInputBackend.Sdk.Invoke("sendIme", text) 调用,不需要改接口。是否在 InputText 里对网页版走这条路?

Docs Design Mask Window (`Docs/design/mask-window.md`)

遮罩窗口(MaskWindow)统一设计

状态:已实现(分支 refactor/20260930-mask-window,实现与设计的差异见第 11 节) · 2026-09-30

1. 背景

遮罩窗口是覆盖在游戏窗口上的透明窗口。外部对它的使用分四类:

使用 当前入口 写入方
绘制图形(识别框、线、文字、技能 CD) VisionContext.Instance().DrawContent → MaskWindow.OnRender 触发器、独立任务、Region 体系
窗口显隐、置顶、跟随游戏窗口 MaskWindow.Instance() 上的方法 TaskTriggerDispatcher、HomePageViewModel、TaskRunner、个别任务
地图点位状态(是否在大地图、视口) 把 DataContext 强转成 VM,直接调用具名控件 MapMaskTrigger、TaskRunner
状态、日志、指标等常驻 UI XAML 绑定 MaskWindowViewModel 配置、各服务

前三类绕过了 MVVM 和 DI,直接操作 View。本设计把它们收敛为三个接口。第四类只做 DI 和 MVVM 规范化。

调用规模:VisionContext.Instance() 78 处,分布在 35 个文件;MaskWindow.Instance*() 20 处,分布在 8 个文件。

2. 现状问题

2.1 绘制链路

  1. 每次绘制都同步切到 UI 线程。DrawContent 的每个 Put*、Remove*、ClearAll 都会调用 MaskWindow.Instance().Refresh(),也就是 Dispatcher.Invoke(InvalidateVisual)。调用方有两类:
    • TaskTriggerDispatcher.Tick 里的触发器。调用时正持有 _locker 和 _triggerListLocker,UI 线程一忙,截图循环就被拖慢;如果 UI 线程上有代码在等这两把锁,就会死锁。
    • 独立任务线程。每画一次框,都要同步等待 UI 线程处理完。
  2. 重绘过多:
    • 去重条件写反了。prevRect.Count == 0 && newRect.Equals(prevRect[0]) 永远不成立,Count 为 0 时还会越界。PutOrRemove*List 只要参数非空就判定为有变化。结果是 SkillCdTrigger 在主界面时每帧都触发一次重绘。
    • Equals 不完整。TextDrawable 只比较坐标,RectDrawable 不比较画笔,所以即使修好去重也会漏掉变化。
    • 重绘范围是整个窗口。OnRender 每次都把准星和全部图形重画一遍,并新建 Pen、Brush、FormattedText。而 AllowsTransparency 窗口每次重绘本身开销就大。
    • 还有其他重绘来源。MaskWindowConfig 任意属性变化,以及 VM 的 IsInBigMapUi、IsMapPointPickerOpen 变化,都会执行 Dispatcher.Invoke(InvalidateVisual)。AutoLeyLineOutcropTask 在 Put 之后还会再调用一次 Refresh()。
  3. 存在数据竞争。字典里存的就是调用方传入的那个 List<T>。AutoArtifactSalvageTask 把列表放进去以后继续往里 Add,还用 TextList.GetOrAdd 绕过了刷新;与此同时,UI 线程在 OnRender 里枚举这些列表。由此产生的异常被 catch 吞掉,表现为偶发丢帧。
  4. 坐标系混用。矩形和直线在生产方就除以了 TaskContext.DpiScale(主屏 DPI),文字则在 OnRender 里除以窗口的 PixelsPerDip。AutoArtifactSalvageTask 用已经换算过的矩形坐标生成文字,高 DPI 下坐标被除了两次。
  5. 渲染器要了解业务:
    • OnRender 通过字符串 "SkillCdText" 特判技能 CD 的样式。
    • DisplayRecognitionResultsOnMask 一个开关同时控制调试框和技能 CD。
    • AutoLeyLineOutcropTask 为了显示自己的提示,会临时改写这个用户配置。
  6. 静态耦合:
    • VisionContext 是静态单例,里面只有一个可替换的 DrawContent,另一个属性 Drawable 没有任何地方使用。
    • DrawContent 本是业务模型,却直接依赖 View。单测里的 FakeDrawContent 没有覆写 PutOrRemoveTextList,一旦调用就会抛出"MaskWindow 未初始化"。

2.2 窗口访问

  1. View 是静态单例。_maskWindow 在构造函数里赋值,窗口关闭后也不清空;窗口创建之前调用 Instance() 会直接抛异常。

  2. 显隐策略分散在 5 处:

    • TaskTriggerDispatcher.Tick:每帧 BeginInvoke(Show)。
    • HomePageViewModel:new、Show、Hide、Close。
    • TaskRunner。
    • AutoLeyLineOutcropTask:设置 Topmost、Show、BringToTop。
    • HideSelf:编辑模式守卫。

    HtmlMaskWindow 在同样的位置被平行调用。CustomHtmlMaskService 在计时器线程上读取 IsVisible。

  3. 遮罩窗口被当作通用 Dispatcher 使用。maskWindow.Invoke/BeginInvoke 的效果与 UIDispatcherHelper 相同,而且会吞掉取消异常。

  4. 越层访问。MapMaskTrigger 和 TaskRunner 把 DataContext 强转成 MaskWindowViewModel 来修改 IsInBigMapUi,还直接调用具名控件的方法 PointsCanvasControl.UpdateViewport。

2.3 MVVM / DI

  1. VM 是在 XAML 里 new 出来的,不经过 DI,依赖只能通过 App.GetService 获取。配置同时从 Config 和 TaskContext.Instance().Config 两处读取。
  2. HomePageViewModel 自己创建并持有 View(new MaskWindow())。
  3. VM 和 View 互相越界:
    • VM 接收 SizeChangedEventArgs,并直接调用 Application.Current.Dispatcher。
    • code-behind 读取 TaskContext、修改 VM 状态、计算点击穿透,还负责打印系统信息和检查游戏设置。

3. 目标与非目标

目标:

  • 业务代码只依赖接口,不引用 MaskWindow、MaskWindowViewModel、Dispatcher。
  • 绘制请求只写数据,不阻塞,也不切线程。UI 侧把多次请求合并,按帧率上限重绘,而且只重绘发生变化的那一层。
  • 遮罩窗口的创建、显隐、置顶、跟随只有一个入口。
  • MaskWindow 回到标准 MVVM:窗口由 DI 构造,状态放在 VM,交互通过绑定和 Behavior 实现。
  • 删除 VisionContext、DrawContent、*Drawable,以及 MaskWindow 的全部静态成员。

非目标:

  • HtmlMaskWindow 内部不改,只是改为由宿主统一驱动它的显隐和位置。
  • 日志框(已通过 IRichTextBox 解耦)、画中画、点位选择器的业务逻辑都不动。
  • 拆分 MaskWindowViewModel,可以留作后续工作。

4. 总体结构

4.1 遮罩窗口内的区域

窗口内有多个互相独立的区域,每个区域有自己的名字和数据来源:

区域 View 元素 数据来源 本次改动
绘制(识别框、线、文字、技能 CD) MaskWindowDrawingLayer IMaskWindowDrawingBoard 新增,替代 OnRender
准星 MaskWindowCrosshairLayer MaskWindowConfig 新增,替代 OnRender
地图点位(大地图、小地图) PointsCanvas、MiniMapPointsCanvas IMaskWindowMapState + IMaskMapPointService 视口改为绑定
状态、日志、指标 现有 AdjustableOverlayItem 配置、IRichTextBox、OverlayMetricsService 不改

窗口本身的生命周期由 IMaskWindowHost 负责。

4.2 分层

text
业务层   Trigger / Task / Region / BgiYoloPredictor / SkillCd …
           │ 只依赖 Core/Mask 里的接口;可在任意线程调用,立即返回
接口层   IMaskWindowDrawingBoard       IMaskWindowHost            IMaskWindowMapState
         画什么                   窗口怎么显示                地图点位状态
           │                        │                          │
实现层   MaskWindowDrawingBoard        MaskWindowHost             MaskWindowMapState
         不可变快照 + Changed     唯一持有 MaskWindow         不可变快照 + Changed
           │                        │ 内部使用 UiCoalescer      │
VM 层    MaskWindowViewModel(DI 单例):用 UiCoalescer 拉取快照 → Drawing / IsInBigMapUi / 视口
           │ 绑定
View 层  MaskWindow(DI 构造)
           ├─ MaskWindowCrosshairLayer   只在准星配置变化时重绘
           ├─ MaskWindowDrawingLayer     只在绘制快照变化时重绘
           └─ 现有 XAML(状态、日志、指标、点位…)

依赖规则:

  • Core/Mask 不引用 View 或 ViewModel。
  • 只有 View 层引用 MaskWindow 类型。MaskWindowHost 放在 View/Mask/ 下,View 层以外的代码只能通过它接触遮罩窗口。
  • 遮罩相关的新代码跨线程时只能经过 UiCoalescer,不再调用 Dispatcher.Invoke。

4.3 绘制时序

mermaid
sequenceDiagram
    participant T as 触发器/任务线程
    participant D as MaskWindowDrawingBoard
    participant C as UiCoalescer
    participant VM as MaskWindowViewModel
    participant L as MaskWindowDrawingLayer
    T->>D: Set(group, shapes)
    D->>D: 与旧内容相同则直接返回
    D-->>C: Changed → Request()(已有待执行则忽略)
    Note over T: 立即返回,不等 UI
    C->>VM: BeginInvoke(Render),距上次 ≥ 33ms
    VM->>VM: Drawing = source.Current
    VM-->>L: 绑定 → AffectsRender
    L->>L: OnRender(只画这一层)

5. 接口定义

命名空间为 BetterGenshinImpact.Core.Mask,目录为 Core/Mask/。不用 Core.MaskWindow 的原因见 10.1。

术语约定:

  • Layer:只指 View 中负责渲染的元素。
  • Group:一组按名字整体替换的图形,也就是原来 DrawContent 的 key。

5.1 绘制:IMaskWindowDrawingBoard

csharp
/// 遮罩窗口绘制入口(业务侧)。线程安全,所有方法立即返回,不等待 UI。
public interface IMaskWindowDrawingBoard
{
    /// 整组替换。shapes 为空等价于 Clear;内容与当前相同则不做任何事。
    void Set(MaskWindowDrawingGroup group, IReadOnlyList<MaskWindowDrawingShape>? shapes);
    void Clear(MaskWindowDrawingGroup group);
    void ClearAll();
}

public static class MaskWindowDrawingBoardExtensions
{
    public static void Set(this IMaskWindowDrawingBoard board, MaskWindowDrawingGroup group, MaskWindowDrawingShape shape)
        => board.Set(group, new[] { shape });

    /// Dispose 时清除该组,用于"任务期间显示、结束即清"
    public static IDisposable Scope(this IMaskWindowDrawingBoard board, MaskWindowDrawingGroup group);
}

MaskWindowDrawingBoard 的实现要点:

  • 存储:按 group.Name 存进 ImmutableDictionary<string, MaskWindowDrawingEntry>。写入时加锁,锁内只做比较和替换。
  • Set:
    • 先把 shapes 拷贝成数组,调用方之后再改原列表也不会影响已存的内容。
    • 再用 SequenceEqual 和旧内容比较(图形是 record,按值相等)。相同就直接返回。
    • 不同则替换内容、版本号加 1、触发 Changed。
  • Changed 在写入线程上同步触发,订阅方在回调里只能做 O(1) 的操作,例如 UiCoalescer.Request()。
  • NullMaskWindowDrawingBoard.Instance 是空实现,用于单测和没有 DI 的环境。

5.2 分组与图形

csharp
public enum MaskWindowDrawingKind
{
    Recognition, // 识别结果(调试可视化):受「在遮罩上显示识别结果」开关和统一样式控制
    Feature,     // 功能提示(技能 CD、地脉 OCR 区域):遮罩可见就显示,使用图形自带样式
}

/// 绘制分组。按 Name 存储,Kind 决定显示规则
public readonly record struct MaskWindowDrawingGroup(
    string Name, MaskWindowDrawingKind Kind = MaskWindowDrawingKind.Recognition)
{
    public static implicit operator MaskWindowDrawingGroup(string name) => new(name);
}

public abstract record MaskWindowDrawingShape;
public sealed record MaskWindowDrawingRect(Rect Bounds, MaskWindowDrawingStroke? Stroke = null) : MaskWindowDrawingShape;
public sealed record MaskWindowDrawingLine(Point From, Point To, MaskWindowDrawingStroke? Stroke = null) : MaskWindowDrawingShape;
public sealed record MaskWindowDrawingText(string Text, Point Origin, MaskWindowDrawingTextStyle? Style = null) : MaskWindowDrawingShape;

public readonly record struct MaskWindowDrawingStroke(Color Color, double Thickness = 2);

/// 为 null 的字段使用 MaskWindowConfig 的识别结果样式。
/// FontSize 单位为捕获像素;Background 不为空时绘制圆角底色;Numeric 表示使用数字字体(Fgi)
public sealed record MaskWindowDrawingTextStyle(
    Color? Foreground = null, double? FontSize = null, Color? Background = null, bool Numeric = false);

约定:

  • 坐标:统一用游戏捕获区域的物理像素,与截图 Mat 的坐标系相同。换算成 DIP 只在 MaskWindowDrawingLayer 里做一次,使用遮罩窗口自身的 DPI。生产方不再读取 TaskContext.DpiScale。
  • 样式:图形没带样式时,使用 MaskWindowConfig 里的识别结果样式。开启"统一样式"后,Recognition 组忽略图形自带的样式,这与现状一致。
  • 类型:Point、Rect 来自 System.Windows,Color 是 System.Windows.Media.Color。它们都是值类型,不持有 GDI 资源。

5.3 读端:IMaskWindowSnapshotSource

csharp
/// 渲染侧读取,仅 MaskWindowViewModel 使用
public interface IMaskWindowSnapshotSource<out T>
{
    T Current { get; }       // 不可变快照,UI 线程直接读取,无需加锁
    event Action? Changed;   // 可在任意线程触发,只表示"有新版本",由订阅方自行合并
}

public sealed record MaskWindowDrawingSnapshot(long Version, IReadOnlyList<MaskWindowDrawingEntry> Entries);
public sealed record MaskWindowDrawingEntry(MaskWindowDrawingGroup Group, IReadOnlyList<MaskWindowDrawingShape> Shapes);

MaskWindowDrawingBoard 同时实现 IMaskWindowDrawingBoard 和 IMaskWindowSnapshotSource<MaskWindowDrawingSnapshot>,DI 中用同一个单例注册这两个接口。

5.4 窗口:IMaskWindowHost

csharp
public interface IMaskWindowHost
{
    void Attach(nint gameHandle);                  // UI 线程。首次调用时创建窗口,绑定游戏窗口并显示
    void Detach();                                 // UI 线程。隐藏窗口并解除绑定,窗口实例保留复用
    void Close();                                  // UI 线程。关闭并释放窗口(程序退出时)
    void ReportGameWindow(GameWindowState state);  // 任意线程,每帧可调;宿主去重后决定显隐、置顶、跟随
    MaskWindowState State { get; }                 // 缓存值,任意线程可读
    event EventHandler<MaskWindowState>? StateChanged; // 在 UI 线程触发,附属遮罩订阅
}

/// 描述游戏窗口,不是遮罩窗口,所以不加 MaskWindow 前缀
public readonly record struct GameWindowState(
    bool IsCapturing, bool IsActive, bool IsMinimized, bool IsForegroundOwnedByBetterGiOrGame, RECT Bounds);

public readonly record struct MaskWindowState(bool IsVisible, Rect Bounds);

显隐策略集中在 MaskWindowHost 中,按优先级从高到低判断,命中一条即停止:

条件 遮罩
未 Attach 隐藏
布局编辑模式 显示
IsCapturing = false 隐藏
IsMinimized 保持不变
IsActive 显示;从非活动转为活动时置顶一次
前台窗口属于 BetterGI 自身或游戏进程,或没有前台窗口(按进程 ID 判断) 保持不变
其他 隐藏

Bounds 变化时重新定位遮罩。位置按游戏窗口所在显示器的 DPI 换算,与 HtmlMaskWindow 一样使用 DpiHelper.GetScale(gameHandle)。

实现要点:

  • 在调用 ReportGameWindow 的线程上算出期望状态。和上次的期望状态相同就直接返回,不同时才调用 UiCoalescer.Request()。
  • 在 UI 线程上应用:窗口由构造注入的 Func<MaskWindow> 工厂创建,然后依次执行 Show/Hide、BringToTop、设置位置,最后触发 StateChanged。
  • CustomHtmlMaskService 和 HtmlMaskWindow.ShowAll/HideAll/UpdateAllPositions 改为订阅 StateChanged,TaskTriggerDispatcher 不再平行调用它们。

5.5 地图点位:IMaskWindowMapState

csharp
public interface IMaskWindowMapState
{
    /// 任意线程调用。字段为 null 表示不修改;多次调用合并为一个快照
    void Update(bool? isInBigMap = null, Rect? bigMapViewport = null, Rect? miniMapViewport = null);

    /// 退出大地图并清空两个视口。在任务开始、地图遮罩关闭时调用
    void Reset();
}

public sealed record MaskWindowMapSnapshot(bool IsInBigMap, Rect BigMapViewport, Rect MiniMapViewport);
  • MaskWindowMapState 同时实现 IMaskWindowSnapshotSource<MaskWindowMapSnapshot>。
  • MapMaskTrigger 现有的合并逻辑(PendingUiUpdate、TryScheduleUiApply)拆成两部分:字段合并移到 MaskWindowMapState.Update,UI 调度交给 VM 的 UiCoalescer。
  • PointsCanvas 和 MiniMapPointsCanvas 新增 Viewport 依赖属性,绑定到 VM。UpdateViewport 改为私有方法,在依赖属性的回调中调用。

5.6 UiCoalescer

csharp
/// 把任意线程、任意频率的 Request 合并成 UI 线程上的一次 apply
public sealed class UiCoalescer(Action apply, TimeSpan minInterval = default,
                                DispatcherPriority priority = DispatcherPriority.Render)
{
    public void Request();
}
  • 单飞:用 Interlocked 保证同一时刻最多只有一个待执行的回调。
  • 限频:两次 apply 的间隔不小于 minInterval,不足时推迟到满足间隔再执行。
  • 不阻塞:只用 BeginInvoke。标志位在调用 apply 之前复位,apply 执行期间新到的请求不会丢失。
  • 没有 Application.Current 时(例如单测)直接同步执行。
  • 位置:Helpers/Ui/UiCoalescer.cs。绘制用 33 ms(约 30 fps),地图状态和宿主用 0。

6. View / ViewModel 规则

  1. 构造方式:
    • DI 注册 services.AddSingleton<MaskWindowViewModel>()、services.AddTransient<MaskWindow>(),另外注册一个 Func<MaskWindow> 工厂供 MaskWindowHost 使用。
    • MaskWindow(MaskWindowViewModel vm) 在构造函数里设置 DataContext,同时删除 XAML 中的 <Window.DataContext>。
    • VM 的依赖全部通过构造函数注入:IConfigService、OverlayMetricsService、IMaskMapPointService、两个 IMaskWindowSnapshotSource<>、ILogger<>。配置统一从 Config 读取。
  2. 删除 MaskWindow 的这些成员:_maskWindow、Instance()、InstanceNullable()、IsExist()、Refresh()、Invoke()、BeginInvoke()、HideSelf()、RefreshPosition*()、LogBox、OnRender。
  3. 渲染层:
    • MaskWindowDrawingLayer 和 MaskWindowCrosshairLayer 都是 FrameworkElement,依赖属性设为 AffectsRender,并设置 IsHitTestVisible=False。两者放在根 Grid 的最底层,跨满所有行列。
    • Pen 和 Brush 冻结后缓存,缓存键是"颜色 + 粗细"。
    • VM 把 MaskWindowConfig.Recognition* 这组配置合成一个 MaskWindowDrawingStyle 记录,只在这些属性变化时更新。
    • 准星的每个配置项分别绑定到一个依赖属性,其他配置的变化不会触发准星重绘。
  4. 点击穿透:
    • VM 提供两个属性:IsClickThrough = !(OverlayLayoutEditEnabled || IsInBigMapUi),IsCrosshairVisible = CrosshairEnabled && IsClickThrough。
    • WindowClickThroughBehavior 绑定 IsClickThrough,据此设置 WS_EX_TRANSPARENT。
  5. 视图事件改为命令。IsVisibleChanged、窗口最小化、PointsCanvas.ViewportChanged 三个事件通过 EventTrigger + InvokeCommandAction 调用 VM 的 CloseTransientPopupsCommand。
  6. VM 不接收 WPF 类型。窗口尺寸由 Behavior 以 Size 推送给 VM,布局提交事件经 EventArgsConverter 转换成纯数据。
  7. code-behind 只保留纯视图逻辑:日志框裁剪、MapLabelSearchWindow 定位。PrintSystemInfo 和 GameSettingsChecker 移到 HomePageViewModel.Start 中,在 Attach 之后调用。

7. 线程规则

  • 业务线程调用 IMaskWindowDrawingBoard、IMaskWindowHost.ReportGameWindow、IMaskWindowMapState 时只写数据,调用立即返回,在持锁代码里调用也是安全的。
  • 进入 UI 线程只能通过 UiCoalescer(内部使用 BeginInvoke)。遮罩相关代码禁止使用 Dispatcher.Invoke。
  • UI 线程只读取不可变快照,不需要加锁。
  • MaskWindowViewModel 中还有两处也要改为经 UiCoalescer 进入 UI 线程:
    • OverlayMetricsService.MetricsUpdated 的回调里同步调用了 UIDispatcherHelper.Invoke。
    • FpsInspector 回调直接写 Fps。

8. 归属、获取方式与调用对照

8.1 归属

text
DI 容器
 ├─ MaskWindowDrawingBoard 单例 ··················· 所有者,生命周期 = 进程
 │   ├─ 读端 → MaskWindowViewModel(构造注入)→ MaskWindowDrawingLayer
 │   └─ 写端 → Win32RuntimeProvider(构造注入)
 │        └─ new GameRuntime(..., drawingBoard, mapState) ···· 借用,Dispose 不释放
 │             └─ TaskContext.Bind(runtime) → SystemInfo.DesktopRectArea → 截图区域树继承
 ├─ MaskWindowMapState 单例 ······················ 同上:读端 VM,写端经 Provider 放进 GameRuntime
 └─ IMaskWindowHost 单例 ························· 窗口显示策略,不属于运行环境,只做构造注入
  • 绘制内容和地图点位状态以这次运行环境的捕获像素为坐标,放在 GameRuntime 上,与 Capture、Input 并列:没有运行环境就没有可画的地方。
  • 实例由 DI 拥有,GameRuntime 只借用。GameRuntimeService.Stop 解绑时 ClearAll 并 Reset,下一次运行环境不会带着旧内容。
  • IMaskWindowHost 管的是遮罩窗口本身(创建、显隐、跟随),和运行环境的生命周期无关,不放进 GameRuntime。

8.2 获取方式

调用方 获取方式
DI 构造的类型 构造注入 IMaskWindowHost;读端注入 IMaskWindowSnapshotSource<>
手上有截图区域(触发器 OnCapture、识别、画框) region.DrawingBoard,或直接 region.DrawSelf/DrawRect/DrawLine
手上没有截图区域(任务开始 / 结束、静态方法、行为树 Terminate) 用到时读 TaskContext.Instance().Runtime?.MaskWindowDrawingBoard;截图器未启动时为 null,没有可清的内容
地图点位 TaskContext.Instance().Runtime?.MaskWindowMapState
单测 区域不传绘制入口即为空实现,不会碰到 DI 容器

规则:

  • 业务代码不调用 App.GetService。
  • 不把绘制入口缓存到跨运行环境存活的对象里(触发器实例、静态字段)。单次任务内可以在 Start 时取一次;行为树节点在 Terminate 时截图可能已释放,可保存最近一次截图的 DrawingBoard。
  • 等 GameRuntime P3 把任务改成由 DI 创建后,TaskContext.Instance().Runtime 换成注入的 GameRuntime,写法不变。

8.3 调用对照

场景 改造前 改造后
区域画框 region.DrawSelf("Name") 写法不变,内部写入 region.DrawingBoard
识别结果 VisionContext.Instance().DrawContent.PutOrRemoveRectList(key, list) region.DrawingBoard.Set(key, list)
清除 RemoveRect(key)、PutOrRemoveTextList(key, null) region.DrawingBoard.Clear(key)
清空(任务边界) VisionContext.Instance().DrawContent.ClearAll() TaskContext.Instance().Runtime?.MaskWindowDrawingBoard.ClearAll()
技能 CD PutOrRemoveTextList("SkillCdText", list),由 OnRender 特判样式 SkillCdOverlayRenderer.Update(board, key, cds),分组类型为 Feature,颜色和字号由生产方写进 MaskWindowDrawingTextStyle
任务期间的提示 临时改写用户配置,再设置 Topmost、调用 BringToTop、Refresh() using var scope = capture.DrawingBoard.Scope(group),分组类型为 Feature;游戏在前台时遮罩本来就显示
跟随与显隐 maskWindow.Invoke(HideSelf)、BeginInvoke(Show)、RefreshPosition() _maskWindowHost.ReportGameWindow(state)
启动 / 停止 new MaskWindow()、Show()、Hide()、Close() GameRuntimeService.Started / Stopped 中 _maskWindowHost.Attach(handle)、Detach()
大地图状态 vm.IsInBigMapUi = x、window.PointsCanvasControl.UpdateViewport(...) Runtime?.MaskWindowMapState.Update(isInBigMap: x, bigMapViewport: r)
任务开始 UIDispatcherHelper.Invoke(() => vm.IsInBigMapUi = false)、maskWindow.Invoke(Show) Runtime?.MaskWindowMapState.Reset(),显示交给下一帧的 ReportGameWindow
样式变更后刷新 MaskWindow.InstanceNullable()?.Refresh() 删除,由绑定自动重绘
通用 UI 调度 maskWindow.Invoke(...) UIDispatcherHelper

9. 迁移步骤

在一个分支里按下面的顺序完成,最后一次性编译通过。与 InputHub 的做法一样,直接替换入口,不保留转发层。

  1. 基础设施:
    • 新增 Core/Mask/,包括所有接口、模型,以及 MaskWindowDrawingBoard、NullMaskWindowDrawingBoard、MaskWindowMapState。
    • 新增 Helpers/Ui/UiCoalescer.cs,并完成 DI 注册。
  2. View 层:
    • 新增 View/Mask/ 下的 MaskWindowDrawingLayer.cs、MaskWindowCrosshairLayer.cs、MaskWindowHost.cs,以及 View/Behavior/WindowClickThroughBehavior.cs。
    • 按第 6 节改造 MaskWindow 和 MaskWindowViewModel。
  3. 窗口调用方:
    • HomePageViewModel:改用 Attach / Detach。
    • TaskTriggerDispatcher:Tick 和 SyncMaskWindowPosition 改用 ReportGameWindow。
    • TaskRunner:删除对遮罩窗口的操作。
    • AutoLeyLineOutcropTask:OCR 区域提示改用 Feature 分组,删除 EnsureMaskOverlayVisible、RestoreMaskOverlayVisible、RefreshMaskWindowForOverlay。
    • CustomHtmlMaskService:改为订阅 StateChanged。
  4. 绘制调用方:
    • Region、ImageRegion、GameCaptureRegion 中的 drawContent 换成 IMaskWindowDrawingBoard。
    • ToRectDrawable 改名为 ToMaskWindowDrawingRect,并去掉其中除以 DpiScale 的换算。
    • 其余 78 处 VisionContext.Instance() 按第 8 节替换。
    • SkillCdOverlayRenderer 负责生成带样式的 MaskWindowDrawingText。
    • AutoArtifactSalvageTask 的文字坐标改用捕获像素。
  5. 地图:
    • MapMaskTrigger 和 TaskRunner 改为写当前运行环境的 MaskWindowMapState。
    • 两个 Canvas 新增 Viewport 依赖属性。
  6. 清理:
    • 删除整个 View/Drawable/ 目录和 FakeDrawContent。
    • 可选:加一个源码扫描单测,确保 MaskWindow 只出现在 View/ 下。

10. 命名(已确认)

10.1 命名规则

  • 业务代码能看到的、用来操作遮罩窗口的类型,一律以 MaskWindow 开头,结构为 MaskWindow + 区域或职责 + 概念:
    • 区域或职责:Drawing、Map、Crosshair、Host。
    • 概念:Group、Rect、Snapshot、Layer 等。
  • 这样和窗口里其他区域以及其他遮罩都能区分开。其他区域指状态、日志、指标(AdjustableOverlayItem);其他遮罩指 HtmlMaskWindow,以及将来可能新增的 Overlay。
  • 不加前缀的只有三个:
    • UiCoalescer:通用 UI 调度工具。
    • WindowClickThroughBehavior:HtmlMaskWindow 也能用。
    • GameWindowState:描述的是游戏窗口,不是遮罩窗口。
  • 命名空间用 Core.Mask,不用 Core.MaskWindow。原因是 C# 查找简单名称时,会先查外层命名空间的成员,再看文件顶部的 using。如果存在 BetterGenshinImpact.Core.MaskWindow 这个命名空间,那么 BetterGenshinImpact.Core.* 下的代码写 MaskWindow 时会先解析到这个命名空间,报 CS0118。

10.2 命名一览

概念 推荐 备选
绘制入口 IMaskWindowDrawingBoard / MaskWindowDrawingBoard(已选定)
空实现 NullMaskWindowDrawingBoard
写入方法 Set / Clear / ClearAll / Scope 沿用 Put / Remove
分组(原 key) MaskWindowDrawingGroup MaskWindowDrawingKey
分组类型 MaskWindowDrawingKind { Recognition, Feature } { Debug, Hint }
图形 MaskWindowDrawingShape / Rect / Line / Text
样式 MaskWindowDrawingStroke / MaskWindowDrawingTextStyle / MaskWindowDrawingStyle
快照 MaskWindowDrawingSnapshot / MaskWindowDrawingEntry
读端 IMaskWindowSnapshotSource<T>
窗口宿主 IMaskWindowHost / MaskWindowHost IMaskWindowController、IMaskWindowManager
宿主方法 Attach / Detach / Close / ReportGameWindow
宿主状态 MaskWindowState
地图点位 IMaskWindowMapState / MaskWindowMapSnapshot IMaskWindowMapPointState
View 渲染层 MaskWindowDrawingLayer / MaskWindowCrosshairLayer
Region 辅助方法 ToMaskWindowDrawingRect / SelfToMaskWindowDrawingRect

几个取舍:

  • 绘制入口用 DrawingBoard,不用 Canvas。窗口里已经有 PointsCanvas 这个真正的画布控件,Canvas 会和它、以及 WPF 的 Canvas 混淆。
  • 分组用 Group,不用 Layer。Layer 留给 View 里的渲染元素,免得 MaskWindowDrawingLayer 里又出现一个"layer"参数。
  • 宿主用 Host,不用 Manager 或 Controller。Host 表示拥有这个窗口并决定它怎么显示;Manager 太泛;Controller 听起来像只负责转发指令。

10.3 其他待确认

  1. 技能 CD 改为 Feature 分组后,不再受"在遮罩上显示识别结果"开关控制,这是一处行为变化。
  2. 绘制重绘上限定为 33 ms(约 30 fps)是否合适,是否需要做成配置项。

11. 实现记录

11.1 与设计的差异

位置 设计 实现 原因
IMaskWindowDrawingBoard.Set shapes 不可空 允许传 null,等价于 Clear 旧代码大量 PutOrRemove*(key, null),迁移时写法最直接
MaskWindowDrawingTextStyle 前景色、字号必填 全部可空,空字段用配置里的识别结果样式 识别结果文字只指定颜色、不指定字号
IMaskWindowHost 无 Close 增加 Close() 程序退出时需要真正关闭窗口
遮罩隐藏 / 最小化 → 关闭弹窗 EventTrigger WindowHiddenCommandBehavior IsVisibleChanged 的参数不是 EventArgs,EventTrigger 无法绑定
布局提交事件 经 EventArgsConverter 转成纯数据 保持 OverlayLayoutCommittedEventArgs 它本身就是纯数据类,不含 WPF 类型
获取绘制入口 构造参数 + App.GetService 兜底 归属 GameRuntime,见第 8 节;Region.DrawingBoard:显式传入 → 继承父区域 → 空实现 绘制坐标属于这次运行环境;子区域自动继承,业务代码拿到截图就能画
地图点位状态 构造注入 同样放进 GameRuntime.MaskWindowMapState 视口也是这次运行环境的坐标,解绑时统一重置
IMaskWindowHost.KeepVisible 任务期间保持遮罩可见 删除 唯一的使用方(地脉花)改用 Feature 分组后不再需要
渲染层坐标换算 用窗口 DPI MaskWindowLayerMetrics 同时计入"本层 → 窗口"的缩放和"窗口 → 设备"的缩放 DpiAwarenessController 会给窗口内容加 LayoutTransform,渲染层在它下面,单用 DPI 会错位

11.2 行为变化

  • 技能 CD(含 AutoCombo 接管时)改为 Feature 分组,不再受「在遮罩上显示识别结果」开关控制。
  • 地脉花任务不再临时改写 DisplayRecognitionResultsOnMask,也不再强制显示遮罩;OCR 区域提示是 Feature 分组,任务期间其他识别框仍按用户开关显示。
  • AutoArtifactSalvageTask 的"识别失败 / 套装名"文字改用捕获像素坐标,修复了高 DPI 下位置偏移。
  • 系统信息与 MSI Afterburner 检查从遮罩窗口 Loaded 移到 HomePageViewModel.OnRuntimeStarted,每次启动截图器都会输出。
  • 截图器停止时清空所有绘制内容并重置地图点位状态。
  • HTML 遮罩改为只在主遮罩显隐或移动时同步,不再每帧 ShowAll。

11.3 验证情况

  • dotnet build BetterGenshinImpact.sln -c Debug 通过,没有新增警告。
  • 没有实际运行程序。以下行为需要手动验证:遮罩显隐与跟随、多显示器 / 非 100% DPI 下识别框和准星位置、布局编辑模式下的点击穿透、大地图点位与小地图、技能 CD 显示。
  • 单测依赖的资源子模块在新 worktree 中没有初始化,未运行单测。

Docs Design Multi Instance Ipc (`Docs/design/multi-instance-ipc.md`)

BetterGI 多实例命名管道协议

设计目标

协议不再使用 InstanceId,也不要求根实例保存“由谁启动了谁”的进程树。实例关系只由
当前 Windows 用户、Windows Session、进程用途和进程 ID 决定。

当前协议版本为 v2,规则如下:

  • 每个 Windows 用户最多有一个根 BetterGI;
  • 根 BetterGI 是该用户固定根管道的持有者,通常运行在主桌面 Session;
  • 与根不在同一个 Session 的 BetterGI 是桌面分身 BetterGI;
  • WebView 无论在哪个 Session 启动,都是根 BetterGI 的直接客户端;
  • 桌面分身 BetterGI 只能查询和访问相同 Session 中的 WebView;
  • 同一个 Session 只允许一个 BetterGI,WebView 则允许多个并通过进程 ID 精确寻址。

独立任务的选择面板、任务下发和状态回传不在当前实现范围内。

管道和 Windows 用户隔离

每个 Windows 用户只有一个固定根管道:

text
BetterGI.v2.user-<Windows用户SID>.root

命名管道名称在整台计算机上可见,而不是按 Windows Session 隔离。因此根 BetterGI、
桌面分身 BetterGI 和 WebView 即使运行在不同 Session,也能连接同一个固定端点。
管道 ACL 只允许当前 Windows 用户 SID 完全控制,并显式拒绝 Network SID,所以
不同 Windows 用户分别拥有自己的根 BetterGI,彼此不会注册到对方的实例组中。

协议内容不敏感,不使用密钥、令牌、InstanceId 或父实例 ID。根端从已连接的命名管道
句柄调用 Windows API 获取客户端真实进程 ID,再由进程 ID取得真实 Windows Session;
客户端提交的用途只用于区分 BetterGI 与 WebView,不能伪造进程或 Session 身份。

启动参数

客户端用途仅使用:

text
--instance <childSession|webview>
  • --instance childSession 表示该进程只能作为桌面分身客户端,根暂时不存在时也不会
    自行成为根;
  • --instance webview 表示该进程只能作为 WebView 客户端,必须同时用
    --instance-name <实例名> 指定实例名(云原神网页版,见 GameRuntime 设计 7.1)。
    实例名随 connection.open 提交,根把它写入该 WebView 的端点(InstanceEndpoint.InstanceName),
    只用于识别和展示;同名互斥由实例名互斥体保证,根不判重;
  • 不带 --instance 的 BetterGI 会先竞争固定根管道。竞争成功即成为根;竞争失败则
    连接已有根,由根根据真实 Session 决定是转发重复启动还是注册为桌面分身。

应用自身重启时额外传递:

text
--restart-from-pid <旧进程ID>

新进程会等待旧进程退出,再竞争根管道或替换同 Session 中的旧客户端连接。该参数只用于
消除重启交接期间的竞态,不作为长期身份。

连接拓扑和访问规则

text
某个 Windows 用户的根 BetterGI
├─ Session 2 的桌面分身 BetterGI(最多一个)
├─ Session 3 的桌面分身 BetterGI(最多一个)
├─ Session 1 的 WebView(可多个,以进程 ID 区分)
├─ Session 2 的 WebView(可多个,以进程 ID 区分)
└─ Session 3 的 WebView(可多个,以进程 ID 区分)

所有客户端都直接连接根管道,不在桌面分身下面建立二级管道。这样根重启后只需恢复一个
固定端点,客户端也只需重连一个固定名称。

WebView 访问规则:

  • 根 BetterGI 可以查询和访问全部 WebView;
  • 桌面分身 BetterGI 只能查询与自己 Windows Session 相同的 WebView;
  • 单播通过 WebView 进程 ID 精确定位;
  • 组播由调用方先查询可见 WebView,再逐个发送,不在协议中维护易失的实例组。

连接建立和重复启动

客户端连接固定根管道后,首先发送 connection.open。根根据管道连接的真实进程 ID 和
Session 处理:

  1. webview:按进程 ID 注册,可在断线后替换旧连接。
  2. BetterGI 与根位于相同 Session:视为重复启动,把业务启动参数转发给根窗口,然后
    新进程退出。
  3. BetterGI 与根位于不同 Session:注册为桌面分身。
  4. 同一 Session 已有桌面分身:把业务启动参数转发给已有进程,然后新进程退出。
  5. 旧连接已经失效,或 --restart-from-pid 指向该 Session 中被替换的进程:接受新连接。

断线、闪退和退出语义

桌面分身 BetterGI 与 WebView 在根管道断开后,每秒尝试重新连接固定根管道,且没有总
超时时间。因此根 BetterGI 闪退、被结束进程或隔很久后才重新启动,都不改变客户端的
发现地址;新根取得固定管道后,仍存活的客户端会自动重新注册。

正常关闭根 BetterGI 时,现有 ChildSessionService.Dispose 流程继续主动断开 RDP 并
注销桌面分身 Session。只有未执行正常释放流程的闪退场景,桌面分身才会留存并等待根
恢复。

连接断开即移除对应运行时记录,不把客户端清单持久化。根重启后的关系由仍存活的客户端
重新连接重建,避免把已经退出的 PID 当作在线实例。

帧格式

同一条连接同时承载 JSON 控制消息和二进制相对鼠标批次。整数使用小端序。

text
uint32 payloadLength
byte   payloadType
byte[] payload

payloadLength 最大为 1 MiB。

payloadType 内容
1 UTF-8 JSON 控制消息
2 相对鼠标二进制批次

JSON 信封的主要字段:

json
{
  "version": 2,
  "requestId": "00000000-0000-0000-0000-000000000000",
  "operation": "ping",
  "success": true,
  "errorCode": null,
  "errorMessage": null,
  "data": {}
}

响应使用相同 requestId,并将 operation 设为 response。信封中不再传输
sourceInstanceId。

当前控制操作:

操作 用途
ping 检查连接并读取端点描述
connection.open 客户端声明用途,根按真实 PID/Session 注册或转发激活
activation.dispatch 根向已有 BetterGI 转发二次启动参数
input.relativeMouse.subscribe 桌面分身请求根开始转发相对鼠标
input.relativeMouse.unsubscribe 桌面分身停止转发相对鼠标
input.relativeMouse.state 预留的相对鼠标状态通知名称
webview.list 查询当前调用方可见的 WebView,按实例名排序
webview.send 根按目标进程 ID 向 WebView 单播
webview.message 根向目标 WebView 下发消息

相对鼠标批次结构:

text
uint16 sampleCount            // 1..64
uint64 firstSequence
int64  baseUtcTicks
repeat sampleCount:
    int32 deltaX
    int32 deltaY
    int32 timestampOffsetUs

发送端最多等待 5 ms 或累计 64 个样本后发送。队列拥塞时将后续位移合并,避免阻塞
Raw Input 采集线程。根 BetterGI 仅在游戏鼠标模式已启用、桌面分身窗口可见、RDP 已连接且
Input Capture Window 具有键盘焦点时转发。


Docs Design Realtime Trigger (`Docs/design/realtime-trigger.md`)

实时触发器生命周期与启停设计

状态:已实施(第 2 版) · 2026-09-30 · 关联:配置持久化链路、GameRuntime、遮罩窗口

0. 一句话说明

触发器实例在启动截图器时创建一次,之后不再销毁或重建。原来的"清空 / 重新引入"改成增删一张"任务期间启用名单",调度器每一帧照着这张名单(不在任务中时照用户配置)决定哪些触发器运行。

  • 实例什么时候销毁:只在停止截图器时丢弃,下次启动截图器再新建一套,这一点和现在一样。任务开始和结束、脚本添加和清空触发器、修改配置,都不再销毁或重建实例。上一次会话中仍处于启用状态的实例,在新会话第一帧收到 OnDisabled,清掉留在遮罩上的内容。
  • 触发器怎么知道自己被开关:调度器每帧把"本帧该不该运行"和上一帧比较,不同时调用触发器的 OnEnabled 或 OnDisabled。原来散在 Init() 和外部代码里的初始化、收尾逻辑,都搬进这两个方法。
  • 对原有功能的影响:用法和效果基本不变,JS 接口不变,暂停、任务期间全停这些规则也保持原样。能感知到的差异见 7.2:任务里临时启用的触发器随任务结束、暂停不再卡住 Tick,其余是 bug 修复和生效时机的细微变化。

1. 背景

实时触发器(ITaskTrigger)由 TaskTriggerDispatcher 驱动。每 50ms 执行一次 Tick:截一帧图,然后按优先级依次调用各触发器的 OnCapture。现有 9 个触发器:

类 名称 优先级 用户开关 说明
TestTrigger RecognitionTest 9999 无,恒为关闭 开发用
GameLoadingTrigger GameLoading 999 GenshinStartConfig.AutoEnterGameEnabled 自动开门;可后台运行;进入主界面或 5 分钟后自停
AutoPickTrigger AutoPick 30 AutoPickConfig.Enabled 脚本可启用,可带参数
AutoEatTrigger AutoEat 25 AutoEatConfig.Enabled 脚本可启用
QuickTeleportTrigger QuickTeleport 21 QuickTeleportConfig.Enabled
AutoSkipTrigger AutoSkip 20 AutoSkipConfig.Enabled 脚本可启用,可带参数;可后台运行
AutoFishingTrigger AutoFish 15 AutoFishingConfig.Enabled 进入钓鱼界面后独占
SkillCdTrigger SkillCd 10 SkillCdConfig.Enabled
MapMaskTrigger MapMask 1 MapMaskConfig.Enabled

2. 现状问题

2.1 清空就是销毁,恢复就是全部重建

mermaid
flowchart TD
    S["启动截图器<br/>Dispatcher.Start"] --> L1["LoadInitialTriggers<br/>new 9 个实例,逐个 Init()"]
    L1 --> IDLE["空闲:按用户配置运行"]
    IDLE --> TI["TaskRunner.Init<br/>ClearTriggers:清空字典和列表"]
    TI --> TASK["任务中:列表为空"]
    TASK --> AT["AddTrigger(name, 参数)<br/>new 一个实例放进字典"]
    AT --> CV["ConvertToTriggerList(true)<br/>字典里全部重新 Init(),再全部强制启用"]
    CV --> TASK
    TASK --> TE["TaskRunner.End<br/>ClearTriggers,然后 SetTriggers(LoadInitialTriggers())"]
    TE --> L2["再 new 9 个实例"]
    L2 --> IDLE
  1. 实例的生命周期和"是否启用"绑在一起:要关只能从列表删掉,要恢复只能重新 new。每个任务结束都会重建全部 9 个实例。
  2. 重建有副作用:AutoFishingTrigger 在构造函数里按当时的配置建行为树,所以钓鱼配置什么时候生效,取决于之后有没有跑过任务。GameLoadingTrigger.Init() 里的 Starward 通知也随重建反复执行,见 2.4。
  3. 收尾逻辑散在外面:TaskRunner.Init 替 MapMask 复位大地图状态,多处调用 DrawContent.ClearAll() 擦掉触发器的残留绘制。
  4. 同一份数据有两个来源:静态的 GameTaskManager.TriggerDictionary 和 Dispatcher 的 _triggers。DialogueOptionVoiceDiagnosticService 读的是字典,任务期间字典为空,拿不到 AutoSkip。
  5. AddTrigger 会把字典里所有触发器都设成 IsEnabled = true。SkillCdTrigger.IsEnabled 的 setter 会写回用户配置,目前只是因为字典总是先被清空才没有出事。
  6. 任务里临时启用的触发器没有撤销入口:自动秘境加入的 AutoEat、地脉花加入的 AutoPick 会一直留到当前项目结束。

2.2 配置一变就调用 `Init()`

mermaid
flowchart LR
    UI["UI 线程<br/>设置页开关"] --> PC["PropertyChanged"]
    TK["任务线程<br/>AutoFishingTask 等"] --> PC
    CAP["截图线程<br/>SkillCd 联机自关"] --> PC
    PC --> TR["ConfigChangeTracker"]
    TR --> SV["防抖保存"]
    TR --> RF["RefreshTriggerConfigs(sender)<br/>按 sender 类型查表"]
    RF --> INIT["对应触发器 Init()<br/>不持有列表锁"]
    RF --> CA["DrawContent.ClearAll()<br/>同步切到 UI 线程"]
    INIT -. 并发 .- OC["Tick 线程上的 OnCapture"]

Init() 同时做了四件事,所以配置只要一变,就只能整个重新调用一遍:

职责 例子
把配置拷贝到触发器 IsEnabled = _config.Enabled、IsBackgroundRunning = _config.RunBackgroundEnabled
计算派生数据 AutoPick 按模式读 5 个名单文件,AutoSkip 读 3 个关键词 JSON
重置运行状态 SkillCd 清空帧缓存和全部 CD 计时
关闭时的收尾 MapMask 隐藏点位,SkillCd 清除文字,AutoSkip 释放选项等待

由此带来的问题:

  1. 强制启用被覆盖:路径追踪强制启用了 AutoPick,这时 AutoPickConfig 只要有一项变化,Init() 就会把 IsEnabled 改回用户的开关。
  2. 无关选项也会重置状态:改 SkillCd 的任意选项,或者编辑 CD 修正规则(这里调用的是全量刷新),CD 计时都会清零。
  3. 线程不安全:Init() 在触发变更的线程上同步执行,可能是 UI、任务或截图线程,并且不持有列表锁,和 OnCapture 并发。SkillCd 检测到联机时会执行 IsEnabled = false,这一句会在它自己的 OnCapture 里重入 Init(),然后在持有两把锁的情况下调用 ClearAll(),同步等待 UI 线程。
  4. 查表只认子配置对象本身:嵌套对象和集合元素(例如 CustomCdList 里的规则)变化时不会刷新。新增触发器时还要记得去登记映射。
  5. 清理范围过大:任一触发器的配置变化,ClearAll() 会连任务画的内容一起清掉。

2.3 暂停拾取会卡住整个 Tick

RunnerContext.StopAutoPick() 让计数加 1。AutoPickTrigger.OnCapture 发现计数大于 0 时执行 while (...) Thread.Sleep(1000),睡眠期间一直持有 _locker 和 _triggerListLocker。

  • "暂停拾取时全部触发器都停下"这个效果,其实是阻塞带来的副作用,并且只在 AutoPick 处于启用状态时才会出现。
  • 窗口跟随、遮罩显隐、画中画这些 Tick 的基础工作也会一起停。
  • 这期间其他线程调用 AddTrigger、ClearTriggers 会一直等锁。

2.4 "记录游戏时长"挂在自动开门触发器上

首页"同时启动原神"里的"使用 Starward 记录游戏时间",代码写在 GameLoadingTrigger.Init() 中。开关开启、并且已注册 Starward 协议时,调用 starward://playtime/{区服}。Init() 在两个时机执行,所以通知也在这两个时机发出:

  • 每次启动截图器:手动启动、调度器或一条龙自动启动、命令行启动、切换截图模式后的自动重启。此时游戏窗口已经存在。
  • 每个独立任务结束:TaskRunner.End 重建全部触发器。

问题:

  1. 通知次数取决于启动了几次截图器、跑了几个任务,和游戏进程无关。调用不带 pid,Starward 收到后会拉起一个进程,每 2 秒按进程名查找游戏,找不到时最多等十几秒。已经在记录的游戏进程会被跳过,所以时长不会重复计算,但每次调用都会多拉起一个 Starward 进程。
  2. 与自动开门无关:关闭"自动进入游戏"也照样通知。
  3. 区服只从 InstallPath 判断:没配置安装路径,或者 exe 不是 GenshinImpact.exe、YuanShen.exe 时,不通知。注册表兜底的条件是 GameServer == null,而字段初始值是 "",所以兜底永远不会执行。

3. 目标与非目标

目标:

  • 每次截图会话(启动截图器到停止截图器)内,每个触发器只有一个实例。
  • 任务期间停止全部用户开启的触发器,只运行任务和脚本显式启用的那些。
  • 任务里临时启用的触发器随启用它的任务一起结束。
  • 配置变化不再调用触发器,触发器每帧读取实时配置。
  • 暂停时全部触发器停止,但不阻塞 Tick。
  • JS 接口不变:dispatcher.addTimer、addTrigger、clearAllTriggers 和 RealtimeTimer。

非目标:

  • 不改各触发器的识别和操作逻辑。
  • 不改 Tick 里截图器、窗口、遮罩的判断,这些分别由 GameRuntime 和 遮罩窗口 负责。
  • 不扩大脚本可启用的范围,仍然只有 AutoPick、AutoSkip、AutoEat。
  • 不改 RunnerContext 的暂停接口,也不改 SkillCdTrigger.Suspend/Resume:AutoCombo 只在任务里运行,而任务期间 SkillCd 本来就是停用的。
  • 不改 ConfigChangeTracker 和保存链路。

4. 设计

4.1 规则:每一帧谁在运行

mermaid
flowchart TD
    A["每帧,对每个触发器"] --> B{"正在执行任务?"}
    B -- 否 --> C{"用户开启了?<br/>IsEnabledByConfig"}
    B -- 是 --> D{"启用名单里有它?"}
    C -- 是 --> ON["启用,没有参数"]
    C -- 否 --> OFF["停用"]
    D -- 有 --> ON2["启用,参数取最后加入的那一条"]
    D -- 没有 --> OFF
    ON --> P{"暂停计数 > 0?"}
    ON2 --> P
    P -- 是 --> SKIP["本帧不运行,状态保留"]
    P -- 否 --> RUN["运行 OnCapture"]

整个设计只有这一条规则,用到三份数据:

数据 谁来改 说明
任务标记 TaskRunner:开始时 BeginTask(),结束时 EndTask() 两个方法都会清空启用名单
启用名单 AddTrigger 加入一条,ClearTriggers 清空,AddTrigger 返回的租约 Dispose 时撤销自己那一条 每条记录"哪个触发器 + 参数",只在任务期间生效
暂停计数 RunnerContext.StopAutoPick / ResumeAutoPick,与现在相同 大于 0 时全部触发器本帧不运行

租约(lease)就是加入名单时拿到的一个 IDisposable,调用 Dispose 就把自己这一条从名单里删掉。不需要中途撤销的调用方(例如 JS 脚本)可以不管它,名单会在下一次 ClearTriggers 或任务结束时整体清空。

4.2 类图

改造前:

mermaid
classDiagram
    direction LR
    class ITaskTrigger {
        <<接口>>
        +string Name
        +bool IsEnabled
        +int Priority
        +bool IsExclusive
        +bool IsBackgroundRunning
        +Init()
        +OnCapture(CaptureContent content)
    }
    class TaskTriggerDispatcher {
        -List~ITaskTrigger~ _triggers
        -object _triggerListLocker
        +Start(GameRuntime runtime, int interval)
        +ClearTriggers()
        +SetTriggers(List~ITaskTrigger~ list)
        +AddTrigger(string name, object externalConfig) bool
        -Tick()
    }
    class GameTaskManager {
        <<静态>>
        +ConcurrentDictionary TriggerDictionary
        +LoadInitialTriggers() List~ITaskTrigger~
        +ConvertToTriggerList(bool allEnabled) List~ITaskTrigger~
        +AddTrigger(string name, object externalConfig) bool
        +ClearTriggers()
        +RefreshTriggerConfigs()
        +RefreshTriggerConfigs(object changedConfig)
    }
    class AllConfig {
        -OnConfigChanged(object sender)
    }
    class AutoPickTrigger {
        +Init()
        +OnCapture(CaptureContent content)
    }
    TaskTriggerDispatcher --> GameTaskManager : 取列表
    GameTaskManager --> ITaskTrigger : new、Init、强制启用
    AllConfig ..> GameTaskManager : 配置变化时刷新
    ITaskTrigger <|.. AutoPickTrigger

改造后:

text
/* Detailed source-code truncated for AI context efficiency. */

触发器部分只新增 TaskTriggerDispatcher 里的两个私有嵌套类。另外从 GameLoadingTrigger 拆出一个静态类,负责 Starward 通知:

类 作用
TriggerSlot 包住一个触发器实例,记录它上一帧是否启用、用的是哪份参数,用来判断状态有没有变
TriggerLease 启用名单里的一条,Dispose 时把自己从名单里删掉,重复调用无副作用
StarwardPlaytime 绑定游戏进程后通知 Starward 记录时长,每个游戏进程只通知一次(4.8)

4.3 接口

csharp
public interface ITaskTrigger
{
    string Name { get; }
    int Priority { get; }
    GameUiCategory SupportedGameUiCategory => GameUiCategory.Unknown;

    /// <summary>用户是否开启。每帧读取,实现里直接读配置,不缓存</summary>
    bool IsEnabledByConfig { get; }

    /// <summary>运行时状态,由触发器自己维护</summary>
    bool IsExclusive { get; }

    /// <summary>实时读取配置</summary>
    bool IsBackgroundRunning => false;

    /// <summary>停用 → 启用。options 是脚本传入的参数,没有则为 null。原 Init() 中的初始化放在这里</summary>
    void OnEnabled(object? options) { }

    /// <summary>启用 → 停用。原 Init() 中关闭时的收尾、以及外部替它做的清理放在这里</summary>
    void OnDisabled() { }

    void OnCapture(CaptureContent content);
}

与现在相比:

  • IsEnabled { get; set; } 换成只读的 IsEnabledByConfig。触发器不再自己保存启用状态,是否运行由调度器决定。
  • Init() 拆成 OnEnabled 和 OnDisabled,只在状态变化时调用。
  • OnCapture 只在启用且未暂停时调用,触发器内部原来那些 if (IsEnabled) 判断可以删掉。
csharp
public class TaskTriggerDispatcher
{
    /// <summary>任务开始:进入任务模式,清空启用名单。用户开启的触发器下一帧停用</summary>
    public void BeginTask();

    /// <summary>任务结束:退出任务模式,清空启用名单。下一帧按用户配置恢复。重复调用无副作用</summary>
    public void EndTask();

    /// <summary>
    /// 任务期间启用一个触发器,只接受 "AutoPick"、"AutoSkip"、"AutoEat"。
    /// 返回租约,Dispose 时撤销这一条;名称不支持时返回 null
    /// </summary>
    public IDisposable? AddTrigger(string name, object? options = null);

    /// <summary>清空启用名单。不销毁任何实例</summary>
    public void ClearTriggers();

    /// <summary>取当前截图会话中的触发器实例,供诊断等只读场景使用</summary>
    public T? GetTrigger<T>() where T : class, ITaskTrigger;

    /// <summary>调度器还没创建时返回 null,供 TaskRunner.End、诊断服务等"可能早于截图器"的调用方使用</summary>
    public static TaskTriggerDispatcher? InstanceNullable();
}
  • ClearTriggers、AddTrigger 保留原来的名字和调用位置,含义从"删除实例 / 新建实例"变成"清空名单 / 加入名单"。
  • TaskRunner.End 第一步就调用 EndTask(),早于"截图器是否已初始化"的判断。任务中途截图器被停止时,也不会停留在任务模式。
  • 删除 SetTriggers。
  • 任务标记用 bool 即可:TaskControl.TaskSemaphore 已经保证同一时刻只有一个任务。

4.4 触发器状态与 Tick

mermaid
stateDiagram-v2
    state "停用" as Off
    state "启用" as On
    [*] --> Off: 启动截图器,创建实例
    Off --> On: 本帧判定为启用 / OnEnabled(options)
    On --> Off: 本帧判定为停用 / OnDisabled()
    On --> On: 参数变了 / OnDisabled() + OnEnabled(新参数)
    Off --> [*]: 停止截图器,丢弃实例
    On --> [*]: 停止截图器,丢弃实例

暂停不是一个状态:暂停只是本帧不调用 OnCapture,不会触发 OnDisabled,所以触发器的内部状态保留,和现在被阻塞时一样。

mermaid
sequenceDiagram
    participant Timer as 定时器(50ms)
    participant D as TaskTriggerDispatcher
    participant T as 触发器
    Timer->>D: Tick(持有 _locker,保证串行)
    D->>D: 在 _leaseLock 里拷贝任务标记、启用名单和上一会话留下的实例
    D->>T: 上一会话中仍启用的实例 OnDisabled()
    loop 每个触发器(SyncTriggerStates)
        D->>D: 按 4.1 的规则算出本帧状态和参数
        alt 与上一帧不同
            D->>T: 原来启用则 OnDisabled()
            D->>T: 现在启用则 OnEnabled(options)
        end
    end
    D->>D: 不在任务中且有触发器停用(用户关闭)时,ClearAll() 一次
    D->>D: 截图器、最小化、前台检查,截图(同现在)
    alt 暂停计数 > 0
        D->>D: 不调用任何 OnCapture
    else
        D->>T: OnCapture(按独占、后台、UI 类别过滤,同现在)
    end

规则:

  1. OnEnabled、OnDisabled、OnCapture 都只在 Tick 里执行,Tick 由 _locker 保证串行,所以触发器内部不需要为它们加锁。
  2. 状态同步放在 Tick 开头,早于"最小化、不在前台"这几处提前返回。游戏在后台时,停用也能及时收尾。
  3. 参数按引用比较。
  4. 回调抛出的异常只记日志,不影响其他触发器,状态照常迁移。
  5. OnCapture 执行期间不持有 _leaseLock,删除 _triggerListLocker。其他线程改名单时只等一次列表增删,不会等 Tick。
  6. 停止截图器时不处理触发器(Stop 可能在退出流程中调用,此时不适合切 UI 线程)。下次 Start 新建一套实例,初始全部为停用;旧实例放进 _retiredSlots,新会话第一帧对其中仍启用的调用 OnDisabled,清掉 SkillCd 文字、MapMask 点位等残留,再按规则启用新实例。
  7. ClearAll() 只在"不在任务中、有触发器从启用变为停用"时执行,对应原来"改触发器配置就清画布"。任务开始、结束时的清理仍由 TaskRunner 负责,撤销租约时不清画布,避免擦掉任务或脚本自己画的内容。

4.5 暂停

暂停计数和 5 个调用点都不改,只改检查的位置:从 AutoPickTrigger.OnCapture 里的 Sleep 循环挪到 Tick。计数大于 0 时,本帧不调用任何触发器的 OnCapture。

调用点 写法
暂停热键 TaskControl.TrySuspend StopAutoPick() / ResumeAutoPick(),不变
战斗、传送、去冒险家协会(PathExecutor ×2、AutoFightHandler) StopAutoPickRunTask(task, 5),不变
选择 F 选项 ChooseFOptionTask StopAutoPick() / ResumeAutoPick(),不变

效果与原来一致,都是全部触发器停止,区别只有两点:不管 AutoPick 是否启用都会生效;Tick 的基础工作照常运行。AutoPickTriggerStopCount 的名字保留,注释改为"暂停全部实时触发器的计数"。RunnerContext.Clear() 在任务开始和结束时把计数清零,这一点也不变。

4.6 配置变更

配置变化不再调用任何触发器。Init() 的四件事去向如下:

职责 新的去处
把配置拷贝到触发器 删除。IsEnabledByConfig、IsBackgroundRunning 等每次直接读配置
计算派生数据 AutoPick 名单在 OnCapture 里按键缓存,键变了才重读;AutoSkip 关键词在 OnEnabled 时读
重置运行状态 OnEnabled
关闭时的收尾 OnDisabled

AutoPick 名单缓存直接写在 AutoPickTrigger 里,不新增类:

csharp
private static int _listVersion;

/// <summary>名单文件被界面修改后调用,下一次 OnCapture 重读</summary>
public static void ReloadLists() => Interlocked.Increment(ref _listVersion);

/// <summary>在 OnCapture 识别到拾取键之后、用到名单之前调用</summary>
private void EnsureLists(AutoPickConfig config)
{
    var key = (config.Mode, config.BlacklistModePickEnabled, config.WhitelistModeDoNotPickEnabled,
               Volatile.Read(ref _listVersion));
    if (_loadedListKey == key)
    {
        return;
    }

    _loadedListKey = key;
    LoadLists(config); // 与原 Init() 相同:按模式读 5 个文件,整体替换 4 个集合
}

读取失败的弹窗改用 UIDispatcherHelper.BeginInvoke 发出,避免在 Tick 里等用户点确定。失败的结果同样按键缓存,同一个键只弹一次。

调用点:

位置 改造前 改造后
AllConfig.OnConfigChanged RefreshTriggerConfigs(sender),然后保存 只保存;sender 是 MaskWindowConfig 时 ClearAll(),保留"切换识别结果显示时清掉旧图形"的效果
TriggerSettingsPageViewModel.OnAutoPickModeChanged RefreshTriggerConfigs() 删除。拾取模式已经是缓存键的一部分
TriggerSettingsPageViewModel.OnEditSkillCdConfig RefreshTriggerConfigs() 删除。SkillCd 本来就每次实时读取 CustomCdList
AutoPickConfigWindowViewModelBase 保存 RefreshTriggerConfigs() AutoPickTrigger.ReloadLists()
AutoPickBlackListViewModel、AutoPickWhiteListViewModel 保存 RefreshTriggerConfigs() 删除。这两个窗口写的是 pick_*_lists.json,触发器读的是 .txt,这次刷新本来就不影响名单

用户关闭某个触发器后,下一帧它会停用。停用那一帧由调度器 ClearAll() 一次,效果与现在"改配置就清画布"相同。任务期间撤销租约时不清画布,任务或脚本自己画的内容不受影响。

AutoSkip 关键词读取失败的弹窗同样改为 BeginInvoke:关键词在 OnEnabled 里读,也运行在 Tick 中。

4.7 脚本参数(options)

options 就是现在 AddTrigger(name, externalConfig) 里的 externalConfig,只有 JS 脚本会传:

触发器 options 类型 JS 写法 作用
AutoPick AutoPickExternalConfig new RealtimeTimer("AutoPick", { forceInteraction: true }) 识别到 F 键就直接按,不看文字
AutoSkip AutoSkipConfig(脚本自己 new 的实例) new RealtimeTimer("AutoSkip", cfg) 用这份配置代替全局 AutoSkip 配置,不读关键词文件
AutoEat 无 new RealtimeTimer("AutoEat")

现在参数只能通过构造函数传进去,带参数启用就只能 new 一个新实例。改造后参数记在名单里,同一个实例在 OnEnabled(options) 时取用,名单清空后回到全局配置。

同一个触发器在名单里有多条时,用最后加入的那一条。触发器保持启用、但参数变了的时候,按"先 OnDisabled 再 OnEnabled(新参数)"处理,运行状态随之重置,与现在"替换成新实例"的效果相同。这只会在脚本内部出现,例如脚本对同一个触发器先后调用两次 addTrigger 并传了不同参数。

脚本传入的 AutoSkipConfig.Enabled 不参与判断,与现在强制启用的效果一致。

4.8 记录游戏时长(Starward)

这个功能记录的是游戏进程的时长,和触发器无关。通知从 GameLoadingTrigger 移到绑定游戏进程的位置,每个游戏进程只通知一次:

mermaid
flowchart LR
    A["Win32RuntimeProvider.AttachTo<br/>绑定游戏窗口"] --> B{"开启了记录游戏时间<br/>且已注册 Starward 协议?"}
    B -- 否 --> X["不通知"]
    B -- 是 --> C{"这个游戏进程<br/>已经通知过?"}
    C -- 是 --> X
    C -- 否 --> D["按游戏 exe 识别区服"]
    D --> E["starward://playtime/区服?pid=进程ID"]
项 规则
时机 Win32RuntimeProvider.AttachTo 组装好 GameRuntime 之后调用 StarwardPlaytime.TryRecord(window, config.GenshinStartConfig)。自动查找、关联启动、手动选窗都经过这里,网页版不经过
条件 与现在相同,只看 RecordGameTimeEnabled 和协议是否注册,不看是否由 BetterGI 启动游戏、是否开启自动进入游戏
去重 记住上一次通知的游戏进程 ID,相同就跳过。任务结束、截图器重启都不会重复通知;游戏重启后进程 ID 变化,会再通知一次。协议未注册时不记录进程 ID,之后注册了还能补上
区服 从绑定的游戏进程取 exe 路径(QueryFullProcessImageName,PROCESS_QUERY_LIMITED_INFORMATION),取不到时退回 InstallPath。GenshinImpact.exe 为国际服;YuanShen.exe 读同目录 config.ini 的 channel,1 为官服、14 为 B 服;仍无法判断时读注册表(官服、B 服)。其他 exe(例如云原神)不通知,与现在一致
参数 带上 pid,Starward 直接按进程 ID 记录,不再按进程名轮询
线程 Process.Start 放到线程池执行,不阻塞 UI 线程

StarwardPlaytime 放在 GameTask/Runtime/Win32/。GameLoadingTrigger 中的 StartStarward、GetGameServerRegistry、IsStarwardProtocolRegistered,以及区服识别代码和相关字段,全部移入这个类。

5. 各触发器改造

触发器 IsEnabledByConfig OnEnabled(options) OnDisabled() 其他
AutoPick AutoPickConfig.Enabled 保存 options 清空 options 名单改为 EnsureLists 按键缓存;删除暂停时的 Sleep 循环
AutoSkip AutoSkipConfig.Enabled(全局) options 是 AutoSkipConfig 就用它,否则用全局配置;使用全局配置时读关键词,使用脚本配置时清空关键词 ReleaseChooseOptionWait、ResetPageCloseRecognition(原 IsEnabled setter 里的逻辑) IsBackgroundRunning 实时读当前配置
AutoFish AutoFishingConfig.Enabled 按当前配置建行为树(原构造函数中的代码),IsExclusive = false IsExclusive = false 删除行为树中对 IsEnabled 的判断
QuickTeleport QuickTeleportConfig.Enabled IsExclusive = false
AutoEat AutoEatConfig.Enabled 计时字段清零(与原来重建实例时一致)
SkillCd SkillCdConfig.Enabled 清空帧缓存和 CD 状态(原 Init() 的前半段),在 _stateLock 内进行,避免与后台队伍同步并发 清除 CD 文字,释放帧缓存 删除写回配置的 setter;联机时直接写 SkillCdConfig.Enabled = false,下一帧自然停用;删除 _wasEnabled
MapMask MapMaskConfig.Enabled _active = true _active = false;释放待计算的 Mat,复位大地图状态和两个视口(原 Init() 的关闭分支) UI 线程应用更新时改看 _active(原来看 _config.Enabled),任务期间停用后迟到的计算结果也会被丢弃
GameLoading AutoEnterGameEnabled && !_finished 删除静态 GlobalEnabled,自停改为 _finished = true;Starward 通知移出(4.8),Init() 删除后不再有其他初始化
RecognitionTest false IsExclusive = false

原来有些运行字段是靠"任务结束重建实例"清零的。实例不再重建后,改造时逐个核对这些字段,需要清零的放进 OnEnabled。OnEnabled 的调用时机与原来重建的时机一致:任务结束后按用户配置重新启用时。

6. 调用点对照

位置 改造前 改造后
TaskTriggerDispatcher.Start _triggers = LoadInitialTriggers();设置 GameLoadingTrigger.GlobalEnabled _slots 由 GameTaskManager.CreateTriggers() 生成
Win32RuntimeProvider.AttachTo 无 组装好 GameRuntime 后调用 StarwardPlaytime.TryRecord(window, config.GenshinStartConfig)
TaskRunner.Init ClearTriggers();复位 IsInBigMapUi BeginTask();保留 IsInBigMapUi 的同步复位。MapMask.OnDisabled 也会复位,但要等下一帧,这里同步复位保证任务第一次点击前遮罩已恢复点击穿透
TaskRunner.End ClearTriggers() + SetTriggers(LoadInitialTriggers()) 第一步 InstanceNullable()?.EndTask(),早于截图器是否初始化的判断
PathExecutor 进入剧情时自带的 AutoSkip new AutoSkipTrigger(自定义配置) + Init(),自己调用 OnCapture Init() 改为 OnEnabled(null),其余不变。这个实例不经过调度器
ScriptService 每个项目的每次执行 ClearTriggers() 不变(含义变为清空名单)
ScriptGroupProject 路径追踪 AddTrigger("AutoPick", null) 不变,忽略返回值
JS Dispatcher.addTimer / clearAllTriggers ClearTriggers() + AddTrigger / ClearTriggers() 不变
JS Dispatcher.addTrigger if (!AddTrigger(...)) throw if (AddTrigger(...) is null) throw
AutoDomainTask Init() 中 AddTrigger("AutoEat", null) 在 Start 中用 using 持有租约,秘境返回时撤销
AutoLeyLineOutcropTask PrepareForLeyLineRun 中 AddTrigger("AutoPick", null) 租约保存到字段,在 Start 的 finally 中 Dispose
DialogueOptionVoiceDiagnosticService TriggerDictionary?.GetValueOrDefault("AutoSkip") GetTrigger<AutoSkipTrigger>(),任务期间也能拿到
GameTaskManager 静态字典及其增删、刷新方法 只保留 CreateTriggers()(原 LoadInitialTriggers 去掉 Init)和 LoadAssetImage 系列

7. 变更前后对比

7.1 一次调度器执行(JS 项目,脚本里调用了自动秘境)

改造前:

mermaid
sequenceDiagram
    participant TR as TaskRunner
    participant SS as ScriptService
    participant D as TaskTriggerDispatcher
    participant GM as GameTaskManager
    participant JS as JS 脚本
    TR->>D: Init:ClearTriggers()
    D->>GM: 清空字典,列表清空
    SS->>D: 每个项目 ClearTriggers()
    JS->>D: addTimer(AutoPick, 参数)
    D->>GM: AddTrigger:new AutoPickTrigger(参数)
    GM-->>D: 字典全部 Init 并强制启用
    JS->>D: runAutoDomainTask 内部 AddTrigger(AutoEat)
    GM-->>D: 再次全部 Init,AutoPick 也被重新 Init
    Note over D: AutoEat 一直留到项目结束
    TR->>D: End:ClearTriggers + SetTriggers(LoadInitialTriggers())
    GM-->>D: new 9 个实例

改造后:

mermaid
sequenceDiagram
    participant TR as TaskRunner
    participant SS as ScriptService
    participant D as TaskTriggerDispatcher
    participant JS as JS 脚本
    participant AD as 自动秘境
    TR->>D: BeginTask():进入任务模式,清空名单
    Note over D: 下一帧用户开启的触发器 OnDisabled
    SS->>D: 每个项目 ClearTriggers():清空名单
    JS->>D: addTimer(AutoPick, 参数):清空名单,加入 AutoPick
    Note over D: 下一帧 AutoPick.OnEnabled(参数)
    JS->>AD: runAutoDomainTask
    AD->>D: using AddTrigger("AutoEat")
    AD-->>D: 秘境返回,撤销 AutoEat
    Note over D: 下一帧 AutoEat.OnDisabled,AutoPick 不受影响
    TR->>D: EndTask():退出任务模式,清空名单
    Note over D: 下一帧按用户配置 OnEnabled

7.2 行为变化

场景 改造前 改造后
自动秘境启用的 AutoEat、地脉花启用的 AutoPick 留到项目结束 随各自的任务结束
暂停拾取(热键、战斗、传送、选 F) AutoPick 启用时整个 Tick 卡住,全部触发器停止 全部触发器停止,与 AutoPick 是否启用无关;窗口跟随、遮罩显隐照常
任务期间 AutoPickConfig 有变化(例如被代码修改) 路径追踪启用的 AutoPick 被改回用户开关 不受影响
修改 SkillCd 选项或 CD 修正规则 CD 计时清零 不清零
开启"记录游戏时长" 每次启动截图器、每个任务结束都通知一次,不带进程 ID 每个游戏进程通知一次,带进程 ID;没配置安装路径时也能识别区服
切换"自动开门" 关闭要等下一个任务结束才生效,开启要重启截图器 下一帧生效(已经自停的不会重新启用)
用户开关触发器 同步生效 下一帧生效,不超过 50ms
修改 AutoSkip 的其他选项 重读关键词文件 不重读;这些文件没有界面编辑入口,关一次再开即可重读
修改钓鱼配置 下一个任务结束后才生效 下一次启用自动钓鱼时生效(关一次再开,或任务结束后)
停止截图器后再启动 上一次的技能 CD 文字、大地图点位可能残留 新会话第一帧清理

其余行为不变:任务期间用户开启的触发器全部停止,任务结束后恢复;脚本的 addTimer、addTrigger、clearAllTriggers 用法和效果相同;路径追踪自动拾取、钓鱼独占、后台运行、UI 类别过滤都不变。

7.3 代码层面

方面 改造前 改造后
实例 每个任务结束重建 9 个 每次启动截图器创建 9 个
数据来源 静态字典 + Dispatcher 列表 Dispatcher 的 _slots
进入 / 结束任务 ClearTriggers() / 清空 + LoadInitialTriggers() BeginTask() / EndTask()
脚本启用 new 实例,全部重新 Init() 并强制启用 加入名单
外部参数 构造函数传入 记在名单里,OnEnabled 时取用
配置变更 RefreshTriggerConfigs(sender) → Init() + ClearAll() 只保存;触发器每帧读配置
暂停 AutoPick 持锁 Sleep Tick 读计数,跳过 OnCapture
线程 Init() 在多个线程上执行,与 OnCapture 并发 触发器的所有回调只在 Tick 里执行
锁 OnCapture 期间持有 _triggerListLocker 只在拷贝名单时持有 _leaseLock
画布清理 任一触发器配置变化就 ClearAll() 不在任务中、有触发器停用的那一帧 ClearAll();各触发器在 OnDisabled 里清自己的内容
跨截图会话 旧实例直接丢弃 旧实例在新会话第一帧收到 OnDisabled,清理残留

8. 改动清单

已在一个分支中完成,编译通过,尚未运行第 9 节的手动测试。

文件 改动
GameTask/ITaskTrigger.cs 按 4.3 修改接口
9 个触发器 按第 5 节改造
GameTask/TaskTriggerDispatcher.cs 新增 TriggerSlot、TriggerLease、BeginTask、EndTask、GetTrigger、InstanceNullable、SyncTriggerStates;AddTrigger 返回租约;Tick 按 4.4 执行并检查暂停计数;删除 SetTriggers、_triggers、_triggerListLocker
GameTask/GameTaskManager.cs 只保留 CreateTriggers() 和 LoadAssetImage 系列
GameTask/Runtime/Win32/StarwardPlaytime.cs 新增(4.8)
GameTask/Runtime/Win32/Win32RuntimeProvider.cs AttachTo 中调用 StarwardPlaytime.TryRecord
TaskRunner、JS Dispatcher、AutoDomainTask、AutoLeyLineOutcropTask、PathExecutor、DialogueOptionVoiceDiagnosticService 按第 6 节修改
AllConfig 与 5 处 ViewModel 按 4.6 修改
RunnerContext AutoPickTriggerStopCount 的注释改为"暂停全部实时触发器的计数"
config-persistence.md 第 4.2 节标注为已被本文取代

9. 手动测试

# 场景 关注点
1 启动截图器,逐个开关各触发器 下一帧生效;关闭后残留绘制被清除,MapMask 点位隐藏
2 开着 SkillCd,调整其他 SkillCd 选项、编辑修正规则 CD 计时不清零
3 切换拾取模式;修改黑白名单后保存 下一次拾取就按新名单执行
4 开着 AutoPick、SkillCd、MapMask,运行自动秘境 任务期间全部停止;结束后恢复,SkillCd 重新计时
5 调度器跑路径追踪,经过战斗和传送 期间全部触发器暂停,结束约 5 秒后恢复;暂停时移动游戏窗口,遮罩跟随
6 任务中按暂停热键,然后解除 暂停期间没有拾取、剧情等操作;解除后恢复
7 JS:addTimer(AutoPick, {forceInteraction:true}),然后 runAutoDomainTask 秘境中强制拾取和自动吃药同时生效;秘境结束后自动吃药停止,强制拾取仍然生效
8 JS:clearAllTriggers() 脚本启用的触发器全部停止
9 开启"记录游戏时长":连续运行 3 个任务,再停止、启动截图器;然后重启游戏 同一个游戏进程只通知一次,重启游戏后再通知一次;Starward 中能看到时长记录
10 联机状态下开启 SkillCd 自动关闭并提示,设置页开关同步变为关闭,没有异常或卡顿
11 进入钓鱼界面,然后退出 进入时独占并开始钓鱼,退出后其他触发器恢复
12 任务运行时打开 AutoSkip 语音诊断 能读到 AutoSkip 的状态
13 停止截图器后再启动 没有残留绘制,触发器按用户配置重新启用

10. 决策记录

议题 决定 原因
实例生命周期 与截图会话一致 任务和脚本的边界只影响是否运行,不应该销毁重建实例
取代清空、重新引入 任务标记 + 启用名单 只有一层规则;原有的 ClearTriggers、AddTrigger 调用点大多不用改
是否启用 每帧计算 只有 9 个触发器,计算量可以忽略;不需要订阅配置,也就没有漏刷、重入和并发问题
状态何时重置 启用、停用变化时回调 只在状态真正变化时执行,时机与原来重建一致
任务期间 用户开启的触发器全部停止 与现在一致
暂停 沿用现有计数,全部触发器停止,改为不阻塞 保持原来的效果,调用点不变
临时启用的范围 随启用它的任务结束 自动秘境、地脉花用租约撤销
参数变化 先停用再启用 与原来替换实例的效果一致
新增类型 Dispatcher 内的两个私有嵌套类,加上 StarwardPlaytime 名单缓存写在 AutoPickTrigger 里,暂停沿用 RunnerContext
记录游戏时长 绑定游戏进程时通知,每个进程一次,带进程 ID 记录的是游戏进程的时长,与触发器生命周期无关
回调线程 只在 Tick 里执行 触发器内部不需要加锁
画布清理 只在用户关闭触发器时 ClearAll() 对应原来"改触发器配置就清画布";任务开始、结束由 TaskRunner 清理,撤销租约不清,避免擦掉任务自己的绘制
跨截图会话 旧实例在新会话第一帧 OnDisabled Stop 可能在退出流程中调用,不适合在那里切 UI 线程;放到下一帧,仍然只在 Tick 里回调
任务开始时的大地图状态 TaskRunner.Init 保留同步复位 停用要等下一帧,任务第一次点击前必须恢复遮罩的点击穿透
Tick 中的弹窗 名单、关键词读取失败改用 BeginInvoke 弹窗 读取改在 Tick 里执行,同步弹窗会让截图循环等用户点确定

Docs Design Task Trigger Dispatcher Di (`Docs/design/task-trigger-dispatcher-di.md`)

TaskTriggerDispatcher 依赖注入改造设计

状态:精简版已实施,编译通过 · 2026-10-01 · 关联:GameRuntime 设计、实时触发器生命周期与启停设计

1. 现状与决策

改造前,TaskTriggerDispatcher 已注册为 DI 单例,GameRuntimeService 和部分 ViewModel 也已注入它。但调度器仍在构造时写入静态 _instance,其他代码通过 Instance()、InstanceNullable() 和 GlobalGameCapture 访问。这使创建顺序隐含在全局状态里,依赖关系也无法从构造函数看出来。

建议:先做局部收敛,预计修改 9 个现有类,新增 0 个类 / 接口。 保留进程级 DI 单例,移除调度器自己维护的 _instance;已有 DI 调用方改为注入,旧任务暂留兼容入口,截图访问回到 GameRuntime。

上一版将 TaskRunner、录制器、脚本宿主的整条创建链都纳入 DI,范围偏大。改造前有 14 个文件直接使用调度器静态入口;仅 new TaskRunner() 就分布在 8 个文件(均不计注释)。彻底删除静态 API 会继续牵动上层调用链,不能只按直接调用点估算成本。

本次优先解决实例来源不统一、截图依赖调度器这两个问题,接受旧代码仍存在隐式依赖。没有必要为了调用方式一致而改造所有对象的创建方式。

当前代码已实现截图器归属:Win32RuntimeProvider.AttachTo() 创建并启动截图器,GameRuntime.Capture 持有它,GameRuntime.Dispose() 释放它。本次沿用这个结构,重点收敛访问路径。

这里的会话是 BetterGI 从绑定游戏到解绑的一段时间。同一个游戏进程中停止、重新启动截图器,会产生新的 GameRuntime 和截图器;一次脚本或独立任务结束不会结束会话。

对象 生命周期与所有权
GameRuntimeService 进程级 DI 单例,统一编排会话启动、停止与失败回滚
TaskTriggerDispatcher 进程级 DI 单例,随容器释放;每次启动时绑定当前运行环境,停止时解绑
GameRuntime 一次游戏运行会话;由 Provider 创建、GameRuntimeService.Current 持有,停止时释放
IGameCapture 属于该 GameRuntime,随会话创建与释放;不是 DI 单例,也不由调度器拥有

直接注入现有具体类即可;复用 IGameCapture。不新增 ScreenshotService、CaptureSession、调度器接口或 DI Scope。TakeScreenshot() 本次保留在调度器中,截图器的所有权仍属于 GameRuntime。

2. 改造前后

改动前

mermaid
classDiagram
    direction LR
    class TaskTriggerDispatcher {
        -TaskTriggerDispatcher _instance$
        +Instance() TaskTriggerDispatcher$
        +InstanceNullable() TaskTriggerDispatcher$
        +IGameCapture GlobalGameCapture$
        -GameRuntime _runtime
    }
    class GameRuntimeService {
        +GameRuntime Current
    }
    class GameRuntime {
        +IGameCapture Capture
    }
    class TaskRunner
    class Dispatcher
    class NotificationService

    GameRuntimeService --> TaskTriggerDispatcher : DI 注入
    GameRuntimeService --> GameRuntime : 持有
    GameRuntime *-- IGameCapture
    TaskRunner ..> TaskTriggerDispatcher : Instance()
    Dispatcher ..> TaskTriggerDispatcher : Instance()
    NotificationService ..> TaskTriggerDispatcher : GlobalGameCapture

本次改动后

mermaid
classDiagram
    direction LR
    class TaskTriggerDispatcher {
        -GameRuntime _runtime
        +Instance() TaskTriggerDispatcher$
        +InstanceNullable() TaskTriggerDispatcher$
        +Start(runtime, interval)
        +Stop()
        +BeginTask()
        +EndTask()
        +AddTrigger(name, options)
        +ClearTriggers()
    }
    class GameRuntimeService {
        +GameRuntime Current
    }
    class GameRuntime {
        +IGameCapture Capture
    }
    class ScriptService
    class TaskRunner
    class NotificationService
    class ServiceProvider

    GameRuntimeService --> TaskTriggerDispatcher : DI 注入并启停
    GameRuntimeService --> GameRuntime : 持有当前会话
    GameRuntime *-- IGameCapture : 拥有并释放
    TaskTriggerDispatcher --> GameRuntime : 运行期间引用
    ServiceProvider --> TaskTriggerDispatcher : DI 创建唯一实例
    ScriptService --> TaskTriggerDispatcher : 构造注入
    TaskRunner ..> TaskTriggerDispatcher : 暂用兼容入口
    TaskTriggerDispatcher ..> ServiceProvider : 仅静态兼容入口查询
    NotificationService --> GameRuntimeService : 构造注入

调度器只在 Start(GameRuntime, ...) 到 Stop() 之间使用会话引用,不反向注入 GameRuntimeService,避免两者形成 DI 循环。GameRuntime 只组合窗口、截图和输入,不负责查询容器,也不充当调度器入口。

方面 改造前 改造后
获取调度器 DI 注册与 _instance 同时存在,构造时写入静态字段 容器是唯一实例来源;DI 调用方注入,旧入口只转发
截图所有权 已归 GameRuntime,但存在调度器转发入口 保持所有权,调用方直接使用会话截图器
调度器职责 触发调度、公开截图器、保存截图 删除公共截图器入口;暂保留保存截图方法
未启动状态 部分调用用静态实例是否存在判断 实例可以提前创建;是否已启动看会话状态

3. 预期改动的类

已修改:9 个类 / 9 个文件

类(文件) 具体改动 涉及范围
TaskTriggerDispatcher 内部日志及服务依赖改为构造注入;删除 _instance,两个静态方法改为兼容转发;删除 GlobalGameCapture,GameCapture 改为私有 核心调整,不改调度规则
ScriptService 构造注入调度器,替换自身的 ClearTriggers() 调用;不向脚本项目继续传参 本类内
HotKeyPageViewModel 构造注入调度器,热键调用其 TakeScreenshot() 本类内
DialogueOptionVoiceDiagnosticService 构造注入调度器,BuildDecisionText() 改为实例方法,调用 GetTrigger<AutoSkipTrigger>() 本类内
NotificationService 构造注入 GameRuntimeService,从当前会话截图 本类内,不改通知服务的其他静态入口
TaskControl CaptureToRectArea() 从 TaskContext.Instance().Runtime.Capture 获取截图器 方法签名不变,上层不动
PictureInPictureWindow 从现有 TaskContext 取得当前会话截图器;无会话时跳过 截图位置小改,不改窗口创建链
LinneaMiningTask 截图来源改为当前会话 截图位置小改
钓鱼行为 TakeScreenshot(AutoFishing/Behaviours.PartII.cs) 截图来源改为当前会话 截图位置小改

前 4 个类调整调度依赖,后 5 个类调整截图来源。已有 DI 注册不变,构造参数由容器补齐;不要求新增工厂或修改所有上层构造函数。

本次不改 / 暂缓

类或调用链 原因与处理
App、GameRuntime、GameRuntimeService、Win32RuntimeProvider、IGameCapture DI 注册和截图会话所有权已具备,本次无需改结构
TaskSettingsPageViewModel 已经注入调度器,保持现状
TaskRunner、AutoDomainTask、AutoLeyLineOutcropTask 暂用兼容入口,保留现有 new、任务收尾和租约用法
GlobalKeyMouseRecord 及其调用方 暂用兼容入口,不连带改造录制器、热键页和 MouseKeyMonitor 的录制依赖
ScriptGroupProject、JS Dispatcher、ScriptProject、EngineExtend 前两个类暂用兼容入口,不为传递一个调度器而修改整条脚本创建链
MusicPageViewModel、OneDragonFlowViewModel、KeyMouseRecordPageViewModel、FeedWindowViewModel、MapEditorWebBridge、RedeemCodeManager、OneDragonTaskItem 避免因任务构造函数变化而连带修改

取舍:这是局部 DI 改造,不是全量清除静态访问。 兼容查询仅保留在调度器的两个旧入口中,不将 App.GetService 散布到业务代码。旧截图路径复用已有 TaskContext;新代码使用构造注入或显式参数,不新增静态调用点。

4. 使用示例

以下示例表达依赖方向,省略原有业务逻辑。

已有 DI 调用方直接注入

csharp
// App.xaml.cs:保持唯一注册
services.AddSingleton<TaskTriggerDispatcher>();

// GameRuntimeService / Provider 沿用已有注册。
// GameRuntime 和 IGameCapture 由 Provider 按会话创建,不在根容器注册。

public partial class ScriptService
{
    private readonly TaskTriggerDispatcher _triggers;

    public ScriptService(TaskTriggerDispatcher triggers)
    {
        _triggers = triggers;
    }

    // 每个脚本项目执行前,原来的静态调用改用 _triggers.ClearTriggers()。
}

调度器自身的 ILogger<TaskTriggerDispatcher>、OverlayMetricsService、CustomHtmlMaskService 也改为构造注入。构造函数只装配依赖和定时器,不启动会话;调度器不反向注入 GameRuntimeService。

旧调用方保留兼容入口

csharp
// 以下方法位于 TaskTriggerDispatcher;删除 _instance 及构造时的静态赋值。
// 仅供旧调用方兼容,返回 DI 容器中的同一个实例,不自行创建或缓存。
public static TaskTriggerDispatcher Instance() =>
    App.GetService<TaskTriggerDispatcher>()
    ?? throw new InvalidOperationException("调度器未注册");

public static TaskTriggerDispatcher? InstanceNullable() =>
    App.GetService<TaskTriggerDispatcher>();

// TaskRunner、JS Dispatcher、录制器等旧调用点暂时保持原样。
TaskTriggerDispatcher.Instance().BeginTask();

这种转发仍属于服务定位(Service Locator),只是把实例来源统一交给 DI,不能称为调用方已完成注入。容器初始化后才允许访问;InstanceNullable() 可能触发容器首次创建实例,不再表示“截图器是否启动”。EndTask() 保持未启动时可调用,GetTrigger<T>() 在尚无触发器时返回 null。

读取本次会话的截图器

csharp
public sealed class CaptureCaller(GameRuntimeService runtimes)
{
    public bool TryCapture()
    {
        var runtime = runtimes.Current;
        if (runtime is null) return false;

        using var frame = runtime.Capture.Capture();
        if (frame is null) return false;

        // 使用本次会话的画面;frame.Frame 随 frame 一起释放。
        return true;
    }
}

长期存在的服务每次操作读取 Current,不能在构造时缓存 Current.Capture。属于某次会话的任务可接收固定的 GameRuntime,并随该会话取消,避免重启后转而操作新游戏会话。调用方释放截图帧,不释放借用的截图器。

以上截图示例只展示依赖与帧所有权;与停止并发时,还需满足下面的生命周期约束,判空本身不能防止对象被释放。

5. 生命周期与实施边界

会话边界

  • 启动:Provider 创建并启动截图器 → 组装 GameRuntime → Service 绑定上下文和输入 → dispatcher.Start(runtime)。失败时由创建方或已接管的 Service 回滚,避免遗漏或重复释放。
  • 停止:由 GameRuntimeService 取消任务、停止调度并释放 GameRuntime。调度器只解绑,不释放截图器。
  • 暂停调度:录制期间的 StopTimer() 只暂停 Tick;截图会话仍然有效。独立任务的 EndTask() 也不释放会话。

现有 Stop() 发出取消请求、停止定时器后立即释放会话,尚未等待在途任务 / Tick 完成。这是既有的释放竞态,不纳入本次 9 个类的 DI 调整,也不能宣称已解决。后续应单独设计停止协调:拒绝新工作,异步等待会话使用者退出后再释放,避免 UI 同步等待或 Tick 等待自身造成死锁。

实施与验证

  1. 修改 TaskTriggerDispatcher 的构造依赖与静态兼容实现,保留现有注册。
  2. 修改表中的 3 个调度调用方与 5 个截图调用方,删除公共截图器入口。
  3. 检查只保留表中 6 个旧类的静态调度调用,未增加分散的容器查询、重复实例或循环依赖。
  4. dotnet build BetterGenshinImpact.sln -c Debug 已通过:0 个错误,628 个警告。按项目要求无需实际运行程序。

未来修改某个旧任务时,再顺手将其依赖改为参数传入;等兼容调用自然收敛后删除静态入口,不为清零调用数量单开一轮全量重构。

触发器的启停名单、租约和每帧调度规则沿用现有设计。本次保留已有注释,修改过的 C# 文件均为 UTF-8 无 BOM。


Docs Technical Recognition Json (`Docs/technical/recognition-json.md`)

Recognition.json 编写说明

本文档说明各任务目录下 Assets\Recognition.json 的写法,用于将普通静态 RecognitionObject 配置化,并通过 RecognitionAssets.Get(...) 动态加载。

1. 文件位置

每个任务自己的识别配置文件放在:

text
BetterGenshinImpact\GameTask\<任务名>\Assets\Recognition.json

例如:

text
BetterGenshinImpact\GameTask\AutoSkip\Assets\Recognition.json
BetterGenshinImpact\GameTask\Common\Element\Assets\Recognition.json

调用方式通常是:

csharp
RecognitionAssets.Get("AutoSkip", "Collect", region);
RecognitionAssets.Get("AutoFight", "Confirm", width, height);
ElementRecognition.Get("PaimonMenu", region);

其中第二个参数就是 objects 下的对象名。

2. 基本结构

json
{
  "version": 1,
  "vars": {},
  "regions": {},
  "templates": {},
  "objects": {}
}

各字段含义:

  • version:配置版本号,目前固定写 1
  • vars:公共变量,供表达式复用
  • regions:公共区域表达式,供 roi / reference.bbox 通过 @别名 引用
  • templates:公共模板别名,供 template 通过 @别名 引用
  • objects:识别对象定义集合

3. 一个最小示例

json
{
  "version": 1,
  "regions": {
    "topLeftQuarter": "rect(0, 0, cw / 4, ch / 4)"
  },
  "objects": {
    "PaimonMenu": {
      "type": "TemplateMatch",
      "template": "paimon_menu.png",
      "roi": "@topLeftQuarter",
      "draw": false
    }
  }
}

说明:

  • PaimonMenu 是对象名,也是调用时的 objectName
  • type 必须与 RecognitionTypes 枚举文本完全一致
  • template 是模板文件名
  • roi 可以直接写表达式,也可以引用 regions 中的别名
  • 模板匹配场景下如果不写 name,运行时名称默认使用模板名,因此通常可以省略

4. 表达式系统

roi、reference.bbox、vars 中的值都支持表达式,底层由 NCalc 解析。

4.1 内置变量

  • cw:当前截图宽度
  • ch:当前截图高度
  • cx:当前截图区域左上角 X
  • cy:当前截图区域左上角 Y
  • s:资源缩放系数
    • 当截图宽度小于 1920 时,s = cw / 1920
    • 其他情况 s = 1

4.2 支持的函数

  • rect(x, y, w, h):创建矩形
  • cutLeft(percent):截取左侧比例区域
  • cutRight(percent):截取右侧比例区域
  • cutTop(percent):截取上侧比例区域
  • cutBottom(percent):截取下侧比例区域
  • cutLeftTop(widthPercent, heightPercent):截取左上区域
  • cutRightTop(widthPercent, heightPercent):截取右上区域
  • cutLeftBottom(widthPercent, heightPercent):截取左下区域
  • cutRightBottom(widthPercent, heightPercent):截取右下区域

4.3 示例

json
{
  "vars": {
    "topBarHeight": "100 * s"
  },
  "regions": {
    "inventoryTopRight": "rect(cw * 3 / 4, 0, cw / 4, topBarHeight)",
    "pageCloseWhite": "rect(cw - cw / 8, 0, cw / 8, ch / 8)",
    "leftBottom": "cutLeftBottom(0.2, 0.2)"
  }
}

5. 模板别名和区域别名

5.1 区域别名

在 regions 中先定义:

json
"regions": {
  "option": "rect(cw / 2, ch / 12, cw - cw / 2 - cw / 6, ch - ch / 12 - 10)"
}

再在对象里引用:

json
"roi": "@option"

5.2 模板别名

在 templates 中先定义:

json
"templates": {
  "pageClose": "page_close.png"
}

再在对象里引用:

json
"template": "@pageClose"

适合多个对象共用同一模板,或者模板文件名比较长的时候。

6. objects 常用字段

下面只列常用和当前加载器支持的字段。

6.1 通用字段

  • name

    • 可选
    • 运行时名称
    • 模板匹配通常可以省略
  • type

    • 必填
    • 必须与 RecognitionTypes 一致
    • 常见值:
      • TemplateMatch
      • ColorMatch
      • OcrMatch
      • Ocr
      • ColorRangeAndOcr
  • roi

    • 可选
    • 返回 Rect 的表达式,或 @区域别名
  • draw

    • 可选
    • 是否绘制调试框
  • drawColor

    • 可选
    • 调试框颜色,HTML 颜色格式,例如 #FF0000
  • drawWidth

    • 可选
    • 调试框线宽

7. TemplateMatch 字段

适用于 type = "TemplateMatch"。

  • template

    • 必填
    • 模板文件名、相对路径或 @模板别名
  • templateMode

    • 可选
    • 对应 OpenCV ImreadModes
    • 默认 Color
  • threshold

    • 可选
    • 匹配阈值
    • 默认通常为 0.8
  • use3Channels

    • 可选
    • 是否使用三通道匹配
  • templateMatchMode

    • 可选
    • 对应 OpenCV TemplateMatchModes
    • 常见值:
      • CCoeffNormed
      • CCorrNormed
      • SqDiff
  • useMask

    • 可选
    • 是否启用遮罩
  • maskColor

    • 可选
    • 遮罩颜色,HTML 颜色格式
  • maxMatchCount

    • 可选
    • 最大匹配数量
    • 不限制时可不填
  • useBinaryMatch

    • 可选
    • 是否先二值化再匹配
  • binaryThreshold

    • 可选
    • 二值化阈值

示例:

json
"SubmitGoods": {
  "type": "TemplateMatch",
  "template": "submit_goods.png",
  "roi": "rect(0, 0, cw / 2, ch / 3)",
  "threshold": 0.9,
  "use3Channels": true,
  "templateMatchMode": "CCorrNormed",
  "draw": true
}

8. ColorMatch / ColorRangeAndOcr 字段

适用于颜色范围识别相关场景。

  • colorCode

    • 可选
    • 对应 OpenCV ColorConversionCodes
    • 常见值:
      • BGR2RGB
      • BGR2HSV
      • BGR2GRAY
  • lowerColor

    • 可选
    • 颜色下界
    • 支持 1 到 4 个数字
  • upperColor

    • 可选
    • 颜色上界
    • 支持 1 到 4 个数字
  • matchCount

    • 可选
    • 至少命中的像素点数量

示例:

json
"SomeColorMark": {
  "type": "ColorMatch",
  "roi": "rect(0, 0, cw / 4, ch / 4)",
  "colorCode": "BGR2HSV",
  "lowerColor": [90, 80, 80],
  "upperColor": [130, 255, 255],
  "matchCount": 10
}

9. OCR / OcrMatch 字段

适用于 type = "Ocr" 或 type = "OcrMatch"。

  • ocrEngine

    • 可选
    • 对应 OcrEngineTypes
    • 当前通常写 Paddle
  • text

    • 可选
    • 用于 OCR 结果筛选的目标文本
  • replace

    • 可选
    • OCR 误识别替换表
  • allContains

    • 可选
    • 所有文本都包含才算成功
  • oneContains

    • 可选
    • 任一文本包含即成功
  • regex

    • 可选
    • 正则表达式列表
    • 当前实现要求列表中的所有正则都命中

示例:

json
"PlayingText": {
  "type": "OcrMatch",
  "roi": "rect(100 * s, 35 * s, 85 * s, 35 * s)",
  "oneContains": [
    "播放",
    "暂停",
    "继续"
  ],
  "draw": true
}

replace 示例:

json
"replace": {
  "播放": ["播故", "搰放"],
  "继续": ["绫续"]
}

10. reference 字段

reference 用于记录模板截图来源信息,便于后续按参考尺寸或原始包围盒做定位扩展。

字段:

  • size

    • 格式 [width, height]
    • 表示模板来源截图尺寸
  • bbox

    • 返回 Rect 的表达式
    • 表示模板在来源截图中的包围盒

示例:

json
"reference": {
  "size": [1920, 1080],
  "bbox": "rect(1680, 32, 180, 72)"
}

search 用于描述参考画布搜索框、响应式锚点和额外扩展区域。
仅当对象同时配置了 reference.size 和 reference.bbox,且没有显式 roi 时,参考画布搜索才会生效。

字段:

  • anchor

    • 对应 SearchAnchorMode 枚举名
    • 可用值:Auto、TopLeft、TopRight、BottomLeft、BottomRight、Center
    • Auto 根据 reference.bbox 所在区域模拟游戏 UI 的响应式布局
  • box

    • 可选
    • 返回 Rect 的表达式,也可写成 @区域别名
    • 使用 reference.size 对应的参考画布坐标系
    • 与 reference.bbox 使用完全相同的缩放和锚定转换
    • 不填时使用 reference.bbox 作为基础搜索框
  • expand

    • 格式 [width, height]
    • 表示转换到当前截图后,左右扩展 width 像素、上下扩展 height 像素
    • 不填时默认四周扩展 10px
    • 配置了 expandPercent 时本字段和默认 10px 均不生效
  • expandPercent

    • 可选,值为直接参与计算的小数比例,例如 0.05 表示 5%
    • 左右以当前截图宽度为基准,上下以当前截图高度为基准
    • 参数顺序遵循 XAML Thickness
      • [all]:四边使用相同比例
      • [horizontal, vertical]:左右、上下
      • [left, top, right, bottom]:左、上、右、下
    • 只允许 1、2、4 个有限且非负的数字,可以大于 1
    • 优先于像素 expand;显式写 [0] 表示不扩展

示例:

json
"search": {
  "anchor": "Center",
  "expand": [120, 80]
}

带独立搜索框和非对称百分比扩展的示例:

json
"reference": {
  "size": [1920, 1080],
  "bbox": "rect(1680, 32, 48, 48)"
},
"search": {
  "anchor": "TopRight",
  "box": "rect(1540, 0, 380, 160)",
  "expandPercent": [0.02, 0.01, 0.03, 0.01]
}

上例的 expandPercent 按“左、上、右、下”解释:左侧扩展截图宽度的 2%,上侧扩展截图高度的 1%,右侧扩展截图宽度的 3%,下侧扩展截图高度的 1%。

12. 推荐写法

  • 普通模板匹配对象优先只写 type、template、roi、threshold
  • 重复使用的区域优先抽到 regions
  • 重复使用的模板优先抽到 templates
  • 可通过表达式描述的 ROI 尽量写进 JSON,不再写死在 C# 里
  • 普通静态对象不要再回退到 XXXAssets 字段里缓存,直接调用 RecognitionAssets.Get(...)
  • 模板匹配对象通常不需要手写 name

13. 不建议放进 JSON 的场景

下面这些仍然更适合保留在 C# 里:

  • 需要运行时变量替换,而且变量来源复杂
  • 需要动态拼接多份 Mat / 模板列表
  • 需要依赖特殊 OpenCV 预处理逻辑
  • 需要依赖外部配置、业务状态或复杂流程控制
  • ROI 不是单纯表达式,而是与多个运行时对象联动

这类对象可以继续保留在对应任务类或专用 helper 中。

14. 排错建议

如果加载失败,当前日志会输出类似:

text
Recognition 加载失败: Collect @ 1920x1080, file=...
Recognition 构建失败: Collect @ 1920x1080

建议优先检查:

  1. type 是否和枚举名完全一致
  2. templateMatchMode、templateMode、colorCode、ocrEngine、anchor 是否和对应枚举名完全一致
  3. template 引用的模板文件或模板别名是否存在
  4. roi / bbox 表达式是否能返回 Rect
  5. @区域别名、@模板别名、vars 引用的名字是否存在
  6. lowerColor / upperColor 数组长度是否在 1 到 4 之间

15. 一个较完整示例

json
{
  "version": 1,
  "vars": {
    "topBarHeight": "100 * s"
  },
  "regions": {
    "topLeftQuarter": "rect(0, 0, cw / 4, ch / 4)",
    "inventoryTopRight": "rect(cw * 3 / 4, 0, cw / 4, topBarHeight)"
  },
  "templates": {
    "menu": "paimon_menu.png"
  },
  "objects": {
    "PaimonMenu": {
      "type": "TemplateMatch",
      "template": "@menu",
      "roi": "@topLeftQuarter",
      "threshold": 0.8,
      "draw": false
    },
    "Inventory": {
      "type": "TemplateMatch",
      "template": "inventory.png",
      "roi": "@inventoryTopRight",
      "draw": false
    },
    "ConfirmText": {
      "type": "OcrMatch",
      "roi": "rect(cw / 2, ch / 2, 200 * s, 80 * s)",
      "oneContains": ["确认", "确定"]
    }
  }
}

Docs Design Pulonia Development Plan (`Docs/design/pulonia/development-plan.md`)

Pulonia:开发与逐步验收计划

返回总览 · 用户故事

每一步都需用户明确验收通过后才能进入下一步。实现时优先阅读需求基线,再查本步对应专题。

当前状态与验收规则

当前仅完成设计文档整理,步骤 0 待用户验收;步骤 1—13 均未开始。这份计划不表示已经授权连续实施全部步骤。

每一步的流程固定为:确定本步范围 → 实现和自查 → 提交验收材料 → 等待用户明确验收通过 → 才能开始下一步。 编译通过、自动检查通过或代理自行判断完成,都不能替代用户验收。需要修改时留在当前步骤修正并重新提交,不跨步推进。

每次提交只附四项:本步完成了什么、如何验收、编译/检查结果、已知限制。验收材料包含可定位的代码/界面入口或示例;代理默认不启动游戏,游戏内效果由用户验收。实现阶段按项目要求执行 dotnet build BetterGenshinImpact.sln -c Debug,程序占用时记录未验证原因,不伪报成功。

若某一步仍过大,在开始该步前列出更小的交付项,每个子步骤也分别等待用户验收,不能通过拆子步骤绕过门槛。下表中的步骤依次推进;调整顺序或扩大范围时先更新计划并交由用户确认。

开发顺序

步骤 本步交付 请用户验收的重点 关联故事
0. 设计基线 总览、专题设计、本计划;列出待定选择 需求未偏离,分层和开发顺序可接受;认可在步骤 1 用样例决定可执行树形式 全部
1. 模型与最小存储 PuloniaTask/计划、稳定 ID、参数对象、JSON 读写与校验;引用/目录/重复的样例;运行准备原型 树格式、ER、命名和参数类型清晰;保存再读取不丢失内容;比较快照直执行与只读执行树,确认一种方案后固定合同 04、05、09、11
2. 树编辑与配置 新“任务计划”页,分组、拖拽、启用、跨计划复制、撤销;参数编辑和有效来源预览 父组开关不覆盖子项选择;复制参数保真;预设切换立即正确显示;编辑与保存失败可恢复。此步不接真实游戏执行 04、09、12
3. 最小可运行链路 串行执行、请求与状态、运行快照、有限超时/取消;Shell、纯计算 C# 和进程内 C# 调用 用无游戏样例完成“提交→运行→结果→取消”;不启动截图器;编辑不影响正在运行的快照;Shell 子进程正确收尾 04、05、06
4. 游戏能力适配 按需准备会话、输入所有权、真实结果;依次接入路线、JS、宏和需要替代的内置任务;JS 子调用统一上下文 同一能力直接启动和 JS 调用结果一致;取消后释放输入;启动不依赖页面;纯任务仍不依赖截图。每类适配单独作为子步骤验收 03、04、05、07
5. 任务库与批量添加 搜索索引、README 预览、目录引用/展开、资源版本;路线录制编辑和 JS 工具的新入口 快速找到材料并加整目录;大目录操作可响应;不需复制资源或运行脚本生成菜单;原资源编辑功能可从新入口到达 10
6. CD、历史与节点续跑 账号/世界作用域、日周月额度、结构化证据、状态文件一致保存、历史详情、从节点继续 用受控样例验证部分成功、重复事件、日切与崩溃;未确认不能记成功;跨计划共享 CD、跨账号隔离;旧快照可续跑 03、06、11
7. 定时、热键与空闲 结构化日程、热键、空闲准入、过期/补触发、运行忙碌策略;Windows 任务计划启动入口 到点不空闲则等待;用户回来停止新增输入;重复热键不重复执行;应用未启动时能按条件唤起;不引入 Quartz 02、08
8. 有界恢复 已知月卡/网络等中断处理、总时限、未知界面待处理与诊断;恢复与业务交接输入 已知场景在预算内恢复;未知界面、背包满和顶号明确停止/待处理;不无限重试,不在旧任务未结束时启动下一项 07
9. 一条龙视图与旧数据迁移 同计划的简洁视图、有效配置直显、旧配置组/一条龙/d-v3 导入、兼容差异清单 简洁与树视图来回切换不丢数据;代表性旧配置导入结果一致;新入口覆盖旧任务、路线和 JS 管理。此步验收前保留旧菜单,不在这里提前删除 04、09、10、12
10. 多账号与外部 C# 按账号顺序运行、预设绑定、受支持渠道切号和 UID 核验;本机 C# 客户端查询/提交/取消 两账号目标与记录不串用,切号失败不在错误账号继续;进程外调用与 UI 结果一致。切号和外部客户端分子步骤验收 05、11
11. 基础目标托管 手选角色/物品目标、读取必要练度/库存/树脂、精选任务资源、可解释规划和今日安排 区分库存目标与周期新增;按优先级共享树脂预算;普通玩家无需研究订阅;识别未知有提示,能调整推荐安排 01
12. 扩大自动识别覆盖 逐项补 box 枚举、物品状态、圣遗物信息与策略、可靠采集事件和能力检查点 每种新增能力用明确样本验收识别/误判和回退结果;不能以“支持接口”代表已实现识别。每种能力分别验收,不能整包宣布完成 01、03、06、07
13. 完整替代与收口 汇总能力对照、导入差异及回归结果;完成已验收范围内的旧菜单切换,保留原数据 用户先确认切换清单,再移除对应旧菜单/重复入口,完成后再验收;剩余缺口明确列出,未满足的原始故事不能标完成 全部

步骤 1—9 完成后,按兼容清单验收“配置组/一条龙编排可替代”;步骤 10—12 完成托管目标。是否完全替代以实际能力和用户验收为准,不以步骤编号或代码合并为准。

步骤 12 的“更多识别与恢复”是持续扩展,但当前用户故事要求的覆盖范围必须列成明确子项;未做完的部分标为未交付,不能用“后续优化”掩盖缺失。远程 HTTP、多机联机编排和任意并行工作流不进入本计划。

每步验收记录

每完成一步,在本文件追加一条记录;未获得用户结论时保持“待验收”。当前步骤未通过,不把后续步骤改为进行中。

text
步骤 / 子步骤:
状态:未开始 / 进行中 / 待验收 / 需修改 / 已通过
交付版本或提交:
本次交付与验收操作:
编译与检查结果:
已知限制:
用户验收结论与日期:

验收场景索引

以下保留原概要的关键场景,按当前步骤选取相关项验证,不要求每次重复全部验证。真实游戏结果由用户验收;模型、取消、时间边界和文件故障可先用受控执行器与固定数据验证。

  1. V01 同一路线在两个计划出现,第一条已确认采集后第二条显示剩余 CD;换账号后不串记录。
  2. V02 20:00 到点但用户操作电脑,20:10 空闲才开始;超出截止时间则过期。
  3. V03 运行时鼠标/键盘有用户活动,停止新增输入,续跑前重新准备环境。
  4. V04 奖励已经领取、记录尚未提交时崩溃,重启后进入核验而不是直接重领。
  5. V05 日切前开始、日切后领取,以及失败后已经采到部分素材,账本归属正确。
  6. V06 修改计划/预设不改变正在执行的运行快照;资源版本变化不会静默影响续跑。
  7. V07 任意入口调用同一动作均显示相同有效参数和来源。
  8. V08 一万项资源目录按需加载和批量保存;使用固定数据集与基准环境记录首次打开、搜索和整目录添加耗时,验证 UI 可响应。
  9. V09 旧任务不响应取消时,不得启动下一账号/节点继续争用输入。
  10. V10 未知界面、被顶号、验证码、UID 错误都能有限结束并留下明确待处理原因。
  11. V11 运行状态替换前后崩溃、历史归档前后崩溃、磁盘写入失败、备份回退时,进度和 CD 不出现两份相互矛盾的当前记录。
  12. V12 目录引用更新后只影响新快照;关闭再开启分组不会丢失子项禁用;拖拽与重命名不破坏历史对应关系。
  13. V13 不引入 Quartz、SQLite;休眠恢复、时区转换、时钟回拨、重复热键均不会无界补任务或重复占用游戏输入。
  14. V14 带数组/布尔值的 JS 配置跨计划复制后,无需重启即可编辑和执行;误删、误拖可以撤销,粘贴继承差异有提示。
  15. V15 同一路线由任务树和 JS 子调用启动,结果原因和额度规则一致;父任务汇总不会重复记账,钓鱼未进入和未领奖不会更新完成 CD。
  16. V16 “库存补到 N”“今日新增 N”“角色培养到目标”分别计算;用户中途使用材料或提升角色后刷新状态,不继续按过期缺口分配树脂。
  17. V17 背包满、计划总时限到达、公共月卡中断及切号失败均有明确终态;旧任务未停止时,次日请求不能抢占输入。详见 US-07、11。

Docs Design Pulonia Execution (`Docs/design/pulonia/execution.md`)

Pulonia:执行、生命周期、恢复与 API

返回总览 · 用户故事 · 开发与验收计划

说明如何运行任务、按需获取游戏资源、停止与恢复,以及 JS/C# 接入。CD 和可靠落盘的详细语义见结果与存储。

执行结构与资源生命周期

mermaid
flowchart LR
    Entry[页面 / 定时 / 热键 / C#] --> Service[PuloniaTaskService]
    Service --> Executors[具体 IPuloniaTaskExecutor]
    Service --> Store[PuloniaTaskStore:JSON 文件]
    Executors --> Existing[现有脚本 / 路线 / 游戏能力]

主链只保留以下职责:

部分 内容
PuloniaTaskService 统一入口、串行队列、集中准备运行快照/执行树、执行编排、条件/CD 检查、运行状态和取消
IPuloniaTaskExecutor 的具体实现 JS、路线、Shell、C#、内置功能的实际执行,接收已解析参数并返回真实结果
PuloniaTaskStore 读写计划、状态、历史等 JSON;不引用 ViewModel,不负责执行
触发入口 定时循环和热键回调只提交请求;代码较长时可放进 PuloniaTaskService 的独立 partial 文件

PuloniaTaskService 通过 TaskType → IPuloniaTaskExecutor 注册查找具体能力。运行准备集中完成展开、参数解析、校验与版本固定,可产出只读执行树,具体选择见任务模型。这不是另一套执行服务;避免重复的 Converter/Factory 链、Engine/Runner 调度层、通用事件总线和后台历史 Recorder。游戏会话准备及恢复复用已有运行环境;可按职责拆分辅助类,不要求把所有逻辑堆入一个大服务。

目录结构按 Pulonia/Models、Pulonia/Services、Pulonia/Executors 组织;WPF View/ViewModel 保持现有项目位置。Model 是普通数据,ViewModel 包装同一模型进行编辑;执行时深复制并固定配置,防止编辑影响运行。实现可以按功能分文件,但不增加仅透传参数的层。

按需求获取资源

能力 所需资源 生命周期
Shell、文件处理、纯计算 C# 通常无游戏资源 自己拥有子进程、文件流等资源
纯计算 JS 脚本引擎 本次动作内创建、结束时释放
游戏路线、秘境、游戏 JS/C# 游戏会话、截图、独占输入 执行前按需获取;相邻游戏动作允许复用会话
游戏启动/账号切换 启动或登录阶段能力 不要求先到大世界;完成后核验身份
自动拾取等实时辅助 当前游戏动作的辅助能力集合 随动作作用域注册与撤销

能力声明独立于实现语言。JS 不天然等于游戏任务,C# 也不天然等于纯计算。旧脚本没有声明时,兼容适配按需要游戏处理;新脚本可声明为纯脚本。声明缺失的游戏调用必须明确失败或通过受控会话入口获取资源,不能绕过协调器。

GameRuntimeService 目前将绑定窗口与截图启动组合在一起,可先复用这个组合边界。第一步只要求纯任务不碰它;确有需求后再拆分窗口、输入、截图获取,不先建复杂资源依赖图。

租约区分“借用用户已启动的截图器”和“本次自动启动”。借用者不能在结束时关闭其他使用者仍需要的环境。动作负责释放自己创建的 Mat、Bitmap、流、引擎和子 CTS;会话服务负责环境。恢复监测只读截图,恢复动作与业务动作必须交接输入控制权。

并发、取消、线程

  • 首版由一个主协调器串行执行请求;Shell 即使不依赖游戏,也可以先排队,避免为首版引入并发复杂度。
  • 同一 Windows 交互会话只允许一个前台输入所有者。已有子进程和 WebView 实例必须通过统一协调器申请;独立启动的旧入口在切换完成后不能绕过它。
  • 等待电脑空闲、等待未来时段、等待人工处理期间不持有游戏输入租约。
  • 每次运行独立 CancellationTokenSource;节点超时建立关联 CTS 并及时释放,不使用全局 CancellationContext 控制新引擎。
  • 计划可设置总运行时限,节点重试和公共恢复均消耗同一总预算。时限到达后走正常取消与清理,不强行把仍在运行的节点标成已停止。请求截止时间控制能否开始,与运行总时限分别保存。
  • 取消请求不等于停止完成。等待执行器退出、释放按键及资源后才能启动下一项;不配合取消的旧代码必须保持租约隔离并显示“停止中/待处理”,不能后台继续点击。
  • Shell 记录退出码并异步读取输出;超时/取消需要结束本次拥有的进程树并确认退出,不能只取消 WaitForExitAsync。
  • WPF 页面、窗口钩子和依赖消息循环的对象继续在 UI/STA 线程建立与释放;耗时执行、扫描、索引在后台。核心 DTO 和引擎不依赖 Dispatcher,通过 UI 适配层切换。

推荐执行顺序

请求准入 → 固定计划、引用与配置版本 → 校验资源 → 获取所需会话 → 核验 UID → 刷新关键状态 → 保存操作意图和额度占用 → 执行动作 → 确认副作用 → 在同一运行状态中保存证据、CD 和进度 → 释放作用域。

CD 和空闲条件至少在排队时、真正执行前分别检查。业务动作跨日切或账号状态变化时,再检查关键状态。状态写入锁只保护短暂的检查与保存,不能跨游戏操作持有。

异常恢复

恢复链为“识别已知打断 → 处理弹窗/等待网络 → 重新建立已知起始场景 → 核验副作用 → 按合同决定重试或待处理”。

场景 建议行为
加载中、短暂网络波动 在超时预算内等待目标状态,有限次重试,避免连续点击
已知日切月卡、已知奖励弹窗 由专用处理器处理,确认界面稳定后继续
进入已知剧情/菜单 按明确识别结果退出;任务重新准备起始状态
被顶号、需要验证码、UID 不符 停止该账号自动执行,进入待处理,不无限重登抢号
游戏退出、截图失效 按配置重建会话,重新核验 UID、游戏状态和未决副作用
背包空间不足、任务所需资源不足 返回明确原因并退出该能力;按计划策略跳过依赖它的部分或待处理,不自动销毁物品
版本更新或未知界面 保存诊断截图及步骤;有限恢复仍未知则待处理

恢复动作不能与业务动作同时发送输入。局部任务状态机负责自己的正常页面;公共恢复器只处理明确识别的全局中断。任务内部重试与公共恢复共享总时长/总次数预算,避免层层重试相乘。

游戏输入后必须等待响应再识别,不能立即用旧帧判断成功;可取得帧编号/时间戳时先要求新帧,再核验目标界面。公共中断处理不能被脚本清理自身实时辅助时误撤销;脚本只管理自己的作用域,宿主负责暂停、交接输入和恢复。旧 JS 无法安全交接时取消当前节点后重新准备,不承诺保留其任意指令位置。

任何“未知界面都能自动恢复”的承诺都不可靠。可持续做法是扩大已知打断覆盖面,记录无法恢复的样本,在明确预算内尝试,最后给出可续跑的位置。

续跑与版本快照

首版提供节点级续跑,满足“从这里开始”:

  • 运行前保存完整计划、引用展开结果、有效配置、账号绑定、资产版本与文件指纹快照。
  • 中断后展示已完成节点、未确认节点、失败节点和未开始节点。
  • “继续上次”创建关联的新运行,沿用原快照,跳过已确认完成的节点;对不确定节点先核验,不盲目重放。
  • “从此节点开始”是明确的运行范围;仍执行必要的会话初始化、账号核验、祖先条件与额度检查。
  • 被引用计划多次出现、重复执行多轮,靠 TaskAddress 区分,不能只存 NextTaskIndex。
  • 无法保留旧脚本版本时,检测指纹变化并提示改为按新版本新开运行,不能假装精确恢复。支持旧版本续跑的资源应保留不可变版本缓存。
  • 后续仅让有明确检查点的路线/内置能力保存 Checkpoint,不要求任意 JS、Shell 或 C# 从某一行恢复。

长任务的最小确认粒度建议是一次领取、一次秘境结算、一个路线段;即使首版只支持节点级续跑,已确认副作用也要即时入账。

C# 调用及自定义能力

两件事分别支持:C# 发起已有计划,以及 C# 实现一个新能力。以下仅为建议契约示意,不是当前可用 API。若模型验收选择只读执行节点,内部执行器入参可据此调整;外部请求、结果和取消语义保持一致。

csharp
namespace BetterGenshinImpact.Pulonia;

public interface IPuloniaTaskService
{
    Task<Guid> EnqueueAsync(PuloniaTaskRequest request, CancellationToken ct);
    Task<PuloniaTaskPlanRunView> GetRunAsync(Guid planRunId, CancellationToken ct);
    Task CancelAsync(Guid planRunId, CancellationToken ct);
}

public interface IPuloniaTaskExecutor
{
    string TaskType { get; }

    Task<PuloniaTaskOutcome> ExecuteAsync(
        PuloniaTask task,
        PuloniaTaskExecutionContext context,
        CancellationToken ct);
}

调用示意:

csharp
// 返回请求 ID;排队时不要求游戏或截图器已启动。
var requestId = await puloniaTasks.EnqueueAsync(
    new PuloniaTaskRequest { PlanId = planId, AccountId = accountId }, ct);

实际 SDK 同时提供按 requestId 查询状态与运行 ID、等待完成、查询节点结果和取消排队请求。请求调用的 ct 只控制提交/查询等待;持久化请求的取消使用显式 API,避免客户端断开导致语义不清。

提交时由服务生成并保护 ID、状态、来源等服务端字段,调用方只填写目标和允许的参数。同一个请求模型即可,不另建仅复制字段的 RequestSpec、Invocation 等中间层;需要边界隔离的外部 IPC 消息可以有最小 DTO。

PuloniaTaskExecutionContext 提供本次账号、有效配置、日志、进度、受控游戏能力及完成事件报告。内部组合使用 ExecuteChildAsync 一类同运行入口,继承同一取消、输入所有权和账本,并记录子步骤;不能通过再入全局队列等待自己,造成死锁或绕过记录。

JS 桥接同样提供可等待的结构化结果,包括状态、原因、确认程度与子步骤关联;保留旧 API 签名的兼容包装,但新脚本不需要伪造日志或按停止热键来报告失败。新树的 JS、C# 与直接执行都必须经过相同的能力适配和记账边界;旧脚本自存的 CD 只能作为带来源的迁移数据,未经核验不能与新账本重复累计。

外部 C# 程序使用轻量客户端,通过本机命名管道访问同一服务;复用当前实例通信基础设施的可用部分,增加协议版本和当前用户访问限制。先支持列出能力、提交计划/单动作、状态、取消;有明确远程需求时再考虑 HTTP。

保留 d-v3 中 C# 与 JS/Shell 同列的交互入口,但普通使用者选择已注册方法及参数。首版采用编译后 DI 注册的执行器或委托,明确注入取消令牌和返回 PuloniaTaskOutcome。不将“程序集扫描 → 猜测重载 → 无参构造实例”的反射链作为默认机制;否则改个方法名就会破坏任务文件,也难以保证取消和依赖注入。需要导入旧 MethodPath 时提供显式映射和不支持提示。

进程外工具通过 CLI/客户端调用。动态编译 .csx、热加载 DLL 和依赖隔离有确切需求再考虑,不引入 Roslyn 作为前置条件。


Docs Design Pulonia Migration (`Docs/design/pulonia/migration.md`)

Pulonia:旧代码重构与迁移

返回总览 · 用户故事 · 开发与验收计划

列出必须解耦的现有代码和一次性数据迁移策略。新菜单达到约定替代范围并通过用户验收后,才进入旧菜单切换。

原项目需要怎样重构

部分 处理方式
ScriptControlViewModel、OneDragonFlowViewModel、旧配置组/一条龙模型 新页面和新模型替代,不继续叠加新需求
ScriptService.RunMulti、TaskRunner 新树引擎接管编排;过渡期仅用于未迁入口,最终不在新调用链里
ISoloTask 和既有内置任务 初期适配成能力,逐项补强类型参数、结果和取消;不能包装 TaskRunner 后仍吞掉错误
ScriptProject 分离资源描述、参数编辑器、脚本执行器;保留引擎与脚本 API 兼容面
JS Dispatcher.RunTask、Genshin 等 移除 ViewModel 取配置,转向能力注册表和本次上下文;旧名称提供兼容映射
TaskContext、RunnerContext、CancellationContext 新代码使用运行作用域上下文;旧任务在串行隔离适配层中渐进迁移,不能假定 AsyncLocal 已解决全部静态共享状态
GameRuntimeService、输入框架、截图 Provider 复用;增加租约/所有权与取消衔接,保留已有线程要求
TaskTriggerDispatcher 保持截图帧实时辅助职责;与定时/热键请求触发器明确分离,支持动作作用域撤销
完成日志、锄地统计、断点记录 新账本与节点结果成为依据;旧日志可作历史展示,未经证实不得直接导入为某账号的可靠 CD
全局 AllConfig 与各功能设置页 设备配置保留,业务参数变成类型设置与共享预设;取消页面之间隐式取值
仓库与路线/脚本管理 保留资源获取和文件格式适配能力,新任务库接管查找、添加、编辑入口
快捷键与启动命令 统一提交请求,取消直接调用页面执行方法的分支
d-v3 的任务树模型与树表、选择器、记录详情 作为新实现参考,按 Pulonia 命名迁入;保留交互,补稳定 ID、参数校验、懒加载和保存边界
d-v3 的 Converter / Factory / BaseGearTask 树 / Runner 链 合并重复转换与调度职责;允许一次构建只读执行树,保留有价值的强类型参数与行为绑定;旧文件迁移单独处理
d-v3 的 EventBus / HistoryRecorder UI 进度用轻量通知;关键结果直接等待文件保存,历史写入由 Store 完成
d-v3 的 Quartz 配置与 Job 不迁入;转换可表示的日程,其余规则明确列出迁移差异

不需要先重写识别、地图匹配、战斗和所有路线算法。真正的前置重构是:显式参数、真实结果、可取消生命周期、状态作用域、输入所有权。

旧数据如何处理

“放弃旧实现”可以同时保留一次性导入器:把旧配置组转换为 group 加具体类型节点,把一条龙转换成新计划,把旧功能设置提取为预设。d-v3 的 GearTaskDefinitionData / GearTaskData 按原有 root_task / children 直接迁移,补 ID,转换参数字符串和目录类型;不要求重新手工录入。导入器应识别重复名称、旧周期语义、反射方法和无法映射的参数,生成差异报告。

保留原始文件和注释,迁移只写新存储。旧版无法确认账号归属的成功记录只读展示或让用户显式归属,不默认影响新账号额度。新入口达到功能对等后移除旧菜单,最终执行只有一套引擎。


Docs Design Pulonia Requirements (`Docs/design/pulonia/requirements.md`)

Pulonia:用户故事与设计边界

返回总览 · 开发与验收计划

这份文档是需求基线。原始故事和社区补充统一编号;来源细节放在调研依据,避免阅读需求时被实现细节打断。

设计的出发点是让玩家少配置、少空跑、知道实际完成了什么。 任务树、调度、记录和 API 都服务于这个目标。后续增减实体、页面或依赖时,先说明它解决下列哪条故事;不能仅因技术上可以扩展,就增加一层框架或一种配置方式。

用户故事清单

US-01—11 依据本次讨论整理,US-12 来自社区反馈。保留用户目标,不把建议的实现方式当成唯一解法;编号用于后文追踪。

编号 用户故事 用户应当看到的结果
US-01 目标驱动托管 作为初级玩家,我只想选择要培养的角色和要采集的物品,让程序了解我的角色 box、练度、库存、圣遗物与树脂,按优先级安排培养、每日/每周/每月事项,剩余时间再跑狗粮等可选内容;不想学习脚本仓库和订阅。 展示目标缺口、今日安排、资源预算以及选择理由;能一键开始、调整优先级。完整资产识别分阶段实现,识别不到时显示未知并允许补充。
US-02 定时且不打扰 作为日常托管用户,我希望每天自动启动程序,在不同时间运行指定任务,而且只在我没有使用电脑时运行。 配置可读的日程和有效时间窗;到点但不空闲时等待,恢复使用电脑时停止继续输入;过期、未启动、等待空闲都能解释。
US-03 刷新与完成判定 作为每天采集、刷怪和领奖的玩家,我希望程序记住哪些资源已采、哪些奖励已领,按真实刷新周期跳过,并区分执行失败与已经完成。 显示完成依据、剩余 CD、下次可运行时间;中途失败不把整条路线记成功,已完成部分也不丢失;日、周、月限制各自正确。
US-04 自由组合 作为喜欢折腾的玩家,我希望将内部功能、路线、JS、宏、Shell 和 C# 组合成新的功能,并复用已有任务组合。 在树里分组、排序、重复添加、引用子计划和设置条件;同一能力可以在不同位置使用不同参数,不靠修改脚本拼接流程。
US-05 开发者接入 作为开发者,我希望从 C# 调用任务执行接口,也能用 C# 提供新任务,获取状态、结果并取消执行。 UI、JS、C# 和外部客户端复用同一执行语义;不依赖某个页面先打开;纯 Shell/纯计算任务不会被迫启动截图器。
US-06 中断续跑 作为长任务使用者,我希望中断后知道做到哪里,能继续剩余内容,或直接指定从树上的某一项开始。 首版做到节点级续跑;保留当时的计划、参数和资源版本;结果不确定的消费/领奖步骤先核验。无需任意脚本逐行恢复。
US-07 异常恢复 作为无人值守用户,我希望网络中断、顶号、剧情界面、日切月卡和版本变化等异常不会让程序一直乱点或卡死。 已知异常在有限次数与时间内恢复;未知界面或账号风险进入待处理,留下原因和续跑位置。
US-08 快捷触发 作为经常使用小功能的玩家,我希望给 JS、任务组合或树中一个子任务设置自己的快捷键。 热键能触发任意受支持任务;忙碌、重复按键和取消行为明确,所有入口共同管理输入占用。
US-09 设置清晰一致 作为配置者,我希望明确任务实际使用哪套设置、为什么生效;一条龙能直接展示复用的独立任务配置。 简洁视图、树节点和独立运行使用同一个参数编辑器,显示有效值与来源;覆盖、继承和共享修改可区分。
US-10 快速找到并添加 作为采集玩家,我希望按角色、材料、地区或用途找到任务,快速添加整个目录,不因大量路线而卡住。 搜索、预览、批量添加在同一入口完成;支持引用目录和展开编辑,不要求手工复制文件、建链接或运行脚本生成菜单。
US-11 多账号托管 作为多账号玩家,我希望自动切换账号,依次执行各账号的一条龙,分别使用对应目标和配置。 切换后核验实际 UID;每个账号的目标、预算、进度和 CD 不串用;登录失败不能接着在上一个账号执行。
US-12 编辑成果可保留 作为已配好任务的用户,我希望跨计划复制子树时保留完整参数,误删或误拖后能撤销,不必重启程序或手改 JSON。 复制参数类型不变、继承差异可见、新节点有新 ID;删除、移动、批量添加可以撤销。来源见社区需求依据。

用这些故事约束后续设计

  1. 默认路径必须短。 选择角色/物品、确认目标与日程、查看推荐安排、开始运行;高级预设、脚本来源和配置继承按需展开。
  2. 树编辑器与一条龙是一份计划的两种视图。 简洁视图不能另存一份隐式参数,编辑、触发、C# 调用不能再出现多套执行结果。
  3. 先完成可替代的任务系统,再逐步扩大识别和规划能力。 不用“智能规划”掩盖底层没有结果、不能取消、CD 不可信等问题;也不能以只完成执行器为由取消 US-01 的最终托管目标。
  4. 公共问题由宿主解决,游戏业务仍由具体能力解决。 宿主负责配置、时间、身份、记录、输入协调和已知全局中断;材料数据、配队、采集路线和奖励核验留在内容/能力中。社区脚本可以继续提供专业策略。
  5. 复杂性要有来源。 保留 d-v3 的树格式与有用交互,统一使用 PuloniaTask 相关命名;不引入 Quartz、SQLite,不增加多机编排、任意并行图或通用规则平台。社区已有外部网站、多人同步方案不自动成为本次交付范围。

用户故事如何落地

开发顺序和每步验收门槛以开发计划为准。这里保留追踪关系,不再维护另一份分期表。

故事 主要设计 开发步骤
US-01 目标托管 模型、托管、界面 11 基础托管、12 扩大识别
US-02 定时空闲 调度、执行 7 触发与空闲
US-03 CD 与证据 结果与存储 6 记录/CD/续跑,12 提高观测精度
US-04 自由组合 模型与树、界面 1 模型、2 编辑、3 执行、4 能力适配
US-05 开发者接入 执行与 API、迁移 3 进程内 C#、4 JS 子调用、10 外部客户端
US-06 续跑 执行、结果与存储 6 节点续跑
US-07 异常恢复 执行 4 取消边界、8 有界恢复
US-08 快捷触发 调度 7 任意计划/子树热键
US-09 设置一致 配置模型、界面 2 参数与继承、9 一条龙视图
US-10 查找添加 界面与任务库 5 索引与目录添加
US-11 多账号 托管与账号、结果与存储 1 预留身份、6 隔离账本、10 顺序切号
US-12 编辑保留 界面、存储 2 参数保真复制、撤销与保存反馈

Docs Design Pulonia Research (`Docs/design/pulonia/research.md`)

Pulonia:社区需求与现有实现依据

返回总览 · 用户故事 · 开发与验收计划

本文件保留 2026-10-02 核查的 23 个 issues、10 个 JS 脚本以及本体和 d-v3 代码依据。它解释需求来源;当前设计决策以各专题为准,不直接照搬脚本的成功推断或配置方式。

GitHub issues 补充的用户故事与验收要求

2026-10-02 检索主仓库 GitHub issues,阅读下列条目的正文,并检查与判断有关的讨论。下表是对需求的归纳,不是对所有报告问题的复现结论。状态为本次查询时的状态;已关闭条目作为历史回归样例,开放也不表示当前代码一定尚未处理。机器人生成的分析不作为用户需求或技术结论依据。

关联故事 来源与用户反馈 纳入设计的用户故事 / 验收补充
US-01 培养与树脂规划 #3396 按提升指南选秘境、#3716 体力计划(均开放):多角色少量材料需要频繁换副本,希望按需求安排多个秘境和次数。 我不需要记住材料来自哪个副本,也不希望一天的树脂被第一个目标全部耗尽;先展示目标、开放日、预计次数和共用预算。提升指南可作为后续目标来源,不强制变成首版扫描依赖。
US-04 组合与复用 #678 子配置组、#1234 配置组优化、#2924 重复添加任务(均开放):希望嵌套组合、显式换队、重复安排邮件/奖励等动作。 我能直接插入“换队”能力,前后各放一次同种任务,并引用一组现成步骤;不能靠先放一条无关路线触发隐藏初始化。重复节点有独立配置与进度,实际资源资格仍共享。
US-09 设置可信 #3168 未覆盖时未按预期继承、#3235 切换预设后界面不更新、#3323 设置过多(均开放)。 我切换计划/预设后看到的必须就是将要执行的参数;公共设置和节点差异可展开查看。吸收 #3323 的设置展示诉求,不把其全部专项功能建议扩成执行内核需求。
US-10 大目录管理 #2530 路线折叠(开放,讨论中已指出分支实现)、#1307 大量项目删除卡住并读档失败(已关闭)。 我能按目录折叠、批量操作;取消或操作失败后原配置仍完整。采用 d-v3 已有树交互,不把“已有分支实现”误写成全新需求。
US-12 编辑成果可保留(补充) #2204 跨配置组复制粘贴、#2205 删除撤销(开放)、#2797 复制后 JS 配置异常(已关闭)。 作为已花时间配好任务的用户,我希望跨计划复制子树时保留完整参数类型,误删/误拖后能撤销;无需重启软件或编辑 JSON 才恢复。复制默认生成新节点 ID,引用复用是另一项明确操作。
US-02、08 日程与热键 #1678 定时启动一条龙、#1207 一条龙快捷键和次数(开放)。 我能给计划或子树设置时间/热键,并决定忙碌时怎么办。到点默认排队;若选择切换到新计划,必须先确认旧任务停止并释放输入,不直接抢占。
US-03、05 可判断的结果 #3662 自动钓鱼失败需要返回值、#3735 JS 路线与直接路线统计不一致(开放)。 作为脚本作者,我希望知道“未进入钓鱼”“路线失败”“完成但未确认”等原因,才能决定重试或记 CD;同一动作不因从 JS 间接调用就绕过额度检查,也不被父子两层重复统计。
US-05 外部控制 #1596 开放 Web 接口(开放):希望无需远程桌面就能启动、停止和查看任务。 保留外部客户端使用同一任务服务的方向;本次先交付 C# 与本机通信。HTTP/远程面板是后续传输适配,不为它增加第二套运行模型,也不承诺无交互桌面运行游戏。
US-07 恢复与结束 #2941 登录网络异常后重试、#3518 日切月卡阻断长 JS、#2485 卡住影响次日任务(均开放)。 我不需要给每个脚本各写一份月卡处理;公共恢复由宿主协调。计划有总时限,恢复失败后保留进度、结束或待处理;停止未完成时不能强行开始下一次。
US-01、07 前置条件不足 #3305 圣遗物背包满导致一条龙卡住(开放)。 我希望预算耗尽、背包空间不足或内容不可用时给出原因,能继续不依赖这些条件的任务;不能把“无法领取”记作完成。清理背包需选择具体清理任务和参数,不是通用恢复器的默认动作。
US-11 账号配置 #1933 多账号一条龙配置区分、#2924 同一条龙处理大小号(开放)。 我的账号身份、目标与预设应显式绑定。吸收按 UID 选择配置的需求;“UID 识别失败就继续用当前配置”不用于无人值守多账号流程,避免串号。

JS 脚本已经表达出的需求

本体仓库的 Core/Script/ScriptRepoUpdater.cs 指向官方内容仓库 bettergi-scripts-list。本地编译输出中也存在该仓库的缓存;为便于复核,以下统一使用远端提交 6be613e7dbb3debf7b242146fbee43ddd8a1b4a1 的 README、manifest 和相关执行代码。这里只做静态阅读,没有运行这些脚本或调用它们的外部服务。

“观察”描述脚本当前提供或文档要求的行为;“设计启示”是本概要的归纳。脚本里的刷新常量、成功推断和配置技巧不能直接视为游戏事实或可靠执行合同。

脚本与可核查来源 观察到的需求和现有做法 对新系统的设计启示
AutoPlan 自动体力计划:说明、入口代码 按队伍、目标、星期、次数和优先级安排多个内置任务;支持 UID 配置。入口还检查圣遗物空间和活动可用性。 US-01、09、11:将目标和计划做成可编辑表单,预算与账号统一管理;无需让新手填写分隔符表达式或部署额外配置站点。
角色养成一条龙:说明、代码 用户先从养成计算器抄材料总量,脚本检查数量并刷取;说明要求未刷完不要使用材料,且秘境配置仍需在独立任务页调整。 US-01、09:区分角色培养目标与简单库存目标,允许刷新已变化的练度/库存并重算缺口;在当前计划直接显示所用参数。不能把“用户期间不玩游戏”当作系统成立前提。
CD-Aware-AutoGather:说明、执行判定 订阅后扫描材料,按地区/类别批选,按目标库存和世界身份管理刷新;代码在部分场景用位移或耗时推断路线执行情况。 US-03、10、11:吸收材料目录、目标数量和世界归属;扫描资源应是资源索引工作,不要求启动游戏任务生成菜单。位移/耗时只算估计,不能证明采集成功。
采集 CD 管理:说明、代码 自行组织路径组、每日采集数量、队伍、优先级、禁用/结束时段、各账号 CD,并根据拾取和耗时调整路线;初始化还需复制路线、运行一次刷新自定义配置。 US-01、02、03、04、10:明确“今天新采多少”与“背包补到多少”的区别;支持子组配置和共享 CD,把文件搬运与刷新菜单的步骤收回任务库。路线效率优化属于规划策略,不增加执行服务层。
背包材料统计:说明、代码 结合库存、目标数量、CD 选择路线;维护黑名单、弹窗处理和路径检测码,试图保留改名路线的记录;使用前仍需订阅并复制路线。 US-01、03、07、10:复用带观测时间的库存快照、资源标识和公共弹窗处理。未知数量不能默认按 0 消费预算;路径指纹可辅助迁移,不能取代真实资源 ID。
AutoHoeingOneDragon 锄地一条龙:说明、执行结果处理 不同路径组共享账号目标、各用队伍/时间安排;路线记录要结合宿主返回值、坐标和回主界面检查,README 明确说明宿主不返回结果时无法可靠确认成功。 US-03、04、05、09:让脚本拿到统一结果、共享额度和子步骤记录;组级目标不能重复分配为每组一份。成功走完和实际掉落数量继续分开。
AccountSwitchStateMachine:说明、切换后核验 以状态机切换账号,支持 UID 校验;UID 不符时可通过配置的停止热键尝试终止配置组。 US-07、11:切号结果必须直接约束后续执行,不能让脚本模拟“停止快捷键”才能防止串号;停止、失败和待人工登录使用统一状态。
waitUntilSpecifiedTime:说明、代码 在任务序列中等待到某个钟点,并区分今日已过时立即跳过或等到次日;实际通过长时间 sleep 等待。 US-02:日程、过期和再排队应是调度职责;等待未来时刻时不占游戏输入,也不阻塞其他已经到期的请求。长等待脚本不作为新定时功能的内部实现。
每月自动兑换抽卡资源:manifest、代码 按 UID 保存每月兑换记录,兑换资源不足时通知,未刷新时跳过。 US-03、11:月周期也是同一套账号账本规则;通用执行器记录结构化结果,具体兑换能力负责确认商品、数量和余额。能力进入模板需显式选择,不因“托管”就自动纳入所有购买行为。
AutoPathingLoader-MultiUser:说明、代码 多人联机同步路线,需要一致的路线文件并校验内容,区分队长/队员和所在世界。 US-03、11:资源归属不能只看当前登录账号,运行应固定路线版本。多人同步是独立扩展需求,本次多账号先做单会话顺序切号,不扩成多机调度系统。

现有代码中的依据

现状 代码位置 对设计的影响
配置组在执行具体项目之前统一启动游戏任务环境 Service/ScriptService.cs:128、:154 新执行器不能调用整个 RunMulti 作为核心实现
通用 TaskRunner 初始化要求截图器已启动,并管理全局取消与运行上下文 GameTask/TaskRunner.cs:46、:147 将任务生命周期与游戏生命周期分开
一条龙由 ViewModel 编排;任务项按名字选择实现、读取其他 ViewModel ViewModel/Pages/OneDragonFlowViewModel.cs:573、Model/OneDragonTaskItem.cs:57 执行业务从页面抽出,用稳定能力 Key 注册
JS 内部任务调用会获取 TaskSettingsPageViewModel Core/Script/Dependence/Dispatcher.cs:132 开发者 API 必须依赖参数与服务,不能依赖页面初始化
路线成功使用 SuccessEnd,其他类型正常返回后统一设为成功 Core/Script/Group/ScriptGroupProject.cs:195、:248、:317 引入统一结果,并区分执行结束与目标确认
执行记录保存为每日 JSON;包含组名、文件名,没有账号字段 GameTask/LogParse/ExecutionRecord.cs、ExecutionRecordStorage.cs 新账本按账号、游戏资源和周期组织,与当前进度在同一运行状态文件中保存
添加路线会递归建立完整 UI 树,逐项调度到 UI 线程 ViewModel/Pages/ScriptControlViewModel.cs:911、:985 建立资源索引、按需加载、虚拟化;这是代码层面的瓶颈判断,尚未做性能测量
已有运行环境服务及 Provider GameTask/Runtime/GameRuntimeService.cs:25 在其上增加资源租约与会话准备,保留已有 Win32/WebPage 分支
已有角色练度、背包物品计数、树脂及 UID 识别 GameTask/CharacterDevelopment/CharacterDevelopmentTask.cs:88、GameTask/Common/Job/CountInventoryItem.cs:50、GameTask/Common/ResinRecognition.cs:31、Core/Script/Dependence/Genshin.cs 可以逐步接入托管,但不代表已有完整账号资产扫描
已有可处理界面状态、重试和超时的状态机 GameTask/Common/StateMachine/README.md 复用任务内部状态机,不再另建一套通用状态机框架

上述路径均相对 BetterGenshinImpact/。这是架构梳理,不是对现有代码进行完整缺陷审查。

d-v3 中直接吸收的设计

以下位置均指 d-v3@b7aa7fc66,以代码实际行为为准;分支中的 GearTaskExecutor_Usage.md 与现有实现有差异,不作为实现依据。

已有实现 采用方式 分支代码位置(相对 BetterGenshinImpact/)
计划元信息 + root_task,节点递归保存 children 保留 JSON 的整体形状,补充稳定 ID、版本和明确的参数类型 Model/Gear/GearTaskData.cs:11、:38
左侧计划列表,右侧任务树表 保留“名称、类型、启用、最近结果、操作”的同一行展示;参数按需打开 View/Pages/GearTaskListPage.xaml:462、:653
拖入分组、节点前后插入、展开状态 保留交互;补上禁止拖入自身/后代、移动后保存一次 View/Pages/Component/GearTaskDragDropHandler.cs
地图路线搜索、目录 README/文件 JSON 预览、目录任务数量 合并到新任务选择器;代码/JSON 预览作为高级页签 View/Windows/GearTask/PathingTaskSelectionWindow.xaml
按目录引用、逐个添加、保留目录结构 保留能力,收敛成“引用目录 / 展开为任务”两种模式及一个结构选项 ViewModel/Windows/GearTask/PathingTaskSelectionViewModel.cs:390、:459
JS 目录摘要、搜索、README/源码按页签加载 保留按需读详情;异步结果绑定当前选项,避免快速切换后显示旧内容 ViewModel/Windows/GearTask/JsScriptSelectionViewModel.cs:158
树上最近结果、历史详情、断点、继续执行/从头执行 保留;补充结果证据和任务版本变化提示 View/Pages/GearTaskListPage.xaml:695、:744
定时与热键分开展示,显示关联任务和启用状态 保留;关联对象改为稳定 ID,默认展示可读日程而非 Cron View/Pages/GearTriggerPage.xaml
每次执行一个 JSON 历史文件,临时文件后替换 沿用文件存储思路,补上单写入者及关键状态一致性 Service/GearTask/Execution/GearTaskHistoryStore.cs:28
路线任务可报告内部恢复位置 保留可选检查点;首版先确保节点续跑可靠 Model/Gear/Tasks/PathingGearTask.cs:12、Service/GearTask/Execution/GearTaskExecutionModels.cs:173
C# 方法调用接入同一任务树 保留扩展入口,采用显式注册的强类型方法或执行器 Model/Gear/Tasks/CSharpReflectionGearTask.cs:19

这些是从 XAML、ViewModel 和执行代码确认的交互设计,不代表已经验证实际渲染效果或大目录性能。路线选择器的 LoadDirectChildrenFromRepo 目前仍递归加载目录,不能据此认定它已经实现懒加载。

保留树结构,收敛实现层次

分支的实际执行链包含存储模型、ViewModel、GearTaskData、GearTaskConverter、GearTaskFactory、BaseGearTask 树、GearTaskExecutor、GearTaskExecutionRunner;记录又经过事件总线与后台 Recorder。职责有价值,但这些内容不必逐层对应一个可扩展框架。

新方案保留一个持久化任务模型、薄 ViewModel、统一任务服务和类型执行器。执行前允许集中构建只读执行树,用于展开引用、解析配置和参数、校验及固定版本;具体形式在模型阶段验收。简化重点是合并仅做转发的转换与调度层,不因存在可执行树就否定 d-v3。保存服务不返回 ViewModel;关键结果直接等待保存后推进,界面进度使用普通事件或 IProgress<T>。

另外保留以下改进边界:节点标识不再采用 0/1/2 这类随排序变化的位置;配置不再强制复用旧 ScriptGroupConfig;不在入口统一 StartGameTask();参数错误不能默默退回默认值;节点创建或参数转换失败不能变成一个被禁用后跳过的错误占位任务。


Docs Design Pulonia Results And Storage (`Docs/design/pulonia/results-and-storage.md`)

Pulonia:结果证据、CD 与文件存储

返回总览 · 用户故事 · 开发与验收计划

明确什么时候算完成、什么时候可重做,以及如何用 JSON 保存一致的进度和账本。不引入 Quartz、SQLite 或 ORM。

CD、周期次数和“成功”的处理

三个问题分别回答

问题 由谁处理 示例
什么时候发起? PuloniaTaskTrigger 每天 20:00
现在能不能开始? 准入与节点条件 电脑空闲、指定账号可用、今天开放
这个资源现在能不能再做? 能力规则 + PuloniaTaskLedgerEntry 此点位未刷新、周奖励已领、今日上限已到

“每周跑一次”是执行偏好,“某奖励每周只能领取一次”是游戏事实。这两类规则需要区分;只有击败某敌人也不等于领取了奖励。

资源规则

首版 PuloniaTaskAvailabilityRule 支持 Unrestricted、RollingCooldown、ResetQuota 三种;一个能力可有多条规则,例如独立的奖励资格与共享次数额度。

  • 滚动 CD:从已确认的采集/领取发生时间计算 NextEligibleAt。例如某资源规则设为 48 小时,这是示例参数,不代表所有采集物规则。
  • 周期额度:按服务器对应日历计算 WindowKey,保存本周期已消耗数量。每日、每周、每月及其他游戏周期由内容数据描述,不能统一写成“过去 24 小时”。
  • 开放日:是日历条件,不能混同 CD。优惠次数、奖励次数、树脂和物品数量也是不同约束。

时间统一存 UTC;周期边界按账号服务器配置转换。优先记录奖励/采集发生时刻,而非整条长任务的开始或结束时刻。跨边界但无法确认事件属于哪一期时保留不确定状态并核验,不猜测为新周期成功。

去重键与作用域

text
ScopeKey = 服务器 + 账号 UID                  // 账号奖励
ScopeKey = 服务器 + 世界拥有者 UID            // 世界采集物,联机时尤其重要
EffectKey = 稳定活动/资源/点位 ID
WindowKey = 规则对应周期起点,滚动资源则使用其活动状态

默认 CD 跟随实际资源,不跟随计划名、节点 ID、文件路径或脚本文件哈希。路线重命名、复制到另一计划、更新代码都不应清空同一资源的 CD。

精度分级:有点位 ID 和确认事件时按点位记;旧路线只能按稳定路线 ID 估算,并明确显示“路线级估计”。两条不同路线可能采同一批点位,没有覆盖关系元数据就无法准确去重。首版保留这个限制,后续给精选路线补 ResourceCoverage,不能宣称路线级 CD 已完全解决重叠。

完成合同

PuloniaTaskOutcome 区分 Succeeded、ExecutedUnverified、PartiallySucceeded、Failed、Cancelled、Skipped、NeedsAttention,并附机器可读原因、证据、产出和已确认副作用。

场景 可以确认什么 账本处理
Shell 退出码为 0 进程按约定成功 可标记 Shell 能力成功,不推出游戏资源完成
路线走到终点 路线执行结束 没有采集证据时只能估计,不能宣称全部物品到手
奖励界面确认、领取状态变化 指定奖励已领取 更新对应周期额度
拾取提示/计数变化/点位确认事件 已观测的采集事实 记录对应资源;批量计数变化不自动推断每个点位都成功
老 JS 正常返回 脚本执行结束 默认 ExecutedUnverified;可选执行频率限制,但与资源事实分开
执行 10 个点位后失败 10 个已确认点位完成 已完成部分保留 CD,剩余部分仍可执行

新 JS 和 C# 可报告结构化完成事件;内置高价值动作增加独立后置核验。脚本作者声明、视觉核验和人工确认要保留来源差异。精选托管模板对消耗额度的动作要求更可靠的完成合同。

针对 US-03、05,内置能力向调用者返回明确原因,例如“未能进入钓鱼界面”“背包空间不足”“树脂不足”“奖励已领取”;调用者不必解析日志判断是否记 CD。通过 JS 调用的路线与直接运行的路线进入同一结果/额度路径。子步骤已经提交的完成事件,父节点只能汇总引用,不能再次计入消耗;路线声明的预期怪物数也不冒充实测击杀数。

防止重复执行与崩溃后的误判

  1. 任务服务串行检查额度,在同一份 state.json 中保存当前操作意图和占用,保存成功后才执行可能产生副作用的动作。
  2. 每条确认事件带稳定 EventKey。将证据、对应额度变化和节点进度作为一次状态更新保存;重试提交相同事件不能重复计数。该路径直接等待写入完成,不经可延迟丢失的进度事件。
  3. 执行中已确认的局部副作用立即记录,不等整个计划完成才保存。
  4. 程序崩溃后,遗留运行与占用转为 Interrupted/Uncertain,不能按租约过期直接当成从未执行。
  5. 先核验游戏当前状态,再决定补记成功、释放占用或重新执行。无法核验的领取/购买/消耗动作进入待处理;可重复且无业务风险的动作才允许自动重跑。

游戏操作与本地文件无法同时提交,因此不能承诺任意任务严格“只执行一次”。需要通过执行前意图、完成证据和不确定态处理缩小重复窗口。文件损坏或写入失败时停止自动推进;不能把损坏的账本重置为空后继续刷取。

用户可以查看“上次确认时间、来源、剩余 CD、下一次可执行时间”,也可以人工校正“游戏外已经采过”。程序未观察到的手动操作不能自动知道;缺少相关信息时显示未知。

默认遇到 CD 跳过当前节点并继续后续任务。需要等 CD 的请求释放资源后重新排队,并设置截止时间,不能让整个一条龙原地等待几小时。人工强制尝试只忽略执行偏好或估计限制,不伪造游戏奖励资格、不清空历史。

存储与第三方库

明确不引入 Quartz、SQLite、EF Core,也不实现一套自制数据库或通用事件溯源框架。沿用 d-v3 的 JSON 文件方向,用单主实例、单写入者和完整文件替换解决当前本地串行场景。

文件组织

text
User/Pulonia/
├─ plans/<planId>.json              # 元信息、root_task、触发器、账号绑定
├─ presets/<presetId>.json          # 可选共享预设
├─ accounts/<accountId>.json        # 账号资料;托管阶段含目标与最近状态快照
├─ state.json                      # 队列、调度游标、活动运行、CD/额度、待归档运行
├─ state.json.bak                  # 上一个有效状态版本
└─ history/<planId>/<runId>.json    # 已结束运行的不可变快照及节点结果

文件用稳定 ID 命名,名称只用于展示。计划重命名不改变引用和历史位置。PuloniaTaskStore 返回模型,ViewModel 只负责编辑;新文件用 Newtonsoft.Json、UTF-8 无 BOM,既有文件仍按原格式导入。

配置文件可分别保存,但运行时在开始前将用到的计划、预设、账号绑定与资源版本固定进 ActiveRun.Snapshot。不同配置文件不要求同步提交,实际用的是哪一版在快照中可追溯;调用者不能边执行边改这份快照。

关键状态只保存一次

state.json 是调度、运行进度和 CD 的唯一当前依据,以下变化在内存中一次完成,再保存成一份文件:

  • 到期请求入队与调度游标前移。
  • 请求出队与活动运行建立。
  • 执行前操作意图、账号身份、资源占用。
  • 完成证据、额度消耗、事件去重键与任务进度推进。

首版一个主协调器拥有状态写权限;已有多实例通过 IPC 提交请求,不能各自直接改这个文件。写入流程为:在状态锁下基于旧版本生成候选状态 → 写同目录临时文件并刷新 → 完整替换正式文件并留备份 → 更新内存可见版本 → 释放锁。保存失败则不提交候选状态、不推进下一动作;游戏操作期间不持有状态锁。

可以使用 File.Replace 替换已存在文件并保留备份,首次建立文件使用同目录移动。重要写入显式刷新到底层存储;不能把只完成内存缓冲的异步写入等同于耐久落盘。这些系统 API 不能消除磁盘故障、突然断电或外部游戏操作的不确定性。File.Replace 文档、.NET 文件 I/O 说明

这是一份状态文件的替换,不是多文件事务协议。不要把进度写到一处、CD 写到另一处,再依赖后台事件碰巧同时保存。

历史、故障恢复与写入频率

运行结束时先将完整结果放进 PendingArchives 并保存状态,然后写对应的历史文件;历史文件写成功后,再从状态移除待归档项。中途崩溃可以按同一个 RunId 重试归档。历史读取不参与 CD 判定,历史保存失败也不能导致任务被重新执行。

启动时校验格式、版本和内容,优先读取正式状态;只能从备份恢复时,必须显示回退并核验遗留运行/未决操作。两份都无法读取时停止自动运行,保留文件,不把状态清零继续。备份回退不能证明丢失时间段中没有游戏操作。

UI 进度、当前坐标等可合并刷新;任务开始/结束、消费或领取确认、取消和关键断点必须等待保存完成。收尾保存使用独立且有时间上限的取消控制,不能直接复用已经取消的任务令牌。关键完成事件不依赖 UI 订阅者;UI 的更新异常不改变业务结果。

配置编辑保留自动保存体验,但拖拽和批量添加只保存一次,普通字段编辑防抖;显示“保存中/保存失败”,失败后保留编辑内容。文件替换只保护完整性,防抖和单写入者才解决重复写与旧快照覆盖问题。

state.json 只保留当前队列、活动运行、未归档结果和仍有效的 CD/额度;不塞入全部历史、完整日志或图片。历史可按数量清理,但不能删除仍约束当前执行的额度状态。若实测证明全文件写入成为瓶颈,再讨论局部优化,不预先引入 WAL、索引树或数据库。

依赖选择

选择 建议
SQLite / ORM 不引入,使用上述 JSON 存储
Quartz / Hangfire / Elsa 不引入,.NET 定时循环足够承担本机触发检查
Cronos 当前不新增;确需高级 Cron 时再单独评估,不能为替换 Quartz 顺带引入另一套调度框架
.NET 的 PeriodicTimer、取消令牌、锁、文件 API 用于调度检查和单写入者;不增加第三方依赖
Newtonsoft.Json 复用项目已有依赖
Windows 任务计划程序 使用系统能力负责应用不在运行时的启动,无需引入大型调度平台
WPF-UI、MVVM Toolkit、Behaviors、gong-wpf-dragdrop 沿用现有 UI 技术
ClearScript、现有识别/状态机/运行环境 封装复用,移除对页面和全局运行状态的依赖
Roslyn、通用规则引擎、新状态机库 首版不需要

当前方案不要求新增第三方库。时间规则边界、关键文件写入和恢复是需要验证的实现内容,不能因为没有框架就省略这些语义。


Docs Design Pulonia Scheduling And Managed (`Docs/design/pulonia/scheduling-and-managed.md`)

Pulonia:触发、托管与多账号

返回总览 · 用户故事 · 开发与验收计划

调度决定何时发起请求,托管决定做什么,多账号决定为谁执行;三者复用同一任务服务。

定时、热键与空闲运行

调度语义

  • 用户设置一次性、每日多个时刻、每周指定日期、每月指定日期或固定间隔;首版用结构化字段表达,不要求输入 Cron。TimeZoneId 明确时区,定时的时区与游戏服务器刷新时区可以不同。
  • 定时与热键都生成请求,不直接调用截图器或页面命令。一个触发器可选择整个计划或子树;单动作快捷方式内部也是一个小计划。
  • 多账号绑定展开为有序请求,使用同一个 BatchId。默认一个账号完整执行后再处理下一个,减少频繁切号。
  • 定时请求使用 (TriggerId, OccurrenceUtc, AccountId) 去重,入队与推进调度游标在同一次 state.json 保存中完成。恢复后重复扫描不能创建重复请求。
  • 同一计划、同一账号已有活动请求时,自动触发默认合并或跳过并记录原因;热键默认不重复排队,可配置排队一次。
  • 不同计划同时到期默认排队。针对“到点改跑每日计划”的需求,可显式选择“停止当前运行后执行”,但必须等待取消与资源释放成功;无法停止时显示受阻,不能同时驱动游戏。
  • 补触发默认“有效时间窗内合并补一次”,而不是把关机数天漏掉的每日任务全部补跑。过期显示为过期,不计成功。
  • 空闲条件在真正启动前重新检查。开始后用户操作电脑,立即停止新增游戏输入,在可取消边界暂停;不支持暂停的旧任务取消当前节点,后续从该节点重新准备。

暂停游戏输入不代表游戏世界暂停。恢复时必须重新检查场景与 UID;不能直接恢复原先按住的按键。

应用内采用一个 PeriodicTimer 循环(例如每秒检查一次)计算到期项、检查空闲和提交请求;使用一个异步消费者避免重复回调,执行任务在独立的串行消费循环中进行,不能阻塞调度检查。定时器只负责唤醒检查,是否到期以持久化游标和当前时间判断,睡眠恢复时补检。.NET 官方实现

时区规则明确为:夏令时缺失时刻顺延至第一个有效时刻,重复时刻每个日程只触发一次;本月不存在的日期默认跳过并在预览中显示。固定间隔以保存的锚点计算,不能按程序每次启动时间重置。时钟回拨结合游标去重,时钟前跳按漏触发策略处理。调度计算接受可注入的时间来源,便于验证这些边界。

d-v3 的 Quartz Cron 不直接复制:能等价表示的规则转换成结构化日程;暂时不能表示的规则保留原文并标记待调整,禁止静默改变时间语义。如果之后确实要完整保留高级 Cron,再单独评估轻量解析器;Quartz 不作为备选引入。

程序没有启动时

采用 Windows 任务计划程序负责启动 BGI/唤醒调度检查;BGI 内部负责真实任务条件与队列。操作系统调度和应用调度使用同一份触发配置生成,避免两套独立时间表。

启动参数例如 --pulonia-dispatch 或 --pulonia-run ;程序已启动时将请求转给主协调器,不另开执行实例。游戏任务需要登录后的交互桌面;Windows 的 TASK_LOGON_INTERACTIVE_TOKEN 只在已有交互登录会话中运行。微软文档

首版不承诺关机自动开机、自动登录 Windows 或锁屏状态下前台游戏操作。设备唤醒作为明确的可选项;解锁后仍在有效时窗内才补执行。

“没有动电脑”的定义

检测当前交互会话的用户活动,区分自身模拟输入与用户/其他程序活动;只读取空闲秒数会受到模拟输入影响。微软文档明确 GetLastInputInfo 是会话范围,且事件时间可能受 SendInput 影响。微软文档

可复用现有键鼠钩子,在输入后端给本程序注入事件加标记并过滤自身事件,不能把所有注入事件都忽略。不同输入后端能否可靠区分需要实际验证;无法判断时按用户正在使用处理。开始前还要检查桌面解锁、前台输入资源可用,不只判断鼠标有没有移动。

自动托管与多账号

新手路径

选择账号 → 选择角色及目标练度/物品数量 → 选择运行时间 → 查看今日安排 → 一键托管。

内置任务目录按角色、材料、任务用途检索,并维护精选资源包。下载、依赖解析、版本锁定在后台完成,用户无需理解仓库订阅;缺失资源显示明确下载/不可用状态。底层脚本来源、版本和权限仍能在详情中查看。

规划器输入为 PuloniaDevelopmentGoal + PuloniaPlayerSnapshot + 游戏规则数据 + PuloniaTaskDefinition + 账本,输出仍是 PuloniaTaskPlan 的具体执行内容。

首版采用可解释规则:时限与用户优先级 → 今日可刷且有缺口的培养材料 → 日周月事项 → 剩余时间的可选采集。树脂等预算贯穿整个账号计划,不能每个目标独立分配一份总树脂。

可先生成“刷新账号状态”准备步骤,再在明确的规划阶段生成并冻结执行子树,记录“为何添加/跳过”。执行中发现状态变化时,仅在节点边界重新规划未执行部分,并留下新版本记录,不改写已执行节点。

不能依赖昨天的快照决定今天一定有多少树脂;花费前核验关键数量。读取不到材料数量不等于材料为 0。游戏规则表、材料来源和开放条件需要版本维护,不能由调度器凭空推断。

物品目标必须明确口径:“库存补到 N”使用最新库存计算缺口;“本周期新采 N”使用该周期已确认产出;“角色培养到指定练度”从当前练度与目标重算剩余材料,再扣除可用库存。用户手动使用、合成或消耗材料后刷新相关状态,不能沿用旧总数持续补货,也不能用静态材料阈值假装已经完成角色培养。相关观测缺失时显示待刷新/待确认,不默认目标清零或无限刷取。这三种口径是目标上的字段,不各建一套规划系统。

天然树脂、浓缩树脂、补充道具及付费补充要有清晰的可用预算和用户选择,预算不足时换候选任务或结束。圣遗物随机收益和偏好意味着“最佳”应表达为“符合当前目标与预算的推荐安排”,并允许改优先级。

自动识别的实施边界

已有角色接口主要读取指定角色的属性/武器/天赋;背包计数主要面向目标物品。完整 box 枚举、全背包/圣遗物资产建模、装备评估和可靠增量更新仍需独立实现和验证。

分层落地:先用户选目标 + 扫描必要字段;再完善角色集合和物品缺口;最后扩展圣遗物与更复杂培养策略。这个顺序不影响同一执行引擎最终承接全部场景。

多账号流程

计划 A × 账号列表 展开后,按绑定顺序执行:准备账号 → 登录/切换 → 到达可核验界面 → 核对服务器和 UID → 执行计划 → 清空会话相关缓存 → 下一个账号。

新增 IPuloniaAccountSwitcher,由具体登录渠道实现;可以先支持手动登录确认,再覆盖已有登录态可自动选择的渠道。验证码、二次验证和失效登录态不能由引擎统一解决,遇到时阻塞该账号,按策略继续其他账号。

账号 UID、资源账本、状态快照、所选预设必须对应一致。不能在切号前把目标账号设置为“已登录”;登录失败时不能将上一账号的执行结果写到目标账号。

登录资料在需要持久化时使用 Windows 凭据存储/系统保护机制,计划只保存引用。BGI 多实例不是多账号状态模型;首版仍按交互会话串行切号,后续隔离桌面或真正独立输入环境再考虑并行。


Docs Design Pulonia Task Model (`Docs/design/pulonia/task-model.md`)

Pulonia:实体、任务树与配置

返回总览 · 用户故事 · 开发与验收计划

定义 Pulonia 命名、实体关系、JSON 树格式和配置优先级。运行时是否采用独立的只读执行节点是待模型阶段验收的实现选择。

实体与命名

采用 BetterGenshinImpact.Pulonia 命名空间,任务相关类型统一以 PuloniaTask 命名,区别旧的 ScriptGroup、OneDragonFlow、TaskRunner。

PuloniaTask 直接表示任务树中的一个节点,通过 TaskType 区分分组、脚本、路线、内置功能等;不再另设同义的 PuloniaTaskNode。PuloniaTaskPlan 保存计划元信息和 RootTask。PuloniaTaskDefinition 仅是代码注册或资源清单提供的类型说明,不再作为每次执行必须引用的独立实体。

运行记录对应区分为 PuloniaTaskPlanRun(一次计划运行)与 PuloniaTaskRun(树中某个任务的一次执行尝试)。账号、状态快照和培养目标使用 Pulonia 前缀,分别命名为 PuloniaAccountProfile、PuloniaPlayerSnapshot、PuloniaDevelopmentGoal。

用户界面依然使用“任务计划”“任务”“配置预设”等中文,不暴露内部术语。

第一阶段模型

概念 / 命名 关键字段 职责与边界
任务计划 PuloniaTaskPlan Id、SchemaVersion、Revision、Name、Description、RootTask、Triggers、Accounts 一个计划文件,包含任务树、触发配置和账号绑定;一条龙只是其简洁视图
树节点 PuloniaTask Id、Name、TaskType、Path、IsEnabled、Parameters、Children、PresetId?、Policy、Source? 沿用 d-v3 的持久化形状;运行前固定快照,可集中构建只读执行节点,不把执行状态写回编辑模型
类型说明 PuloniaTaskDefinition TaskType、ParameterSchema、Requirements、CompletionContract、AvailabilityRules 由内置注册或资源清单提供,不单独存储,不设管理服务;执行器注册字典按 TaskType 查找
配置预设 PuloniaTaskPreset Id、TaskType、ResourceId?、SchemaVersion、Values 可选;脚本专用预设同时绑定资源和参数版本,避免不同 JS 的同名参数被混用
触发器 PuloniaTaskTrigger Id、TargetTaskId?、Kind、Schedule/Hotkey、TimeZoneId、AdmissionPolicy 嵌入计划,定时或热键;全局触发页面只是各计划触发器的汇总
游戏账号 PuloniaAccountProfile Id、Uid、Server、LoginProfileRef、Preferences 游戏身份和账号偏好;不是 BGI 进程实例,不把密码直接写入计划
计划账号绑定 PuloniaTaskAccountBinding AccountId、Enabled、PresetSelections 嵌入计划,列表顺序就是账号顺序;不单独持久化
待执行请求 PuloniaTaskRequest Id、PlanId、AccountId?、Source、IdempotencyKey、NotBefore、ExpiresAt、Status、BatchId? 运行状态文件中的队列项;不设独立请求仓储
计划运行 PuloniaTaskPlanRun Id、RequestId、Snapshot、Status、StartedAt、EndedAt、ResumedFromPlanRunId? 记录一次实际执行,持有不可变的计划、资源版本、有效配置快照
节点尝试 PuloniaTaskRun Id、PlanRunId、TaskAddress、Attempt、Status、Outcome、Evidence、Checkpoint? 记录某个位置的某次尝试;失败、跳过、重试都能定位
活动账本项 PuloniaTaskLedgerEntry EventKey、ScopeKey、EffectKey、WindowKey、State、Units、OccurredAt、NextEligibleAt?、TaskRunId? 运行状态中的 CD/额度记录,保留未决操作与有效完成依据;不新增账本服务
运行状态 PuloniaTaskState SchemaVersion、Sequence、PendingRequests、ActiveRun、PendingArchives、Ledger、TriggerCursors 一个文件统一保存会相互影响的执行状态,替代数据库事务需求

真正独立保存的对象只有计划、预设、账号资料、运行状态、运行历史五类。其余是其中的嵌套记录或类型说明,不对应独立文件、仓储、服务或菜单。保留命名是为了表达业务含义,不是增加调用层级。

Policy、Requirements、Outcome、Evidence、Checkpoint 都是值对象,不再各自引入实体管理页面。

托管阶段增加两个实体

实体 主要内容
PuloniaPlayerSnapshot 账号状态快照:角色、武器、物品、树脂等。每个字段记录观测时间、来源、可信程度;未识别不等于 0
PuloniaDevelopmentGoal 角色或物品目标、目标口径(培养 / 库存达到 / 周期内新增)、目标等级/数量、优先级、资源预算和允许的替代策略

圣遗物逐件属性、配装优化等可后续扩展快照内容,不把这部分复杂度放进执行引擎。

ER 图

这是主要对象关系图;使用文件存储,没有数据库表。类型说明来自注册字典,省略在图外。

mermaid
erDiagram
    PuloniaTaskPlan ||--|{ PuloniaTask : contains
    PuloniaTask o|--o{ PuloniaTask : parents
    PuloniaTaskPreset o|--o{ PuloniaTask : selected_by
    PuloniaTaskPlan o|--o{ PuloniaTask : referenced_by
    PuloniaTaskPlan ||--o{ PuloniaTaskTrigger : triggered_by
    PuloniaTaskPlan ||--o{ PuloniaTaskAccountBinding : binds
    PuloniaAccountProfile ||--o{ PuloniaTaskAccountBinding : uses
    PuloniaTaskState ||--o{ PuloniaTaskRequest : queues
    PuloniaTaskState ||--o{ PuloniaTaskPlanRun : active_or_pending_archive
    PuloniaTaskState ||--o{ PuloniaTaskLedgerEntry : retains
    PuloniaAccountProfile o|--o{ PuloniaTaskRequest : targets
    PuloniaTaskRequest ||--o| PuloniaTaskPlanRun : starts
    PuloniaTaskPlanRun ||--o{ PuloniaTaskRun : records
    PuloniaTaskRun o|--o{ PuloniaTaskLedgerEntry : supplies_evidence
    PuloniaAccountProfile ||--o{ PuloniaPlayerSnapshot : observed_as
    PuloniaAccountProfile ||--o{ PuloniaDevelopmentGoal : pursues

约束补充:

  • 每个计划恰有一个根节点;非根节点恰有一个父节点。具体任务没有子任务;分组、重复、引用按类型约束子任务来源。
  • 根节点固定为 group。引用可形成计划依赖,但禁止自引用和间接循环,校验最大嵌套深度。
  • 每个计划的账号绑定不重复。一次请求只生成一次实际运行,依靠单写入者检查状态,不依赖数据库唯一约束。
  • 每个请求生成至多一次运行;续跑创建新请求、新运行,以 ResumedFromPlanRunId 关联原运行。
  • 节点尝试关联的是运行快照中的节点地址,不依赖当前可编辑树。因此删除节点、移动顺序、修改计划都不会改写历史。
  • TaskAddress 使用稳定节点 ID、引用调用路径、重复轮次组合,不使用显示名称或列表下标。
  • 账本可由人工校正或状态核验生成,因此 TaskRunId 可空;账号/世界作用域由 ScopeKey 表达,不能仅绑定某个计划。

任务树的最小语义

保留 d-v3 的“计划 → 根任务 → children”格式。TaskType 是唯一类型判别字段:

类型 行为
group 按 Children 数组顺序执行;承担分组与公共配置继承
javascript / pathing / keymouse / shell / csharp / builtin.* 按类型查找具体执行器,参数直接在节点中保存
plan 引用另一个计划;在本次运行中按展开快照执行
repeat 重复一棵子树;固定次数,或带最大次数/最长时长的结束条件

格式示意:

json
{
  "id": "plan-daily",
  "schema_version": 1,
  "revision": 1,
  "name": "每日计划",
  "root_task": {
    "id": "task-root",
    "name": "每日计划",
    "task_type": "group",
    "is_enabled": true,
    "parameters": {},
    "children": [
      {
        "id": "task-collect",
        "name": "采集材料 A",
        "task_type": "pathing",
        "path": "{pathingRepoFolder}/材料A/路线1.json",
        "is_enabled": true,
        "parameters": { "party_name": "采集队" },
        "children": []
      }
    ]
  },
  "triggers": [],
  "accounts": []
}

示例 ID 用可读字符串说明关系,实际创建时生成稳定 ID。与 d-v3 相比,必要调整只有:

  • parameters 存 JSON 对象,运行时按类型校验和转换;不再存转义后的 JSON 字符串,Shell 命令也放进明确的参数对象。
  • is_directory 不再与 task_type 同时决定行为,导入时转换为 group;空分组仍是分组。is_expanded 可保留为展示状态,执行器忽略它。
  • 新增节点 ID,数组位置只表示顺序。节点 priority 不参与重排,调度优先级只属于请求。
  • Source 是目录/计划引用的可选值对象:目录保存定位与版本,计划引用保存目标 PlanId。目录引用仍以分组展示,不额外增加一个用户必须学习的实体。引用组保存来源、展开组保存 Children,不能同时维护两份可编辑的子任务。
  • 每个 pathing 节点直接指向路线,每个 JS 节点直接指向脚本资源,不强制先建一份“能力定义实体”再建节点。

节点可附带启用状态、结构化条件、失败策略、超时。日历窗口、CD、树脂不足等返回可解释的跳过或等待原因,不能都记为失败。

内置条件只覆盖常见情况,例如星期、物品缺口、树脂阈值、剩余时长。复杂逻辑交给 JS/C# 能力;首版不设计图形化编程语言。条件读到未知状态时,可以先刷新,仍未知则跳过或待处理,不能当成 0。

text
每日计划
├─ 日常
│  ├─ 领取邮件
│  ├─ 派遣
│  └─ 每日奖励
├─ 培养素材
│  ├─ 刷秘境 [预设:角色 A 天赋]
│  └─ 采集材料 A
│     ├─ 路线 1
│     └─ 路线 2
├─ 引用:每周事项
├─ 剩余时间采集 [有总时长上限]
└─ Shell:生成本次报告

手工任务树严格按用户顺序执行。自动托管在生成树时按目标优先级排序;队列优先级只决定下一个请求,不打断正在消耗资源的动作。

重复不是重试:重复会产生新的业务尝试并重新检查额度;重试必须先判断上次副作用是否已经发生。失败策略首版只需要停止计划、跳过后续同组、继续后续节点,外加有限重试。完成后操作使用普通末尾节点;异常清理属于执行器 finally,不把“关机”与“释放输入”混在一起。

配置层级与复用

先按含义分开,避免什么都放进一个 AllConfig:

配置类别 归属
截图后端、窗口绑定、日志等设备配置 程序/运行环境;业务节点不能任意覆盖
UID、服务器、登录资料、账号培养目标 账号资料;属于上下文,不参与任意 JSON 合并
秘境、队伍、路线策略、JS 参数、Shell 参数 类型默认值、能力预设、树上覆盖
超时、有限重试、失败策略 可继承的执行策略;按字段解析
定时、热键、空闲门槛、触发过期时间 触发器
资源刷新规则、奖励上限、完成证据要求 能力元数据与账本;不是普通参数覆盖项

业务参数和执行策略使用统一解析规则,优先级从高到低:

text
本次调用覆盖 > 本节点覆盖 > 最近祖先覆盖 > 选用预设 > 类型默认值

根节点就是计划级配置;祖先覆盖从近到远查找。通用执行策略可按 TaskType 组织;脚本专用参数还需绑定具体资源及参数版本,不能仅凭都属于 JS 就合并配置,也不能把路线的队伍字段灌入 Shell。默认界面只展示“当前任务设置”和“继承的公共设置”;共享预设、嵌套覆盖和本次临时覆盖按需展开。

账号绑定可以为指定节点选择另一个预设,例如大号和小号使用不同战斗队伍;它替换“选用哪个预设”,不增加一层隐式合并。

每个字段显示最终有效值及来源,例如“战斗队伍:采集队,来自素材采集分组”。支持“恢复继承”;缺省、显式空值、0、false 分开保存,集合默认整体替换。配置在开始运行时解析成强类型、不可变快照。

独立任务、一条龙卡片和树节点详情复用同一参数编辑器。编辑动作明确区分“修改共享预设”和“只覆盖此节点”。计划引用默认沿用被引用计划的内部配置;引用节点仅允许覆盖声明的公共参数及执行策略,避免外部祖先悄悄改写整棵引用树。

UI 可为内置能力提供专用参数编辑器;JS 依据 Schema 生成表单。编辑器提供方放在 WPF 层,PuloniaTaskDefinition 不引用 UserControl。

切换计划、节点、账号或预设时,编辑器和有效值预览绑定同一个当前选择;异步加载只提交仍属于当前选择的结果。复制、导入、正常读档使用同一参数转换与校验规则,保留数组、布尔值和数值类型;不能出现复制后必须重启才正常的第二种参数表示。

持久化树与可执行树的取舍

持久化模型保持 PuloniaTaskPlan → RootTask → Children。执行前需要展开目录/计划引用、解析有效参数、校验能力并固定资源版本,因此允许在这一步生成只读执行树。它属于本次运行的内部表示,不增加另一份用户配置文件。

建议在第 1 步用一份实际样例评估两种实现:直接执行已解析的快照,或生成携带强类型参数和执行器绑定的只读节点。两者满足相同合同后,由用户验收确定;“禁止可执行树”不再是架构约束。

  • 构建逻辑集中在一个入口,需要独立类时可命名 PuloniaTaskBuilder;不再串联只做转发的 Converter、Factory 与多层 Runner。
  • 执行节点保留来源节点 ID 与引用路径,用于界面定位、结果和续跑;不能仅凭数组下标对应。
  • 可序列化的运行快照记录展开内容、有效参数和资源版本;执行器实例、服务引用和委托不直接保存到 JSON,恢复时按快照重新绑定。
  • 运行状态继续归 PuloniaTaskRun 等记录,不能因节点有 RunAsync 就把执行行为、可变状态和 WPF 命令全部塞进一个类。
  • 参数校验失败在执行前指向原节点报错;不能悄悄替换为被禁用的占位节点。

具体调用与生命周期见执行机制,快照落盘见结果与存储。


Docs Design Pulonia User Interface (`Docs/design/pulonia/user-interface.md`)

Pulonia:菜单、任务树与添加体验

返回总览 · 用户故事 · 开发与验收计划

沿用 d-v3 有价值的树表交互,用同一计划和参数编辑器服务高级编辑与简洁一条龙视图。具体 UI 是开发步骤 2、5、9 的独立验收对象。

新菜单与添加体验

页面沿用 d-v3 的列表与树表组织,在“自动化”下提供以下入口:

页面 用户看到的内容
托管 账号、培养目标、今日计划卡片、下次执行、缺失条件、一键开始
任务计划 左侧计划列表;右侧“任务、执行记录、触发方式”页签;任务树为主要工作区域,选中项参数按需显示
任务库 内置功能、材料采集、刷怪路线、JS、宏、Shell、C#;按用途和目标筛选
运行记录 各计划运行的汇总,复用计划内执行记录组件,不再做一套状态模型

保留 d-v3 中“最近运行结果、可续跑位置”的摘要,当前运行显示固定状态条。成功率、平均耗时放进记录详情,不占任务树默认空间;运行监控和历史使用同一个详情组件。全局触发管理只汇总计划内触发器,定时与热键分开展示。

任务树表列沿用“名称、类型、启用、最近结果、操作”,按需增加“下次可运行/剩余 CD”。右键支持添加、复制、删除、从此开始;拖拽以明确插入线/分组高亮反馈位置,禁止环路。选中分组时向组内添加,选中叶子时默认插在其后,不强迫用户先返回父目录。

对应 US-12,支持跨计划复制/粘贴子树和编辑撤销。复制默认保留参数值与显式预设引用,为新节点生成 ID,重映射子树内部引用;不会同时复制原触发器或历史进度。父组继承值可能在目标位置变化,粘贴前显示差异并提供“保留当前有效设置”选项,避免无提示改用另一支队伍。删除、拖动、批量添加作为一次可撤销的编辑;用当前编辑会话内有上限的变更前后快照实现即可,不引入通用事件溯源。撤销只作用于配置,不撤销已经发生的游戏操作。

分组禁用时整棵子树不执行,但不覆盖子任务原本的勾选;重新启用后保留原来的选择。“启用全部子任务”作为独立命令。这吸收 d-v3 父子联动的便利,同时避免一次关闭/开启分组抹掉用户对部分节点的禁用配置。

参数编辑采用同一组件,宽窗口显示侧栏,窄窗口用抽屉/对话框;避免常驻三栏挤压树表。账号管理由托管页和计划绑定页进入。路线录制、路线编辑、JS 开发作为任务库的工具入口,原页面的有效功能迁入。

一条龙卡片使用树节点的同一参数编辑器,直接显示“队伍、策略、次数、来源预设”等有效配置,不再要求用户去另一个独立任务页面寻找被暗中复用的设置。高级树无法简化的节点显示为可展开步骤,不能在切换简洁视图时丢失节点。

添加任务与整个目录

沿用 d-v3 的“左侧搜索/目录,右侧说明预览”。JS 展示名称、简介、作者和版本,默认预览 README,源码放高级页签;路线目录展示说明和任务数量,文件展示路线摘要,JSON 放高级页签。来源切换、版本和下载在这个窗口内完成,不要求跳转脚本订阅页面。

目录添加保留两种模式,默认引用目录:

模式 保存与编辑方式
引用目录 保存 Source 中的目录定位及所选资源版本;树上显示一个可展开的引用组,子项按需预览。运行前生成固定清单;用户要逐项改动时“展开为可编辑任务”
展开为任务 将当前选中文件批量写成普通任务节点;默认保留目录结构,可选择扁平添加

引用目录不是“永远运行最新内容”:默认固定版本,更新后显示数量/清单差异,由用户选择更新。开始运行时再冻结实际文件清单与指纹,续跑沿用它;内容版本缓存缺失时明确提示不可精确恢复。引用展开的任务 ID 按来源项稳定映射,并保存在运行快照中;不要每次查看目录都生成随机 ID。目录引用与展开模式均能添加整目录,不需要逐个点选。

  • 后台维护轻量目录索引,只读清单、文件属性及可用元数据;不在打开选择窗口时解析全部路线内容或初始化全部脚本引擎。
  • 搜索走缓存索引,输入防抖并取消过时查询;只有可见项创建 UI 容器,文件详情按需读取。
  • 引用目录仅在用户展开预览或运行准备时解析;后台索引支持按目录统计和检索。展开模式在确认后批量生成节点,不能在每次勾选时先构建全目录控件。
  • 展开为普通任务后不随仓库静默变化;引用目录的版本更新与计划更新均留痕。不要在转换任务时全量复制资源仓库,选定资源按版本缓存、按需读取。
  • 批量添加按一次模型变更、一次保存处理,不逐节点触发序列化与磁盘写入。

优先验证 d-v3 已用的 ui:TreeListView 在当前控件版本中的支持情况和性能;保留树表交互,不保证原样复制控件就能处理大目录。必要时用原生虚拟化控件实现同样的树表表现,不新增第三方树组件。WPF 的虚拟化、容器回收以及外层布局需要一起检查。微软文档

新页面继续使用 ObservableObject/项目 ViewModel、[ObservableProperty]、[RelayCommand]、Behaviors 和现有拖拽框架。简单提示使用 ThemedMessageBox,领域实体不承担 UI 命令。


--- METRICS ---