← 返回全部文章

可被验证的分数

发布于:
agent-runcandyrushdeterminismsimverifyevidence

上一篇写的是 agent bridge,结尾那句话如今已经变成了一张待办清单:游戏本来就是 确定性的,bridge 只是不再把这份确定性丢掉。

十三天后,这句话得加个星号。游戏的确定性,足够支撑「把它玩下去」,却不足以支撑 证明一个分数。8 月 2 日到 8 月 15 日之间,整个套件几乎把全部产出都花在了填这道 缝上:找出模拟里那些悄悄读错时钟的地方;造一个能在不信任提交者的前提下验证一次 提交的服务;上线一块只由该服务喂数据的每周排行榜。

这是四个故事,所以本文是四篇短文,而不是一篇长文。

合并的 PR
109
四个仓库,13 天
提交数
346
自上一篇文章起
SimVerify
0 → 21k
行 C#,从空仓库起步
分歧的 tick
3651 → 0
在一次 5176 tick 的采集中
被错误应用的能力
7 → 0
每次采集的升级次数
SimVerify 测试
640
0 失败,14 个依赖浏览器

目录

  1. 追猎分歧 —— 每一次回放分叉,都是同一个 bug 换了身衣服。
  2. 一块你无法欺骗的排行榜 —— 验证器为什么拒绝 自己去模拟。
  3. 一个分数,四个仓库 —— 跟着一次运行,从浏览器 一路走到公开页面。
  4. 十三天 —— 凭据,以及那个故意倒退的数字。

1. 追猎分歧

确定性模拟给出一个承诺:同一个种子、同一串输入、同一场运行。CandyRush 把一次运行 记录成种子加输入流;所谓验证,就是重新模拟这份记录,并把滚动的状态摘要(digest) 与原始运行盖下的那一份逐项比对。承诺若成立,两条流应当完全一致。

它们并不一致。一次采集跑了 5,176 个 tick,其中 3,651 个 tick 出现差异——不是 和另一台机器比,而是和它自己比。

追了一周之后,最值得说的一点是:这些没有一个是随机数的 bug。每一个都是时钟的 bug——某一处状态在一个按 tick 推进的模拟里,却按帧的节奏发生了变化。于是这次变化 落在哪个 tick 上,取决于机器当时跑得多快;同一份记录的两次重放就此分叉,全程与 RNG 无关。

它穿过的几身衣服:

  • 一具不肯离场的尸体。 敌人是在溶解淡出结束时退出模拟注册表的,而那段淡出走的 是墙钟时间。实测:编号 id=101 的敌人在一次运行中于 tick 1331 退出注册表,在 另一次运行中从未退出。现在死亡在死亡那一 tick 就注销,淡出纯属表现层。仅这 一对改动,就把该次采集从 3,651 个差异 tick 降到 2,并在之后一次采集中降到 0。
  • 一台慢了一帧的摄像机。 敌人的生成位置是相对摄像机 transform 计算的,而那个 transform 由 LateUpdate 每帧写一次。在每帧推进上百 tick 的回放泵之下,transform 就会落后玩家若干个 tick——具体多少取决于平台这一帧批了多少。证据异常干净:在 tick 724,25 个敌人逐位相同,最新生成的 10 个敌人整体被同一个平移推开,位移 (+2.25, +2.69)、模长 3.498 世界单位,而每个作用域的 RNG 抽取计数完全一致。抽取 相同、刚体偏移唯一,说明生成的参照点不同。这些被推开的敌人随后在不同时刻越过 了屏外传送阈值,于是在 tick 773 多花了一次抽取,把 digest 的分叉推迟到真正分歧 之后 50 个 tick 才显形。
  • 一次三行长的浮点往返。 玩家朝向本是用定点数精确算出的,却被转成 float32 再转回来,然后与一个定点阈值比较。那个判断里其他每一项都是精确的。比较一旦翻转, 就有一个敌人传送或不传送——这恰好等于多一次或少一次 RNG 抽取,而玩家位置不变、 敌人数量也不变。digest 分叉了,而 digest 报告的每一个分量却依然吻合。 这正是 它多日无法归因的原因,也是此前两次归因都归错的原因。

最后这一条,其实讲的是仪器而不是 bug。digest 当时只记录敌人的数量而非状态, 也就是说:任何回放检查、任何排行榜检查,从来没有验证过敌人的位置。在同一个窗口 里,digest 先后加进了各作用域的累计 RNG 抽取计数,又加进了对位置、血量与类型的 逐实体哈希;采样节奏也从 60 tick 一窗改为逐 tick,于是一份分歧报告能点名具体 tick,而不是给出一个邻域。逐实体哈希落地时,三个已提交的测试夹具中有两个纹丝 未动——它们里面根本没有实体可折进哈希,这本身就是对那些夹具覆盖之薄的一次直白 度量。

