本文由我和ai共同完成,您可以在 我的博客 中获取最佳阅读体验,如果您觉得我的思路对您的项目有帮助,请给lunabox一个star,也欢迎与我讨论更多细节
Github链接 : https://github.com/Saramanda9988/LunaBox
LunaBox 是一个本地 galgame 游戏库与游玩时长统计工具。和很多工具一样,你只需要点一个「启动」,管理器就能帮你启动游戏并记录时长
这时候有一个看似简单的问题出现了:到底哪个进程才是真正的游戏进程?
这篇文章记录了我们如何给出一个「几秒内给出,并且之后还能自我纠错」的答案。
背景:为什么「找到游戏进程」这么难
LunaBox 启动游戏后,必须要尽可能锁定真正的游戏进程,后续的很多功能都依赖于一个稳定的 pid, 比如活跃时长(只在游戏窗口处于前台时计时), 后台静音(游戏失去焦点时把它静音)等
大部分时候,双击一个没有汉化、基本原生的游戏,那打开的进程一般就是游戏,没什么可检测的。但现实中各种汉化的游戏(尤其是 galgame)远没有这么规矩。汉化组们为了汉化,往往会有各种手段,导致你启动的可能不是你玩的游戏。我们在监控中遇到过这些情况:

之前的做法:线性的规则轮询
我们旧的检测逻辑是一条按顺序执行的规则链:启动后先等 5 秒,之后每 2 秒做一轮扫描,每一轮依次检查四条规则,直到用户配置的超时:

这套机制经过我们的微调,可以使用,但有几个问题:
结论只有「完美命中」和「超时」两种:没有「证据已经足够,可以提前下结论」的中间态。证据不够完美时,只能一直等到超时。
每一轮都是独立的快照,没有记忆:一个进程已经在前台稳定待了 30 秒,和一个刚出现、下一秒就会消失的启动窗,在规则眼里没有区别。于是一方面容易在某一轮恰好抓到中间进程,另一方面又不敢对「看起来很对」的进程提前下结论。
规则之间靠顺序和互斥条件维系:例如为了不把启动器误认成游戏,规则统一排除了我们启动的那个进程本身。结果当「启动的 exe 就是游戏」时,这个早就看得到的正确答案反而被丢掉,只能等到超时再退回来。每多支持一种游戏行为,就要再加一条分支和例外。
采样粗、开销大:2 秒一轮的粒度偏粗;而每一轮里「枚举子孙」「枚举目录」「按名字查找」各自重新拍一次进程快照,开销随规则数量线性增长。
Brainstorm:一个游戏窗口有什么特征?
重构前我们先做了一轮 brainstorm,原始想法大致如下:
在 x 分钟的启动时长中,一个游戏窗口有哪些特点?
被根进程所启动,在进程树中存在;
有一个前台窗口;
在 20s 中都存在 / 在 40s 中都存在 / 在 60s 中都存在。
或许使用一种置信度算法:每一个 tick 中给每一个 candidate 增加权重,有前台窗口会增加较大的权重。20s 时达到某个权重(超过 80% 的理论权重)认为是游戏进程,40s 超过 50% 理论权重,60s 时取 candidate 中的最高权重。
问题是:等待时长是用户可调整的,判断窗口过长的时候应该如何划分这些层次?并防止整数的权重溢出?采样窗口要足够小才行(500ms?),不然可能出现中间进程的捕获?
核心思路:用 EMA 表示置信度
我们把每个候选进程的置信度定义为「即时证据」的时间加权指数移动平均(EMA)。
每个采样 tick,对每个候选计算一个即时证据 ,然后更新它的分数 :
其中 是距上次采样的实际时间,时间常数 。
这个小公式解决了 brainstorm 里的几个问题:
1. 有界,不会溢出
把更新式展开:
是上一次分数与本次证据的凸组合,只要 , 就永远在 内。它本身就是「占理论上限的比例」,阈值可以直接写成 这样的常数。
2. 与采样率无关
由真实流逝的时间 决定。对恒定输入,两次间隔 的更新与一次间隔 的更新完全等价:
所以即使调整采样间隔,分数曲线也不变,阈值也不需要重新调。采样卡顿(系统繁忙导致某次 tick 延迟)也会被正确地按时间补偿。
3. 偏重近期证据,自动遗忘
类似启动屏这种场景,屏幕消失后它理论上就应该从候选中逐渐消失;窗口失去前台后,分数按 衰减。新出现的真实游戏只需要「自身证据足够强」,不需要我们再去写规则追平历史。
对于持续给出满证据()的候选,分数从 0 开始的变化是 ,爬到 需要:
这正好构成一个天然的「稳定性要求」:一个进程至少要连续表现得像游戏 4 秒左右,才可能被提前确认;没有窗口的短命启动器进程或者2-3秒秒启动屏一般不会达标。

即时证据由三个部分加权组成:
| 通道 | 权重 | 含义 |
|---|---|---|
| 可见顶层窗口 | 0.35 | 进程拥有可见、无 owner 的顶层窗口 |
| 前台窗口 | 0.40 | 当前前台窗口属于该进程 |
| 启动关系 | 0.25 × 关系强度 | 该进程与本次启动在结构上的关联 |
写成公式就是:
什么时候可以下结论
1. 提前确认
需要同时满足:
检测已运行至少 3 秒(给快速退出的启动器留出时间);
领先候选自首次出现至少 3 秒;
领先候选分数 当前阈值 ;
领先第二名至少 (避免两个旗鼓相当的窗口时随便挑一个)。
对于不同的时间阶段,使用不同的阈值作为判断:

