LunaBox——冷知识,如何较为正确地判断一个 galgame 真正的进程

LunaRain_079
LunaRain_079
·38

本文由我和ai共同完成,您可以在 我的博客 中获取最佳阅读体验,如果您觉得我的思路对您的项目有帮助,请给lunabox一个star,也欢迎与我讨论更多细节

Github链接 : https://github.com/Saramanda9988/LunaBox

LunaBox 是一个本地 galgame 游戏库与游玩时长统计工具。和很多类似的管理工具一样,你只需要点一个「启动」,管理器就能帮你启动游戏并记录时长

这时候有一个看似简单的问题出现了:到底哪个进程才是真正的游戏进程?

这篇文章记录了我们如何给出一个「几秒内给出,并且之后还能自我纠错」的答案。

背景:为什么「找到游戏进程」这么难

LunaBox 启动游戏后,必须要尽可能锁定真正的游戏进程,后续的很多功能都依赖于一个稳定的 pid, 比如活跃时长(只在游戏窗口处于前台时计时), 后台静音(游戏失去焦点时把它静音)等

大部分时候,双击一个没有汉化、基本原生的游戏,那打开的进程一般就是游戏,没什么可检测的。但现实中各种汉化的游戏(尤其是 galgame)远没有这么规矩。汉化组们为了汉化,往往会有各种手段,导致你启动的可能不是你玩的游戏。我们在监控中遇到过这些情况:

汉化 galgame 启动时常见的几种「不规矩」行为

之前的做法:线性的规则轮询

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

旧方案:按顺序执行的规则链,任何一条命中就立即确认

这套机制经过我们的微调,可以使用,但有几个问题:

  • 结论只有「完美命中」和「超时」两种:没有「证据已经足够,可以提前下结论」的中间态。证据不够完美时,只能一直等到超时。

  • 每一轮都是独立的快照,没有记忆:一个进程已经在前台稳定待了 30 秒,和一个刚出现、下一秒就会消失的启动窗,在规则眼里没有区别。于是一方面容易在某一轮恰好抓到中间进程,另一方面又不敢对「看起来很对」的进程提前下结论。

  • 规则之间靠顺序和互斥条件维系:例如为了不把启动器误认成游戏,规则统一排除了我们启动的那个进程本身。结果当「启动的 exe 就是游戏」时,这个早就看得到的正确答案反而被丢掉,只能等到超时再退回来。每多支持一种游戏行为,就要再加一条分支和例外。

  • 采样粗、开销大:2 秒一轮的粒度偏粗;而每一轮里「枚举子孙」「枚举目录」「按名字查找」各自重新拍一次进程快照,开销随规则数量线性增长。

Brainstorm:一个游戏窗口有什么特征?

重构前我们先做了一轮 brainstorm,原始想法大致如下:

在 x 分钟的启动时长中,一个游戏窗口有哪些特点?

  • 被根进程所启动,在进程树中存在;

  • 有一个前台窗口;

  • 在 20s 中都存在 / 在 40s 中都存在 / 在 60s 中都存在。

或许使用一种置信度算法:每一个 tick 中给每一个 candidate 增加权重,有前台窗口会增加较大的权重。20s 时达到某个权重(超过 80% 的理论权重)认为是游戏进程,40s 超过 50% 理论权重,60s 时取 candidate 中的最高权重。

问题是:等待时长是用户可调整的,判断窗口过长的时候应该如何划分这些层次?并防止整数的权重溢出?采样窗口要足够小才行(500ms?),不然可能出现中间进程的捕获?

核心思路:用 EMA 表示置信度

我们把每个候选进程的置信度定义为「即时证据」的时间加权指数移动平均(EMA)。

每个采样 tick,对每个候选计算一个即时证据 x∈[0,1],然后更新它的分数 s:

α=1−e−Δt/τ,s←s+α (x−s)

其中 Δt 是距上次采样的实际时间,时间常数 τ=2 s。

这个小公式解决了 brainstorm 里的几个问题:

1. 有界,不会溢出

把更新式展开:

sn=(1−α) sn−1+α xn

​sn 是上一次分数与本次证据的凸组合,只要 x∈[0,1],s 就永远在 [0,1] 内。它本身就是「占理论上限的比例」,阈值可以直接写成 0.85 这样的常数。

2. 与采样率无关

​α 由真实流逝的时间 Δt 决定。对恒定输入,两次间隔 Δt 的更新与一次间隔 2Δt 的更新完全等价:

(1−αΔt)2=e−2Δt/τ=1−α2Δt

所以即使调整采样间隔,分数曲线也不变,阈值也不需要重新调。采样卡顿(系统繁忙导致某次 tick 延迟)也会被正确地按时间补偿。

3. 偏重近期证据,自动遗忘

类似启动屏这种场景,屏幕消失后它理论上就应该从候选中逐渐消失;窗口失去前台后,分数按 τ 衰减。新出现的真实游戏只需要「自身证据足够强」,不需要我们再去写规则追平历史。