这一组里最糟的一个,甚至根本不是分歧。升级选项是从运行中的 RNG 流里抽的,而记录 存下的是玩家选中的卡片序号。于是任何上游分歧都会改变发到手上的那三张卡,回放 随即应用了另一个能力、打出了另一套 build:某次采集的七次升级,全部落在玩家从未 选过的能力上。 现在选项按稳定的序数而非流位置生成,记录会盖上所选能力的 id, 回放时逐项比对。七处不匹配归零,而彼时这次运行在别处仍在分歧——这正是理想的 形状:一件仪器,一种失效模式,互不混淆。

而我最喜欢的那个,今天什么都不影响。每块地形分片的背景是从一个池里抽的,抽取用的 RNG 由当前 tick 生成,而调用它的流式加载方法跑在按帧推进的更新里。现在每张场地 都只配了一个背景预制体,所以这次抽取与种子无关,没有任何 digest 会因此移动。等到 哪天某张场地上了第二张背景图,它就会变成一次真实的分叉——同一份提交的两次验证互相 矛盾,而肇事的那个提交只加了美术资源。


2. 一块你无法欺骗的排行榜

8 月 2 日时并不存在 SimVerify。它在 8 月 8 日被立起来,如今是四个 .NET 项目、算上 测试约 21,000 行代码,作为服务部署在隧道之后,带健康端点与构建互斥锁。

塑造了其余一切的那个设计决定,是一次拒绝:SimVerify 不做模拟。 曾有人提出用 一个无头 C# 重模拟来充当验证权威,被明确否掉了——那会是第三个产物,带着自己的 浮点行为(桌面 Mono/IL2CPP 对浏览器 WASM),与它一致什么也证明不了,因为它证明不了 玩家的浏览器当时到底算出了什么。权威是已发布的生产构建。服务把一个无头浏览器 指向那个产物、收集结果、比对 digest。它只做编排,从不自己重新推导真相。

同样的直觉支配着比较器。服务与游戏必须跑完全相同的比较代码,因此比较器源码放在 UTools,在这里以 git 子模块的形式直接编译进服务。NuGet 被明确否掉:游戏按 pin 从 UTools 头部构建,服务却会从某个已发布版本构建,而这段错位窗口恰恰是整个设计要 防的那种失效。跨仓库的链接源码则根本什么都 pin 不住。

在此之上是一道跨产物门禁:一份记录、两个产物、同一个 commit SHA。它先把 WASM 构建 跑两遍;若一个构建与它自己都不一致,本次运行直接短路,原生那条腿根本不会启动 ——那样的判定无法归因,而一个无法归因的红灯比没有门禁更糟。

接下来是假定调用者怀有敌意的那部分,它改变的是需求本身,而不是往上加需求。提交端点 过去依据一个 matchesStamped 字段来接受一次运行——而那个值是由被测产物自己算出 来的。服务自己的代码早已把它标注为「仅供参考」。一台信任它的服务器,什么也没验证。 现在接受与否由服务端跑共享比较器决定,榜上记录的是重建推导出的分数,绝不是文件 声称的那个;运行时长同理。一次没有推导出结果的验证,宁可什么都不上榜,也不上一个零。

身份链路上也有对应的一串。Steam 会话票据向 Valve 校验,通过校验的 SteamID 是一次 提交唯一的身份。其中四处发现值得复述,因为每一处在有人去看之前都是隐形的:

  • 票据调用漏了 Valve 文档标注为必需的 identity 参数,而且指向了错误的主机——按 当时发布的代码,任何真实票据都不可能校验通过。
  • 发行商 Web API key 是走在查询串里的,而默认的 HTTP 客户端日志器会在 Information 级别打印请求 URI,于是每一次提交都会把这把 key 写进服务日志。日志器已被移除; 现在有一条测试断言这把 key 不出现在任何级别的任何一行日志里,去掉修复它就会红。
  • AuthenticateUserTicket 是幂等的,所以一张被截获的票据可以反复上榜。票据现在 一次性生效,以 SHA-256 追踪,而不是存下凭据本身。
  • 判定的 switch 在遇到未知判定时是失败即放行的。

现在端点前面按敌意请求抵达的顺序立着四道门:路由之前施加的上传字节上限(在模型 绑定之后再限,限的是一具已经被完整读入的躯体)、对不受信任记录的模式校验、拒绝而 非排队的准入背压,以及按已校验 SteamID 计的滑动窗口限流——429 而且只有 429,绝不 与认证失败或验证失败混为一谈。

可信度评分值得单独一句,因为那是这个服务唯一刻意选择「不执法」的地方。影子指标照算 不误,并以只追加的语料形式存下来;而最严格的模式也只是把可疑的运行路由到 Assisted 分区,而不是拒收。一个未经校准、却有权丢弃运行的阈值,最先误伤的一定是最强的玩家。


3. 一个分数,四个仓库

一个分数如今走的完整路径是这样的。

玩家跑一局。CandyRush 记下种子,以及输入、升级与作弊三条流,并每 N 个 tick 盖一次 digest 采样。UTools 提供定点数学、digest 哈希格式,以及比对这些 digest 所用的 比较器。提交时,SimVerify 校验 Steam 票据、把一个无头浏览器指向已发布产物、重新 推导 digest 流与分数,并把推导出的分数写进每周榜单,再把榜单发布为静态 JSON。 PublicWebsite 在客户端拉取这份 JSON,渲染在 /candyrush/leaderboards——这意味着 分数随每次访问刷新、站点无需重新构建,而公开站点从头到尾不与验证服务通信。