普通游戏都在 3–15 秒的严格阈值区间内被确认,所以调大 不影响普通游戏的确认速度。
2. 独苗规则
如果只剩一个非 launcher 的候选、它已稳定存在 10 秒,并且有一个可持久化的进程名,即使没有窗口也直接确认。这覆盖了「launcher 已退出,游戏还在加载、还没出窗口」,以及「平台本身拿不到窗口信息」的情况。
3. 超时兜底
到达 仍未确认时,按下面的顺序兜底,其中「监控 launcher」与旧行为一致:

为什么我们敢进行提前确认?
「提前」就意味着可能犯错。我们之所以敢在 4 秒左右就下结论,是因为系统里有两层纠错机制。
第一层:继任检测
被监控的进程退出时,LunaBox 不会立刻结束会话,而是在 0/1/2/3 秒的宽限期内寻找「接班」的进程:它的子进程、游戏目录内的新进程,或者同名进程(re-exec)。所以「先确认了启动窗,启动窗退出后拉起真游戏」这种情况,早就能被正确接力。
第二层:后台验证
继任检测解决不了一种情况:被确认的进程不退出。典型例子是设置窗/启动器常驻,而真正的游戏是另起的进程。为此,提前确认之后,检测并不停止,而是由
ProcessVerifier以 1 秒间隔继续采样评分,直到超时。如果出现一个挑战者同时满足:
不是 launcher 本身;
首次出现时间不早于当前目标;
分数 ,且领先当前目标至少 ;
上述状态持续 5 秒;
就把监控换绑到它身上(每个会话最多换绑 3 次)。
「首次出现时间不早于当前目标」这条规则很关键:它让验证只沿着启动链向前移动(launcher/设置窗 → 游戏),而不会因为用户切回去点了一下旧的启动器窗口,就把监控换回旧进程。
当前目标已经退出时,验证器不做任何事,交给继任检测处理,两套机制职责不重叠。
工程实现
一次快照,复用到底
旧代码在一个 tick 里会分别调用「枚举子孙进程」「枚举目录下进程」「按名字查进程」,每次都重新做一次 Toolhelp 进程快照;「目录下进程」还要对系统中每一个进程调用 OpenProcess + QueryFullProcessImageName 读取镜像路径。采样提到 500ms 后,这个开销不能再忽视。
新实现每个 tick 只做:
一次进程快照,在内存中建好 PID 索引和父子索引,所有查询都基于它;
一次
EnumWindows枚举可见窗口;一次前台窗口查询。
镜像路径加了跨 tick 的缓存:进程的镜像路径在其生命周期内不会变,缓存以 (PID, 进程名, 父 PID) 为键,防止 PID 复用读到旧值,并在每次快照后修剪掉已退出的进程。
换绑与监控的衔接
验证器通过一个容量为 1 的 channel 发布换绑请求;新请求会覆盖尚未消费的旧请求,消费方永远拿到最新结论。会话监控在等待进程退出的同时监听这个 channel:
for {
select {
case <-exitChan:
// 进程退出:先做继任检测
case next := <-handoff.verifiedRetargets():
if s.switchToVerifiedProcess(session, processID, processName, next, handoff) {
return // 新进程已由新的 goroutine 接管,旧监控器随之释放
}
continue
case <-session.done:
return
case <-maxRuntime.C:
// 24 小时兜底
}
break
}换绑时复用了与继任接力相同的逻辑:重定向活跃时长追踪、恢复旧进程的音频状态、持久化新的进程名,下次启动可以直接命中保存的进程名快捷路径。
效果与局限
在 Windows 上,最常见的「直接启动的 exe 就是游戏」通常在 4–5 秒内就能确认;确认得越早,活跃时长追踪和后台静音也就越早生效。带设置窗或常驻启动器的游戏,在游戏接管前台后会自动换绑到真正的游戏进程,而不是一直把启动器当成游戏。
我们测试了几个常见的游戏:
- 恋花绽放樱飞时 (Bravo 汉化组,翻译进程壳)
- 越界恋人!! (julixian 翻译,启动窗)
- 霞流宝石心 壮志凌云振寰宇 (破琵琶翻译,实际进程为随机 .tmp 文件)
- 魔女恋爱日记 (爱丽丝后宫协会翻译,启动器进程快速退出)
均能在 15 秒内准确确定实际进程。
最大的局限仍然是权重是手工设定的。这些权重来自对典型行为的分析,并不是从数据中学来的。另外,我们用于测试的游戏数据集很少,测试使用的电脑配置也不多,需要更大的样本量来提供更精确的经验值。如果您对此感兴趣,欢迎使用LunaBox,或者给您的启动器使用类似的实现,并与我们分享监控的准确性🥰
也欢迎关注我们的其他工作:


https://github.com/Umbrae-Labs/lunar

https://github.com/Saramanda9988/LunaBox






![鲲 Galgame 表情包 [3] - 1](https://image.kungal.iloveren.link/ca/46/ca46fc9dfaa4c1cfa5da6c912e45bdfc51a14ee4308048c37d90a18c290f0bd4_320.webp)

![鲲 Galgame 表情包 [1] - 14](https://image.kungal.iloveren.link/4d/9b/4d9b0251608693431b1bb499994729ad6dd111ebb6545820d1ca2ee89319e23e_320.webp)
原图在这里~![鲲 Galgame 表情包 [6] - 1](https://image.kungal.iloveren.link/74/ce/74ce5e3c1f8a8973e92463f90b8a6053d764d48c10ac618996df2214c4531d0d_320.webp)
![鲲 Galgame 表情包 [3] - 2](https://image.kungal.iloveren.link/6a/84/6a8425110424ab79bfa5a379b3515c8535821afb407fc4151e8c6f81ca09f717_320.webp)
4 条回复