对于持续给出满证据(x=1)的候选,分数从 0 开始的变化是 s(t)=1−e−t/τ,爬到 0.85 需要:

1−e−t/2≥0.85  ⟹  t≥−2ln⁡0.15≈3.8 s

这正好构成一个天然的「稳定性要求」:一个进程至少要连续表现得像游戏 4 秒左右,才可能被提前确认;没有窗口的短命启动器进程或者2-3秒秒启动屏一般不会达标。

τ = 2 s 时三类候选的分数曲线:只有持续呈现「像游戏」的进程能越过 0.85

即时证据由三个部分加权组成:

通道

权重

含义

可见顶层窗口

0.35

进程拥有可见、无 owner 的顶层窗口

前台窗口

0.40

当前前台窗口属于该进程

启动关系

0.25 × 关系强度

该进程与本次启动在结构上的关联

写成公式就是:

x=0.35⋅1window+0.40⋅1foreground+0.25⋅r,r∈[0,1]

什么时候可以下结论

1. 提前确认

需要同时满足:

  • 检测已运行至少 3 秒(给快速退出的启动器留出时间);

  • 领先候选自首次出现至少 3 秒;

  • 领先候选分数 ≥ 当前阈值 θ(t);

  • 领先第二名至少 0.25(避免两个旗鼓相当的窗口时随便挑一个)。

对于不同的时间阶段,使用不同的阈值作为判断:

θ(t)={0.85,t<15 s0.85−0.30⋅t−15 sT−15 s,15 s≤t<T0.55,t≥T

阈值 θ(t):3–15 秒内固定为 0.85,之后线性放宽;超时 T 变长只会让尾部更平缓

普通游戏都在 3–15 秒的严格阈值区间内被确认,所以调大 T 不影响普通游戏的确认速度。

2. 独苗规则

如果只剩一个非 launcher 的候选、它已稳定存在 10 秒,并且有一个可持久化的进程名,即使没有窗口也直接确认。这覆盖了「launcher 已退出,游戏还在加载、还没出窗口」,以及「平台本身拿不到窗口信息」的情况。

3. 超时兜底

到达 T 仍未确认时,按下面的顺序兜底,其中「监控 launcher」与旧行为一致:

超时兜底:从上到下依次尝试,越往下越保守

为什么我们敢进行提前确认?

「提前」就意味着可能犯错。我们之所以敢在 4 秒左右就下结论,是因为系统里有两层纠错机制。

第一层:继任检测

被监控的进程退出时,LunaBox 不会立刻结束会话,而是在 0/1/2/3 秒的宽限期内寻找「接班」的进程:它的子进程、游戏目录内的新进程,或者同名进程(re-exec)。所以「先确认了启动窗,启动窗退出后拉起真游戏」这种情况,早就能被正确接力。

第二层:后台验证

继任检测解决不了一种情况:被确认的进程不退出。典型例子是设置窗/启动器常驻,而真正的游戏是另起的进程。为此,提前确认之后,检测并不停止,而是由 ​ProcessVerifier​ 以 1 秒间隔继续采样评分,直到超时。如果出现一个挑战者同时满足:

  • 不是 launcher 本身;

  • 首次出现时间不早于当前目标;

  • 分数 ≥0.6,且领先当前目标至少 0.3;

  • 上述状态持续 5 秒;

就把监控换绑到它身上(每个会话最多换绑 3 次)。

后台验证:设置窗被提前确认后,真正的游戏以足够的领先优势保持 5 秒,监控随之换绑

「首次出现时间不早于当前目标」这条规则很关键:它让验证只沿着启动链向前移动(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 秒内就能确认;确认得越早,活跃时长追踪和后台静音也就越早生效。带设置窗或常驻启动器的游戏,在游戏接管前台后会自动换绑到真正的游戏进程,而不是一直把启动器当成游戏。

我们测试了几个常见的游戏:

  1. 恋花绽放樱飞时(Bravo 汉化组,翻译进程壳)

  2. 次元凸破恋战姬!!(julixian 翻译,启动窗)

  3. 霞流宝石心 -壮志凌云振寰宇-(破琵琶翻译,实际进程为随机 .tmp 文件)

  4. 魔女恋爱日记(爱丽丝后宫协会翻译,启动器进程快速退出)

均能在 15 秒内准确确定实际进程。

最大的局限仍然是权重是手工设定的。这些权重来自对典型行为的分析,并不是从数据中学来的。另外,我们用于测试的游戏数据集很少,测试使用的电脑配置也不多,需要更大的样本量来提供更精确的经验值。

如果您对此感兴趣,欢迎使用LunaBox,或者给您的启动器使用类似的实现,并与我们分享监控的准确性🥰

也欢迎关注我们的其他工作:

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

https://github.com/Saramanda9988/LunaBox

https://github.com/Umbrae-Labs/rito-rn

https://github.com/Umbrae-Labs/umbra-sdk

评论 1

Ringyuki
Ringyuki· 21小时前

没有更多了