在 CI 中证明确定性的回放测试框架
CandyRush 建立在确定性模拟之上:相同的种子和相同的输入,必须逐 tick 产生完全相同 的游戏。正是这一性质,后来让服务器验证的每周排行成绩成为可能。但在 2026 年 5 月 初,它在很大程度上还只是一种信念,依据是许多针对单个类的小型单元测试。
5 月 8 日,我用一个回放测试框架补上了这个缺口,它在常规测试套件中运行。它做的事 可以用一张表概括:
| 层 | 场景 | 长度 | 摘要 | 运行时间 |
|---|---|---|---|---|
| EditMode 基础层 | 带种子的 RNG、定点数运算、循环调度、合成输入流 | 3,600 tick(60 秒) | 61 个哈希,每秒一个 | 不到一秒 |
| PlayMode 实体状态层 | 真实游戏场景、完整依赖注入、第 1 关、一个技能 | 1,800 tick(30 秒) | 30 个哈希,每秒一个 | 启动一次场景,然后以 10 倍速运行模拟 |
每一层都在每个模拟秒对模拟状态做一次哈希,并把这串哈希与提交在仓库中的 fixture 比对。任何一秒出现差异,测试就会失败,并打印最先出现分歧的那些 tick。
这个框架以一次提交落地:21 个文件,新增 1,054 行。在合并之前,它还发现了一个真实 的确定性泄漏。
它从何而来
这个框架是一项后续工作。前一天,PR #254 审查了每一个弹道类的确定性违规。它删除了
五个已废弃的弹道类,并把仍在使用的九个改为在 Simulate 中基于 tick 计时:不再由
缓动库的回调决定剑的判定框何时关闭,也不再用浮点累加器记录经过时间。那个 PR 涉及
21 个文件,+879/-740。
它的验收标准里包括一个端到端回放测试,但放不进那个 PR。于是我写了一个后续 issue, 列出明确的验收标准:录制的输入流和种子、无实时输入的回放、按固定 tick 间隔生成且 适合逐字节比对的状态摘要、在 Windows 上的参考运行并在至少一个其他操作系统上验证, 以及一个 CI 闸。
一个 agent 依据这个 issue 实现了它。我审查了设计和 diff,issue 当天关闭。摘要、 Windows 参考运行和 CI 闸都交付了。有两件事以不同方式交付或没有交付,下文会再谈: 输入是在代码中定义的,而不是录制到文件;也还没有在第二个操作系统上验证过。
摘要是怎样构建的
摘要是一组 (tick, hash) 对。PlayMode 层从运行中的场景计算每个哈希:
ulong hash = 17;
hash = hash * 31 + tick;
// Player position: FixedVector2 raw values are bit-identical across platforms.
var pos = player.FixedPosition;
hash = hash * 31 + (ulong)pos.X.RawValue;
hash = hash * 31 + (ulong)pos.Y.RawValue;
// Then: player projectiles, enemy projectiles, enemies (id, position, HP),
// each list sorted into a stable order and prefixed with its count.
这里有三个关键选择。
哈希定点数的原始值,而不是浮点数。 模拟用 Fixed64(一种 64 位定点数类型)
存储位置。它的原始整数就是真实状态。两台机器要么逐位一致,要么不一致;没有需要
调的 epsilon。唯一的例外是敌人的 HP,当时仍是浮点数,在哈希前会四舍五入到千分位。
数量和内容一起哈希。 即使多出一个没有其他影响的弹道,哈希也会改变。
哈希前先排序。 Unity 的 FindObjectsByType 返回对象的顺序是未定义的。每个列表
都先排成确定的顺序,而正是这条规则暴露了那个 bug。
场景是在代码中固定的,而不是从真人游玩中录制的:种子 12345、第 1 关、
ThunderRing 技能,以及 1,800 tick 的空闲输入序列。EditMode 层则生成带种子的合成
输入流(3,600 tick,30% 密度),因此玩家输入在最底层得到了覆盖。提交的 fixture
就是那份“录像”:一个小文本文件,头部描述场景,之后每秒一个哈希。
# Candy Rush replay determinism baseline
schema=1
scenario=playmode_entity_state_v1
os=windows
snapshot_count=30
seed=12345
ticks=1800
snapshot_interval=60
ability=ThunderRing
---
60:C6202871EAEDBD4B
120:C8229854DEC63708
...
构建过程中发现的泄漏
第一个版本按 EntityId 对弹道排序,这和模拟中其他所有地方使用的规则相同。结果
对弹道来说,这条规则其实是空的。
原因是:两个弹道类都提供了 SetEntityId,但从来没有任何代码调用它。游戏中的每个
弹道都是 EntityId = 0。对一个所有键都为零的列表排序,等于什么都没做,于是哈希
悄悄退回到了 FindObjectsByType 的未定义顺序。
这是一个潜在的问题,而不仅仅是测试的问题。任何把“按 EntityId 排序”当作弹道 “确定性顺序”的模拟代码都是错的。框架现在按弹道的原始定点位置排序,这才是真正的 确定性状态。缺失的 ID 作为单独的 issue 跟踪,并由同一个提交关闭。
针对单个类的单元测试没有发现这个问题。它们一次只测一个对象,而只有一个对象时, 顺序永远无关紧要。
每层三个测试
每一层都运行相同的三项检查:
- 同一操作系统,多次运行一致。 在一个进程中生成两次摘要,要求哈希完全相同。 这能在不需要任何 fixture 的情况下,抓住墙钟时间、无序字典、Unity 回调顺序之类的 旁路。
- 与提交的自身基线一致。 把新摘要与本操作系统提交的 fixture 比对。这是回归闸: 任何改变游戏行为的改动都会在这里失败,哪怕它依然是确定性的。
- 与提交的对端基线一致。 把新摘要与其他每个操作系统的 fixture 比对。这是跨平台闸。
围绕它们还有两条流程。缺少 fixture 时会自举:测试写出文件,并提示你提交它。有意的 行为改动要使用一个显式的重新生成测试,它在常规运行中永远不会执行,因此重新认可 fixture 始终是一个有名字、可审查的动作,而不是副作用。fixture 还带有 schema 版本; schema 不同的对端会被跳过而不是判失败,所以格式变更不需要所有操作系统同时提交。
在 CI 中,这个框架在自托管 Windows runner 上的常规 EditMode 和 PlayMode 步骤里运行, 测试失败就会让构建失败。
它今天证明了什么(直说)
跨平台的设计已经就位,跨平台的证据还没有。
我查了历史:每一个提交过的回放 fixture 都带着 os=windows 标记。截至 2026 年
11 月共有五个(最初的两个,加上后来添加的 props、boomerang 和关卡分段场景),从未
提交过任何 macOS、Linux 或移动端 fixture。所以对端测试从未真正比较过两个操作系统。
这就是那条没有达成的验收标准。
这个框架在每次运行时确实证明的是:
- 在 Windows 上,模拟在实体层面多次运行结果一致;
- 自从 fixture 上次被认可以来,游戏行为没有改变。
后来证明,第二项保证是我用得最多的。9 月到 11 月之间,Windows fixture 在许多个 独立提交中被重新认可,每个提交都以导致它的有意模拟改动命名:一次种子混合修复、 一次流场重写、波次变化、一轮敌人密度性能优化。这些改动都不可能悄悄夹带行为变化。
游戏确实提出的跨平台主张建立在其他证据之上:模拟中的定点数运算,以及后来对提交 成绩在服务器端的重新模拟。那些是另外的故事。
后来出过的两个问题及修复
Inconclusive 不等于通过。 没有其他操作系统可比时,对端测试会报告
Inconclusive。这看起来无害,但 Unity 的批处理测试运行器会把 Inconclusive 映射为
失败的退出码。在只有 Windows 的环境中,一个零失败的套件仍然以非零退出。6 月,
对端测试改为在没有对端时以一条解释性消息通过。
fixture 可能在提交时就已过期。 6 月,两个 PlayMode 自身基线测试都变红了。在 重新生成任何东西之前,负责修复的 agent 先确认了:在不同的 Unity 进程之间新生成的 哈希完全一致,并且自 fixture 提交以来没有任何模拟代码改动。它们是在更早的工作中 以过期状态提交的。确认之后,才通过显式的重新生成测试重新生成。如果先重新生成, 就会毁掉判断模拟是否真的发生漂移的证据。
可以推广的经验
哈希你真正存储的状态。 定点数原始值让比对是精确的。浮点数需要容差(HP 的取整 就是一个),而容差正是 bug 藏身的地方。
你哈希的每个列表都需要全序。 只有 ID 唯一时,“按 ID 排序”才算一种顺序。在 信任一次排序之前,先数一数有多少个不同的键。
让重新设定基线成为一个显式动作。 每次运行都改写自身文件的 golden-file 测试 什么也证明不了。一个独立的、需主动选择的重新生成步骤,让每次基线变化都在审查中 可见。
说清楚还没证明的东西。 这个框架在设计上是跨平台的,在证据上是单平台的。这样 说出来,才能让“确定性已验证”这块牌子保持诚实。