用途
在任何人争论代码位置之前,按 tick 顺序找出游戏玩法缺陷发生的时间点。语义游戏缺陷——分数被重复计入、冷却永不结束、资源在存档后重新填满——不会抛出任何异常。没有堆栈跟踪,因此从错误向后追踪没有起点。起点必须被构造出来:逐事件轨迹加上一个对每个健康状态都为真的谓词,扫描谓词首次失败的索引。
输出是一个 tick 索引和两个状态,不是诊断,也不是补丁。
何时使用
- 缺陷可确定性复现,且错误可观测值是一个值、计数、重复或缺失的效果,或一个本应不可达的状态。
- 症状出现在远离原因的地方:HUD 显示错误数字、运行以不可能的总数结束、存档恢复了本不应恢复的内容。
- 存在日志或遥测转储,但没人知道其中哪一行是第一个坏行。
何时不使用
- 缺陷尚不可复现。停止。以下所有内容都重放固定输入;没有固定输入就没有可重放的内容,任何结论都是猜测。
- 异常、崩溃、断言或失败测试已经指明了文件和行号——改为遵循该线索;此过程严格来说更昂贵。
- 值正确但感觉太强、太弱或不好玩。这是平衡性问题,不是分歧。
- 你已经知道分歧 tick,只需要选择修复方案。
所需输入
一个 ReproCase——最小契约,内联于此,因此此技能不需要其他内容:
id: # 此缺陷的稳定名称
seed: # RNG 种子,如果运行无种子则为 "none"
build: # 提交 / 构建标识符
setup: # 起始状态或存档槽,如果不是全新运行
inputs: # 有序输入事件及其 tick 索引——输入磁带
observe: # 错误可观测值,作为值:"score == 50 at tick 6, expected 30"
expected: # 健康运行在该点产生的值
determinism: # always | intermittent(n/m runs) — intermittent 会阻止此过程
还需要:
- 一个可重放核心:游戏逻辑必须能够从 (state, event) -> state 逐步执行,路径中没有挂钟时间、无种子 RNG 或 I/O。如果不是,为受影响子系统提取一个核心是前置工作,不在此过程范围内。子系统测试装置可能足够,但从不可重放的真实项目中提取一个尚未被此技能验证;报告该差距,而不是将测试装置视为常规或已证实。
- 至少一个 oracle 谓词(见下文)。
过程
- 在查看轨迹之前编写 oracle。将属性表述为对观测状态的谓词,形式为在每个健康步骤都为真且可机械检查:
INV-SCORE-ONCE: no entity identity is credited score more than once.
INV-<field>-SOURCE: <field> changes only on the event types allowed to change it.
INV-<x>-ROUNDTRIP: after a restore, <x> equals the value captured at the matching save.
只能由人类阅读日志来评估的谓词不是 oracle。如果属性无法以这种方式编写,请说明并停止——该缺陷无法通过此方法定位,而猜测修复是你正在拒绝的替代方案。
-
发出观测轨迹。每次状态转换一条记录,而不是每次运行一条。字段列表在 references/observation-event.md 中。从重放发出,而不是从实时游戏发出,以便轨迹可复现。
-
重放并扫描。将输入磁带折叠通过核心,保留每个中间状态。在每个索引处评估每个 oracle。记录任何 oracle 首次失败的最低索引。当轨迹适合内存时线性扫描;仅当重放昂贵且 oracle 是单调的(一旦为假,保持为假)时才二分——非单调 oracle 会二分到错误索引。
-
停止条件,按此顺序应用:
- 任何地方都没有 oracle 失败,但最终状态错误。观测层太粗糙:某些已改变的状态不在轨迹中。添加缺失字段并重新运行。不要继续修复。这是首次尝试中最常见的结果,它是关于插桩的发现,而不是关于游戏的。
- 一个 oracle 在索引 0 处失败。设置已经无效;ReproCase 起始状态错误,而不是游戏。
- 两个 oracle 在同一索引处失败。报告两者。不要选择看起来更容易的那个。
-
将首次违反与首次原因分开。谓词断裂的索引是状态变得可观测错误的地方。使其成为可能的写入通常更早且无声——设置了一个标志、重用了一个身份、快照中遗漏了一个字段。从分歧索引向后遍历被违反 oracle 读取的字段,并报告写入其中任何字段的最早事件。命名两个索引。在违反索引处修复而不这样做会产生一个隐藏症状的守卫,并留下坏写入。
-
确认定位是因果的。重新运行同一磁带,移除单个分歧事件或更改其输入。如果 oracle 仍在同一索引处失败,则定位错误——报告的事件是旁观者。将此报告为失败的定位,而不是交付未验证的索引。
输出
一个 DivergenceReport:
repro: # ReproCase id
oracle: # the predicate that failed, verbatim
first_violation_index: # tick / event index
first_violation_event: # the event at that index
state_before: # observed fields the oracle reads, immediately before
state_after: # the same fields immediately after
earliest_writer_index: # earliest event writing any field the oracle reads
causal_check: # confirmed | failed — result of step 6
unobserved: # state deliberately not in the trace, and why
unobserved 是必需的。声称完全覆盖的轨迹是在声称缺陷不可能在其他地方,这几乎从不真实,并且是使下一个 bug 昂贵的假设。
验证
- oracle 被表述为谓词并机械评估,而不是肉眼阅读。
- first_violation_index 是最低失败索引,而不是第一个被注意到的索引。
- causal_check 是 confirmed,或者报告说定位失败。
- earliest_writer_index 存在且 <= first_violation_index。
- 报告不包含提议的补丁。选择修复是一个单独的决定,将其混入此处是定位变成对先选修复的论证的方式。
常见失败模式
- 仅聚合遥测。运行总数("final score 50, expected 30")无法定位任何东西;它们是症状的复述。逐事件记录是整个方法。
- 在阅读轨迹后编写 oracle。它将被塑造成接受轨迹显示的任何内容,并将通过。在步骤 1 中编写它,不要在步骤 3 中编辑它。
- 非确定性被偷运进重放。无种子洗牌、挂钟读取或对无序映射的迭代会在运行之间移动分歧索引。如果索引在两次重放之间不稳定,在相信任何索引之前先修复它。
- 停在读者处。HUD 显示错误数字是你注意到它的地方,绝不是它发生的地方。步骤 5 存在是因为这是默认错误。
- 二分非单调 oracle。"能量在范围内"可能失败、恢复、再次失败;二分落在任意失败上。除非 oracle 是单调的,否则线性扫描。
参考
- references/observation-event.md — 逐事件轨迹记录,以及它与运行聚合遥测的区别。