SuperAPI AI 工具集

localizing-game-state-divergence

测试与质量

查看项目

更新于 10-15 10:17

技能介绍

用途

在任何人争论代码位置之前,按 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 谓词(见下文)。

过程

  1. 在查看轨迹之前编写 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。如果属性无法以这种方式编写,请说明并停止——该缺陷无法通过此方法定位,而猜测修复是你正在拒绝的替代方案。

  1. 发出观测轨迹。每次状态转换一条记录,而不是每次运行一条。字段列表在 references/observation-event.md 中。从重放发出,而不是从实时游戏发出,以便轨迹可复现。

  2. 重放并扫描。将输入磁带折叠通过核心,保留每个中间状态。在每个索引处评估每个 oracle。记录任何 oracle 首次失败的最低索引。当轨迹适合内存时线性扫描;仅当重放昂贵且 oracle 是单调的(一旦为假,保持为假)时才二分——非单调 oracle 会二分到错误索引。

  3. 停止条件,按此顺序应用:

    • 任何地方都没有 oracle 失败,但最终状态错误。观测层太粗糙:某些已改变的状态不在轨迹中。添加缺失字段并重新运行。不要继续修复。这是首次尝试中最常见的结果,它是关于插桩的发现,而不是关于游戏的。
    • 一个 oracle 在索引 0 处失败。设置已经无效;ReproCase 起始状态错误,而不是游戏。
    • 两个 oracle 在同一索引处失败。报告两者。不要选择看起来更容易的那个。
  4. 将首次违反与首次原因分开。谓词断裂的索引是状态变得可观测错误的地方。使其成为可能的写入通常更早且无声——设置了一个标志、重用了一个身份、快照中遗漏了一个字段。从分歧索引向后遍历被违反 oracle 读取的字段,并报告写入其中任何字段的最早事件。命名两个索引。在违反索引处修复而不这样做会产生一个隐藏症状的守卫,并留下坏写入。

  5. 确认定位是因果的。重新运行同一磁带,移除单个分歧事件或更改其输入。如果 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 — 逐事件轨迹记录,以及它与运行聚合遥测的区别。