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

该话题被推 开发技术
268 编辑于
LunaRain_079
话题封面

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

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

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

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

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

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

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

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

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

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

image.png

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

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

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

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

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

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

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

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

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

  • 有一个前台窗口;

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

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

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

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

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

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

α=1−e−Δt/τ,s←s+α (x−s)\alpha = 1 - e^{-\Delta t / \tau}, \qquad s \leftarrow s + \alpha\,(x - s)

其中 Δt\Delta t 是距上次采样的实际时间,时间常数 τ=2 s\tau = 2\,\text{s}。

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

1. 有界,不会溢出

把更新式展开:

sn=(1−α) sn−1+α xns_n = (1 - \alpha)\, s_{n-1} + \alpha\, x_n

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

2. 与采样率无关

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

(1−αΔt)2=e−2Δt/τ=1−α2Δt\left(1 - \alpha_{\Delta t}\right)^2 = e^{-2\Delta t / \tau} = 1 - \alpha_{2\Delta t}

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

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

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

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

1−e−t/2≥0.85  ⟹  t≥−2ln⁡0.15≈3.8 s1 - e^{-t/2} \ge 0.85 \;\Longrightarrow\; t \ge -2 \ln 0.15 \approx 3.8\,\text{s}

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

image.png

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

通道权重含义
可见顶层窗口0.35进程拥有可见、无 owner 的顶层窗口
前台窗口0.40当前前台窗口属于该进程
启动关系0.25 × 关系强度该进程与本次启动在结构上的关联

写成公式就是:

x=0.35⋅1window+0.40⋅1foreground+0.25⋅r,r∈[0,1]x = 0.35 \cdot \mathbb{1}_{\text{window}} + 0.40 \cdot \mathbb{1}_{\text{foreground}} + 0.25 \cdot r, \qquad r \in [0, 1]

什么时候可以下结论

1. 提前确认

需要同时满足:

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

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

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

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

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

θ(t)={0.85,t<15 s0.85−0.30⋅t−15 sT−15 s,15 s≤t<T0.55,t≥T\theta(t) = \begin{cases} 0.85, & t < 15\,\text{s} \\[4pt] 0.85 - 0.30 \cdot \dfrac{t - 15\,\text{s}}{T - 15\,\text{s}}, & 15\,\text{s} \le t < T \\[8pt] 0.55, & t \ge T \end{cases}

image.png

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

2. 独苗规则

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

3. 超时兜底

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

image.png

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

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

第一层:继任检测

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

第二层:后台验证

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

  • 不是 launcher 本身;

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

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

  • 上述状态持续 5 秒;

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

image.png

「首次出现时间不早于当前目标」这条规则很关键:它让验证只沿着启动链向前移动(launcher/设置窗 → 游戏),而不会因为用户切回去点了一下旧的启动器窗口,就把监控换回旧进程。

当前目标已经退出时,验证器不做任何事,交给继任检测处理,两套机制职责不重叠。

工程实现

一次快照,复用到底

旧代码在一个 tick 里会分别调用「枚举子孙进程」「枚举目录下进程」「按名字查进程」,每次都重新做一次 Toolhelp 进程快照;「目录下进程」还要对系统中每一个进程调用 OpenProcess + QueryFullProcessImageName 读取镜像路径。采样提到 500ms 后,这个开销不能再忽视。

新实现每个 tick 只做:

  • 一次进程快照,在内存中建好 PID 索引和父子索引,所有查询都基于它;

  • 一次 EnumWindows 枚举可见窗口;

  • 一次前台窗口查询。

镜像路径加了跨 tick 的缓存:进程的镜像路径在其生命周期内不会变,缓存以 (PID, 进程名, 父 PID) 为键,防止 PID 复用读到旧值,并在每次快照后修剪掉已退出的进程。

换绑与监控的衔接

验证器通过一个容量为 1 的 channel 发布换绑请求;新请求会覆盖尚未消费的旧请求,消费方永远拿到最新结论。会话监控在等待进程退出的同时监听这个 channel:

go
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

cbshuimu

cbshuimu 推了这个话题,干货满满

6 小时前
永雏小夜

永雏小夜 推了这个话题,吾辈楷模,此贴当浮一大白!

15 小时前
ace393282

ace393282 推了这个话题,牛逼就完事了

20 小时前

本文版权遵循 CC BY-NC 协议 和 本站版权政策

4 条回复

ace393282
发布于

牛逼大佬,昨天下午偶尔看见就下载来使用了,蛮好用的,已严肃加☆。

我在导入元数据时,本来还好奇怎么没显示kungal的元数据导入呢。然后刚刚再上线发现又有了,神奇,也可能是我不会操作或看漏了吧。

还有我想要大佬你这张背景图有吗,在网上找都找不着。鲲 Galgame 表情包 [3] - 1

永雏小夜
发布于

严肃支持露娜老大鲲 Galgame 表情包 [1] - 14

LunaRain_079
发布于

@ace393282 #1 正式版才加上,应用内更新了应该就有了
7197193FA1BEC7B3DD494CECB6B2B118.jpg原图在这里~鲲 Galgame 表情包 [6] - 1

ace393282
发布于 (编辑于 )

(。>︿<。) 已经一滴回复都不剩了哦~