四个仓库,四块各自正确的部件;然后——不出所料——所有有意思的 bug 都住在它们之间的缝里。

最锋利的一个,是一条把自己的 schema 撑破了的裁定。一块榜单由 (周, commit SHA) 标识,且到期归档而非清空,所以当游戏在一周中途发了新构建,一周就可能拥有不止一块 榜单。而已发布的 schema 是按周为键的 previousWeeks: string[],它压根表达不了这件事: 站点的归档选择器把两块榜单塌成一行,第二块从此无法抵达。修法是一个纯增量的 schema v2——新增榜单 id、commit SHA 和一份完整榜单清单,同时旧字段保持旧含义,好让 v1 渲染 器继续可用——站点则在新清单有内容时优先用新的,没有时回落到旧的。

其余的缝隙 bug 都属于同一族:一个在边界这一侧为真、在另一侧为假的假设。

  • 归档选择器是从当前加载的那块榜单推出来的。而归档文件是不可变快照,其清单在发布 那一刻就冻结了,无法点名任何在它之后发布的东西。于是打开一份归档反而让选择器 变短,把访客困在里面。现在这份清单是一个只增不减的并集。
  • 每一块归档榜单都挂着「可能已过期」的标记,因为按定义归档必然早于 24 小时的过期 阈值。过期性现在只属于实时榜单。
  • 发布方用 8 位 SHA 命名榜单文件,站点却渲染 7 位。展示同一个 SHA 的另一种缩写, 比什么都不展示更糟:一个构建的两种写法会被读成两个构建,而这恰恰是「按 commit SHA 标识榜单」本要消除的混淆。
  • Steam ID 在整个 Web 层全程保持为字符串。64 位 id 超出 JavaScript 数字能精确表示的 范围,而一块会悄悄四舍五入身份的排行榜,就是一块会把玩家合并掉的排行榜。

榜单 id 来自网络,并会被拼进一个 fetch URL,因此在发起任何请求之前都要先校验—— 路径穿越、协议相对以及绝对 URL 形式的 id 一律拒绝。这就是缝隙教的另一件事:一个值 只要跨过了仓库边界,它就是不受信任的输入——哪怕两边都是你自己写的。


4. 十三天

以上一切,化作凭据。计数取自 8 月 2 日至 8 月 15 日之间落在各自主干上的合并提交。

仓库合并 PR提交在做什么
CandyRush67221确定性修复、digest 仪器化、每周挑战启动、提交客户端
UTools2375定点数学、digest 哈希 v4→v6、共享比较器、RNG 抽取计数
SimVerify1744整个服务——仓库建于 8 月 8 日
PublicWebsite26排行榜页面及其归档
Agent bridge 一文SimVerify 立项digest v4:RNG 计数SimVersion 重置为 1tick-773 摄像机分叉服务端推导判定每周榜单上线digest v6:逐实体
2026 年 8 月 2 日至 8 月 15 日。每一个标记都是四个仓库中一次已合并的 pull request。

关于这些数字,有两点要说老实话。这里的一个 pull request 是评审的单位,而不是工作量 的单位——其中好几个是针对已合并 PR 的审计增量,这种模式出现得够频繁,已经算是一种 工作方法而非偶然。另外,窗口内 CandyRush 的 139 个非合并提交中,有 118 个带着 Co-Authored-By: Claude 标记。

这十三天最具代表性的产出不是功能,而是仪器:一个从「数敌人」变成「哈希敌人」的 digest、折进该哈希的各作用域 RNG 抽取计数、逐 tick 的采集窗口、在指定 tick 上不设 上限的状态转储、一个编辑器内的验证工装、一道跨产物门禁,以及一个全部职责就是「不相信 提交上来的文件」的服务。

这也带出了那个故意倒退的数字。盖进每份记录的模拟版本号,在大约一周内一路爬到 3 → 4 → 5 → 6,原因只是设计文档里的一句豁免措辞,被读成了「digest 语义一变就可以 往上跳」的许可。8 月 10 日它被重置为 1 并冻结,那句驱动了四次跳版的句子被删掉。 模拟本身没有任何回退;只是版本号不再充当日记,重新做回一个在发布之前都不会再动的 兼容闸门。


四篇里贯穿的是同一个模式,而它其实与游戏无关。这个窗口里的每一次失败,都是边界 的失败——帧时钟与 tick 时钟之间、一个构建与它自己的重跑之间、同一个 commit 的两个 产物之间、发布方的 schema 与读取方的假设之间、客户端的声称与服务端的判定之间。 两边各自都是对的。错的是缝。

分数如今是这套系统能够证明的东西,而不再是玩家自己报出来的东西。走到这一步, 大部分工作就是找出那些在悄悄用错单位度量的地方——而它们从内部看,没有一个显得不对劲。