验证与取证
验证与取证
面向「所有断言全绿,但游戏没法玩」这类事故的通用作业手册。不依赖任何具体引擎、语言或平台;具体技术栈的结论集另见引擎附录。
0. 地基思想
0.1 把玩家的话翻译成缺陷类别
玩家反馈里几乎不会出现「函数 X 在 Y 条件下失效」。玩家只会说:「按 F 没反应」「数字对不上」「这里很出戏」「屏幕全黑」。把这些话逐条翻译:每一条的底层都是宣称与呈现不符——系统声称提供了某能力,实际呈现不出它的可感知后果。
在动手前把每条反馈写成一行断言:
玩家说:按 F 没反应
翻译:某个交互的「宣称」(此处可交互)与「呈现」(有可见/可听反馈)之间断开
不翻译就直接改,是本领域返工率最高的路径。判据:每条外部反馈在开工前必须有一句「宣称 vs 呈现」的书面表述。
0.2 把「按 F 没反应」拆成三个不同 bug
这是三种根因完全不同的缺陷共用同一句玩家话。不拆开查,会在错误的方向上迭代三轮以上。
| 子类 | 根因 | 判据 |
|---|---|---|
| (a) 几何不可达 | 拾取只认最近命中,有个壳体/挡板挡在真正目标前面 | 从每个交互物 8 方向 × 2 距离 × 9 级俯仰逐个打射线,命中率必须 100% |
| (b) 可见性为零 | 交互其实通了,但画面与声音一处变化都没有,玩家只能判定为坏了 | 断言「触发后至少一个可感知量改变」(音效响过 / 贴图切换 / 计数器变红 / 灯强变化),并断言感知通道本身在验收环境里是活的 |
| (c) 入口生命周期只挂在一瞬间 | 入口只在某个一次性时刻触发;玩家选了别的选项就永久错过 | 对每个入口枚举「可达时刻集合」,断言不存在「错过即永久锁死」的分支 |
先判子类再动手。典型顺序错误:在 (a) 上改了半天视觉反馈,而实际是 (c)——某个选项永久关掉了入口。
0.3 承认「全绿」不等于「能玩」
三类真实事故,读代码都发现不了:
- 门格被砌成墙,整局不可玩(地图数据正确,建造顺序错误)。
- 寻路死锁,敌人永远追不上人(所有局部移动断言通过,全局不可达)。
- 一帧异常让画面全黑,而所有逻辑断言全绿(断言只看逻辑状态,没看界面/渲染)。
推论是硬约束:验收必须跑在与玩家同一条路径上、开音频、无渲染节流。 判据:验收页的配置与玩家实际配置逐项对账一次,差异写在页头注释里;不允许「为了跑得快」而关掉被测对象(见 §6.10)。
0.4 先怀疑探针,再怀疑游戏——但只怀疑一次
这条纪律的价值在于:探针写错一次,就能把正确实现判成失败,进而把正确实现「改坏」。判据:探针写错的历史次数必须记账;写探针时先在「已知答案」的输入上自测一遍,标定通过才允许拿去测未知对象。
反过来也成立:探针标定通过之后,必须立刻回头查游戏本体,因为探针是有概率撞上真 bug 的。亲历案例:装写入间谍抓栈时顺手抓到一个拾取射线穿墙的真 bug。
纪律:一次怀疑,两个动作。 先标定探针,标定通过后不许再以「探针问题」解释失败,直接当游戏 bug 查。
1. 试玩反馈的收口规程
外部反馈是一轮工作的输入形态,与内部缺陷单不同:它是玩家的语言,信息量比表面少,复现成本比机器断言高。这一章规定从「一句反馈」到「一条判据 + 一次覆盖范围声明」的固定动线。
读法:1.1 是查询表,反馈进来先在表里找到行;1.2 是试玩提问清单;1.3 是反馈进场的形式约束;1.4–1.6 是收尾侧的三件事(收尾纪律、台账字段、反面做法)。1.1 的每一行都指向本篇其他章已有的判据,不重复展开。
1.1 反馈形态 → 真实问题类型 → 第一动作 → 通用教训
玩家的同一句话经常盖住三类根因完全不同的缺陷。表里「第一动作」必须是可立即执行的动作,不是「检查一下」。
| 反馈形态 | 真实问题类型 | 第一动作 | 通用教训 |
|---|---|---|---|
| 「按某个键没反应」 | 几何不可达 / 交互其实通了但画面与声音零变化 / 入口只挂在一瞬间 | 三路分查:① 从该交互物 8 方向 × 2 距离 × 9 级俯仰逐点打射线,记命中率;② 触发一次,把触发前后的可感知量(响过的音、切过的贴图、变色的计数、变过的亮度)列成清单逐项比对;③ 枚举该入口的可达时刻集合 | 先判子类再动手(形态与判据见 §0.2) |
| 「数字对不上」 | 同一事实存在多处副本 / 落盘时机挂在结算页而不是状态变更点 / 分类口径被命名污染(某件物品的命名让它被算进了另一个类目) | 列全字段清单:每个字段一行,标出「谁写、谁读、什么时候落盘」;再对同一事实做单数据源断言 | 同一事实只能有一个数据源;落盘点必须挂在状态变更点上,挂在结算页等于中途崩一次就全丢 |
| 「这里很出戏」 | 宣称与呈现不符:设计/文档说 A,玩家看到 B | 先写出「宣称 vs 呈现」两栏,再动手;两栏写不出来就退回要材料 | 不翻译直接改是本领域返工率最高的路径(§0.1) |
| 「屏幕全黑 / 卡死 / 按重试也没用」 | 每帧异常把渲染吃掉 / 数值非有限沿调用链传播 | 分开打印两个计数:循环计数(每帧递增)与绘制计数(真正出图的帧)。前者涨、后者停 = 每帧抛异常;两者都停 = 主循环挂死 | 循环活性与渲染活性是两个量,只测一个必然误诊(§5.17) |
| 「模型精度差 / 会穿模」 | 权威数据不一致:导航与碰撞各用一套身体包络 | 把导航用的包络与碰撞用的包络各打印一次,逐轴比对,不一致的轴即穿模来源 | 「看起来糙」往往不是面数问题,是两处几何各说各话;先对账再加面 |
| 「按了没声音 / 太吵」 | 静音体验缺失 / 层级混乱(环境声与自身声同层) | 分层采样一次混音:环境声层与自身声层的实时条数与叠加峰值分别打印 | 密集重叠用密度表达,不用响度表达——降响度会连清晰度一起降,削密度只伤拥挤感 |
| 「按钮被顶走 / 在屏幕外」 | 界面契约未隔离:入场动画改了固定元素的矩形 | 对该元素连续采样矩形三次,三次逐项相等才算通过;再确认入场动画的目标集合里不含固定元素 | 固定元素不参与任何入场/位移动画;动画目标用类名选中,不用元素序号 |
| 「两处结论矛盾」 | 世界内文档与实际布局矛盾 | 建「文档 ↔ 地图」对账表:每条文档声明一行,标出对应空间实例与实测坐标 | 文档是对账对象不是权威;改文档与改布局必须落在同一次提交里 |
| 「进了这个房间什么都没发生」 | 该处被设计成无事件,但玩家的预期已由前一段建立;或者事件条件写成了「玩家未观测到某物」,玩家永远观测不到 | 把该处的预期来源列出来(玩家凭什么觉得这里该有事);再把事件的触发条件逐条念一遍,检查其中是否含「玩家只能靠 X 得到」的项 | 空房间可以是设计,但必须是「玩家已经知道这里会空」之后的空;预期落空与预期落空后的静默是两种缺陷 |
| 「重开之后我的东西没了」 | 进度与设置共用同一套持久化位置,且其中一项被覆盖写;或退出路径没有走收尾写入 | 列出所有需要跨会话存活的字段,逐项标出写入时机与写入者;数出「同一位置被写入」多于一次的项 | 会话存活的数据不能与每局重置的数据共用位置;重置写入必须发生在读取之前或明确与存活数据分文件 |
| 「难度忽高忽低 / 一会儿很吓人一会儿没事」 | 张力来源与量表脱钩:峰值超阈值未触发退场,或静默窗口被非事件内容(过场、无事件移动段)吃掉 | 把这一段的张力值逐时刻采样成时间序列,标出峰值、超阈时刻、静默窗口实际时长三项 | 难度不均匀的第一嫌疑是节奏机制失配,不是内容分布不均;先看时间序列再动事件 |
| 「这东西很蠢 / 它好像没看见我」 | 感知与决策两段脱钩:感知半径判据用了与寻路不同的判据,或状态锁期间感知仍在累积但决策不再响应 | 把感知判定与决策判定各打印一次当前值,逐字段比对 | 「很蠢」通常是两段判据不一致,不是参数不当;把感知与决策的输入逐字段列出来对账 |
三条容易被漏掉的追加纪律:
- 分类口径污染:显示名会渗进统计口径——名字里带某个词,它就被算进那个类目。判据:对每个类目做一次「把显示名替换成无关词」的重跑,计数必须逐项不变。计入哪一类由结构化字段决定,不由命名决定。
- 「按钮被顶走」要写两条断言:结果侧(钉底定位,多次采样矩形完全相同)+ 原因侧(固定元素不出现在任何动画目标集合里)。只写前者会反复回归,因为每次都能靠调参数蒙过去。
- 同一类反馈第二次上报,是上一轮收尾的账:如果它原样回来,说明上次只修了那一个实例,或报了覆盖范围但对方没读懂。判据:台账里出现同类第 2 条时,本轮收尾必须给出这一类的判据文件位置,不能只交代码改动。
1.2 一轮试玩该采集什么
不要问「好不好玩」。 这个问题把几乎所有反馈都导向「加内容」,而且它不可定位、不可判定,答案与提问者的期待强相关。替代口径是下面这八条,每条都指向一个具体时刻或具体位置。
| 要问的问题 | 为什么 | 记录形式与判据 |
|---|---|---|
| 你在哪一刻感到了恐惧 / 在哪一刻感到了安全 | 恐惧与安全都由具体时刻承载,「整体还挺吓人的」无法定位 | 记时刻 + 场景名;恐惧与安全各至少两个时刻,只有一个说明玩家没建立对比 |
| 第一次觉得危险是什么时候 | 它区分「预先建立的威胁感」与「突然出现的惊吓」,两者的修法完全相反 | 记时刻 + 当时是否看见了威胁源;看见与没看见分开记,两者是不同缺陷类别 |
| 哪一刻你觉得无聊 | 无聊是可定位的:它几乎总是一段「有预期但没兑现」的时长 | 记时刻 + 该段时长;时长超过阈值即列为可查项,不接受「有一会儿没意思」 |
| 你在哪里放弃的,以及在哪里死的 | 放弃比死亡更值得问——死亡有明确触发点,放弃是玩家在有解时选择退出 | 两处分别记位置与当时的进度量;只记死亡点会漏掉全部劝退信息 |
| 第一次卡住的位置,卡了多久 | 卡住通常是一处可达性或信息缺口,不是难度问题 | 记位置 + 时长;同一位置第二次出现即升级为缺陷项 |
| 哪些提示被读过,哪些被跳过 | 被跳过的提示不是噪音,是宣称与呈现的又一次错位:他看见它,但不知道要不要执行 | 逐条标注已读/跳过;对每个跳过项单独追问「当时你以为它要我做什么」,答案写进对账表 |
| 收集/进度数字你自己能不能对上 | 对不上说明呈现层与状态层口径不一致(见 1.1 第二行),而玩家会先发现 | 记玩家口报的数与实测数;不等即当场立一张字段清单(§1.1 第二行) |
| 用一句话描述这个游戏 | 与设计意图的落差是最好的一致性指标:描述会暴露玩家实际建立的规则,而不是你希望的规则 | 逐字记原话,不做归纳;下一轮把设计意图句与这句并排放进文档对照,差得越远说明前面几条纪律漏得越多 |
提问方式的三条纪律:
- 一次只问一件事。 连问三个问题会拿到一个混合答案,而混合答案无法定位。推导:三个问题对应三个不同的缺陷类别,回来的答案是它们的并集,逐条拆解时每一项都带着另外两项的污染。
- 不要在问句里带解释。 「刚才那个房间是不是太安静了」不是在提问,是在提示正确答案;玩家会顺着你的暗示答,得到的反馈全部作废。改问「你在哪里第一次觉得该有东西发生」。
- 不要在对方回答后立刻解释设计意图。 一旦解释了,「用一句话描述这个游戏」这条就废了——你得到的是你的设计意图的回声,不再是玩家实际建立的规则。意图的对照留到下一轮再做。
1.3 反馈进场的形式
一条可开工的反馈 = 截图 + 一句话 + 位置。
- 没有位置的反馈无法复现;只有截图没有位置的也一样——同一张截图在不同空间里含义不同。
- 位置允许写成空间名(「进门左手第二个架子」)或坐标加朝向,两者都可,但必须落在可复现的形式上。
- 判据:任何一条反馈在开工前要能被复述成「在这张图的这个位置,玩家看到的是 X,他期待的是 Y」。复述不出来就退回要材料,不凭猜测开工。
拿不到位置时的处置顺序:先问「上一个已知位置是什么」(用相邻已知点定位),再问「当时朝向哪边」,最后才用截图里的环境特征反查。三步都问不出位置,就把这条标成 NOT_REPRODUCED 挂账,等下一轮,不要用猜测代替位置——猜错位置做的修复会污染样本,把另一处的正确行为一起改掉。
1.4 收尾纪律
- 一条反馈触发全局同类排查,并把判据写进项目文档。 推导:只修被点名的那一处,同一类必然在下一轮重犯,而且会以「怎么又坏了」的形式回来,届时看起来像回归。做法:为这一类起一条可写的判据——能落成断言或审计页的一行——写进项目文档,而不是一次性修复;然后落成可自动化的断言或审计页。判据:收尾清单里每一类都带一个文件位置指针。
- 收尾必须显式交代覆盖范围。 写「同类排查已完成,覆盖 N 处,判定方式 X」。推导:对方无法从「修好了」三个字推出覆盖面,不写他就会反复重报同一类。判据:收尾正文里「已排查 + 数量 + 判定方式」三项齐全,缺一不算收尾。
- 主动声明工具盲区。 例:本次只测未被门禁的交互物的可达性探针,被门禁住的那批查不到。推导:探针的覆盖面本身就是结论的一部分,隐去盲区会让「全绿」被读成「全查过」。判据:每次收尾列出「本次探针覆盖了什么 / 没覆盖什么」;未覆盖项要么排期,要么显式标注为已知盲区。
- 反馈进账本,逐项回答。 外部反馈与需求先进账本(来源、原文、位置、当前状态)。每轮收尾逐项回答「做了没有」;未做的显式挂账并写清阻塞原因。判据:账本每条都有本轮的处置结论,出现「无结论」条目即视为收尾未完成。不要静默跳过——静默跳过在下一轮会以「你根本没看」的形式回来。
- 一条反馈命中表里多行时,先按表把每行走完,再定优先级。 推导:这类反馈的表层描述会把两三个真问题叠在一起,先修最显眼的那个,剩下的会以「还在」的形式回来,而此时已经又动过一轮代码,归因更难。判据:跨类的反馈在收尾里逐行列出处置结论,「本轮不处理」也要写明理由。
1.5 台账字段
台账(反馈账本)的每一行固定这几个字段,缺字段的行不算已收集:
| 字段 | 内容 | 判据 |
|---|---|---|
| 原文 | 玩家原话,不做归纳 | 与你的转述逐字可对照 |
| 位置 | 空间名或坐标加朝向 | 能被复述成「在这张图的这个位置」 |
| 表中行号 | 命中 1.1 表的哪一行 | 行号缺失即视为未翻译 |
| 宣称 vs 呈现 | 两栏对照(§0.1) | 两栏都填不出即退回要材料 |
| 缺陷类别 | 1.1 行的第几类分支 | 与该行的三路分查结果一致 |
| 处置 | 已修 / 挂账 / NOT_REPRODUCED | 三态之一,不允许留空 |
| 判据落点 | 写进哪个文件的哪一节 | 非空,且指向可读的一行 |
1.6 反面做法
- 把「好玩吗」当唯一问题。 后果:全部反馈被翻译成「加内容」,且无法定位到具体位置。
- 收到反馈直接改,不先翻译成缺陷类别。 后果:返工,且返工点通常到第三轮才暴露。
- 修完被点名的那一处就收工。 后果:同一类下一轮重犯,账面看是回归,实质是全局排查从未发生。
- 声称「已排查」但不报覆盖范围。 后果:对方无法判断该继续报还是该等,只能重复上报同一类。
- 把位置猜出来而不是问出来。 后果:改错实例,样本被污染,且这类改动往往看起来「有效」——相邻处的行为被顺手改好,掩盖了真正的缺陷。
1.7 一轮收尾的固定模板
四项缺一不可,缺的那项就是下一轮重复上报的来源:
本轮处理(反馈行号 → 缺陷类别 → 处置)
同类排查:本类共 N 处,覆盖 M 处,判定方式 ______(探针/枚举/对账/人工过一遍)
工具盲区:本次探针覆盖 ______;未覆盖 ______,处置:排期 / 已知盲区
账本状态:新增 __ 条,已结 __ 条,挂账 __ 条(逐条列出)
可回退:本轮可 revert 的提交 ______(见 §6.13)
2. 静默失效五形态
游戏 bug 的主要形态不是抛异常,而是无报错地全段失灵。按下面的顺序排查,因为出现频率从高到低。
2.1 读而从未写
某个状态字段被多处读取,却没有任何写入点。
- 症状:
undefined <= 0恒 false;计数器永远停在初始值;一个功能自上线以来从未发生过一次。 - 审计法:列出该字段的全部引用点,逐点归类为「读」或「写」。写入点数为零 = 半条链。
- 判据:审计报告里该字段的写入点数 ≥ 1,且每个写入点都被一次真实触发覆盖到。
2.2 未初始化哨兵
每帧判定的冷却/计时字段,在状态字面量里漏了初值。
- 症状:
if (cd <= 0)对未定义永远不成立 → 音效、触发、重生机制全死。 - 判据:遍历状态对象的所有条目,凡是参与比较或自减的字段必须显式初始化(数值给 0,标志给 false)。
- 追加判据:断言机制真的触发,不是断言「没报错」。触发断言的形式是「首次触发前的帧数 < N」或「触发后该字段回到初值」。
2.3 默认回落覆盖断裂
读取方在取不到数据时静默换成默认值,系统「看起来正常」。
- 症状:某个方言/变体参数永远是默认值;写了三天的功能其实从没跑过真路径。
- 判据:凡有 fallback 的读取点,必须断言 fallback 没有被触发——要么比对实际取值与期望值,要么做像素级比对(渲染出的画面必须呈现该参数独有的视觉特征)。
- 写法建议:把 fallback 做成可计数的路径(命中时记一笔到诊断页),让「从未走过真路径」成为一条可断言事实。
2.4 接线认错对象
A 模块把函数挂在某个返回对象上,B 模块读的是另一个全局对象上的同名属性。永远取到空 → 静默回落(于是掩盖成 1.3)。
- 判据:维护一份跨模块接口清单(谁挂、挂在哪个对象上、谁读、读哪个对象)。清单上每一行的「挂的位置」与「读的位置」必须字面相同。
- 追加判据:每一行接口都要有一次真实触发证明生效,不能靠「读到了非空」过关(非空不等于同一个东西)。
2.5 依赖实现碰巧正确
未定义行为或灰色写法,在当前实现上恰好表现正常。
- 症状:着色器插值函数的边缘字面量倒置 = 未定义行为;当前软渲染恰好按反向斜坡实现,换个驱动就是硬跳变(玩家截图里是一条屏幕中线)。
- 判据:把「未定义行为的当前正确」判为不正确。凡是这类写法,必须加源码 lint 而不是靠一次目视通过。
- 推广:任何「靠某个具体驱动/平台恰好如此」的正确性,都要写进注释并配一条 lint 规则,否则换环境必然回归。
2.6 正面证据原则
「没报错」= 不合格证据。 任何「功能存在」的断言,必须由一次真实触发支撑:音效响过、贴图渲染出了正确的形制、计数器变红、灯强度按预期曲线变化。
- 判据:把断言从
assert(flag === true)改成assert(counter === 3)或assert(pixelsAt(120, 88).r > 200)。 - 反模式:
assert(!consoleErrors.length)单独存在时,这条断言只能证明「没有抛异常」,不能证明功能存在。
3. 测量先于调参
3.1 把现象写成可测量的量
「调参式修 bug」到此为止。凡是「某个地方很黑 / 某个地方不响 / 某个东西看着不对」的反馈,先把现象写成一个可测量的量,再改任何参数。
- 判据:每条「某处 X」类反馈在开工前必须产出一个可测量的量,四选一:数值表 / 像素比对 / 计数器 / 时间序列。写不出这个量,说明还没理解问题,此时任何参数改动都是在赌。
3.2 亲历:手电「照不亮」
手电被反复调参三轮都无效。根因是光照衰减函数在设定距离处直接归零——大房间 10m 外必然死黑,与参数大小无关。
- 把「照不亮」写成一间房八个方向的照度表之后,根因才现形。
- 改完实测:各房近中距墙照度从
0 ~ 0.41变成0.40 ~ 0.78。
教训记成一句话:照不亮是分布问题,不是强度问题。 先画分布,再动标量。
3.3 同类:先量再砍
- 脚步声「物理性地疼」→ 先量峰值、步频、重叠密度三个量,再砍参数。
- 屏幕正中一条竖线 → 先量中线两侧像素,才抓到两个独立缺陷叠加:插值边缘字面量倒置 + 混合函数参数反了。
判据同 §3.1。这两个案例的共同结构是:两个缺陷叠加时,现象只有一个。所以量必须能区分「一个原因」与「多个原因」——选择正交观测量(例如同时看竖线位置与左右亮度差)。
4. 取证七式
4.1 判决实验:不要猜因果,构造只差一个变量的 A/B/C
构造一个只有一处变量不同的三格页面,让三种假设各自对应一格的画面。
- 亲历:加第三格(关掉后期 / 把两个 uniform 钉死)来区分「光锥根本不落地」与「后期把亮面压黑」。两格不足以区分两个假设。
判据:三格页面必须在同一机位、同一场景状态、同一时刻出图。
4.2 同机位 A/B
修复前/修复后必须同一机位、同一状态出图一对。只有相对比较能定位,单张图不能。
判据:把机位写成可复现的显式参数(坐标 + 三个朝向角 + 时间点),而不是「随便站一下」。断言:两次采样的机位参数逐项相等。
4.3 像素级断言优于存在性断言
不断言「有图」,断言「三种纸的底色梯度、刻痕的钉孔暗点、无中线的月牙、红章的像素」。
- 判据:每个生成类资产至少有一条断言读像素值,且该像素位是该资产独有的(不是任何图都有的公共色)。
- 理由:存在性断言对「渲染了错误的东西」完全免疫。
4.4 枚举与聚类
外观一致性问题(例如十间房全用同一张贴图)靠逐点检查抓不到。做法是枚举全部实例,对像素做颜色聚类,看是否出现预期的多种取值。
- 判据:把「应该不同的 N 处」写进断言——聚类结果的簇数 ≥ 2,且每一簇都能对应到具体实例编号。
- 这类断言专治静默短路(见 §8.6)与默认回落(见 §2.3)。
4.5 写入间谍抓栈
给可疑字段装属性写入拦截器,抓到调用栈。
- 亲历:坐标在敌人激活瞬间变成非有限值,用间谍一次抓到带调用栈的非法写入点。
- 判据:间谍常驻于诊断页,且把「最后一个写入栈」渲染在页面可见区域(不是控制台)——见 §7.7。
- 提醒:拦截器本身有性能与递归代价,只在诊断页常驻,不要放进玩家路径。
4.6 先热身再测泄漏
资源计数在镜头第一次看向某区域时会跳一次(首次上传/编译),长得和每帧分配一模一样。
- 正确测量形状:静置采样(必须平)→ 每个动作各来一遍 → 重复 N 次 → 分相采样。
- 判据:静置段的一阶差分全为 0;动作段的每步增量与重复次数之间成线性(不是台阶式上升)。
- 教训:测泄漏必须先热身。 未热身的数字只能证明「有东西被分配过」。
4.7 量到「机制失效」之前,先怀疑探针
坐标系与状态假设错误会把正确实现判成失败。清单见 §5,逐条对照,不要凭「这个机制显然不会失效」下结论。
5. 探针假象清单
以下每条都真实发生过。共同点:把正确实现判成失败,或把游戏 bug 判成探针问题。
坐标/俯仰角的符号约定与直觉相反。 本项目「正 = 抬头」,按相反假设摆姿势会一直看地板,据此误判核心机制「永不触发」。纪律:库的符号约定与直觉相反时,先用一个已知答案的姿势标定,再批量测。(标定方法见 §0.4。)
摆完姿势忘了刷新相机矩阵 → 射线全部落空。纪律:设完朝向必须显式提交矩阵更新,再打射线。
上一轮测试的剧情副作用污染本轮。 亲历:测试触发的剧情把角色抓死了,导致后续断言全在死亡态下跑。判据:每轮开始前断言角色处于标准初始状态(位置、朝向、生命状态、持有物)。
页面被重载(渲染进程崩溃) → 所有测试结果作废。关键在于症状不是「测试失败」,是**「测试根本不在你以为的进程里跑」**:结果文件的修改时间停在上一次运行。判据:结果里必须写入本次运行的启动标识与墙钟时间,跑完后重新读文件确认标识变了。
渲染循环被节流或冻结。 验证方法不是猜,是看游戏内时钟是否推进。真因见 §7.6(后台标签的动画帧回调被冻结:定时器正常,但渲染循环不跑)。判据:断言分「两次采样之间帧计数在涨」与「游戏内时钟与墙钟的差值在容差内」两条。
探针自身的坐标系错误被记成游戏 bug。 纪律同 §5.1。
假「干净」:验收环境若是「无用户手势」启动模式,音频层实际是空操作。 于是所有音频 bug 在全部断言里隐身。亲历:三个真机必炸的音频雷(音名表缺一个音、某个音效调用传参错、非有限值直进音频 API)藏了 12 轮,因为全部验收页都是空音频跑。
- 修法:常驻一页「全库唯一真开音频跑」的穷举页,把所有音频调用点按真实参数各打多遍,含非有限值输入。这页与常规页物理分离,不允许「为了跑得快」关掉它。
- 判据:穷举页必须断言「至少有一次真的发出了声音」,判据本身不能依赖被测代码。
假「回归」:验收假设撞上了设计行为。 亲历:根因修好后 soak 页报失败,但那不是回归——以前因为数值非有限她从不移动,soak 页从来没被真正追上过。修法:给验收页补死亡复位,不是回退修复。判据:soak 页每轮开头必须把角色重置到可动状态。
假「全绿」:只看逻辑状态的端到端断言,漏掉「界面盖住了演出」。 演出期间逻辑状态本来就是暂停,而暂停面板本不该出现。
- 修法:界面可见性状态本身要有断言(层是否处于显示态),不要只看逻辑状态。
- 判据:每一条涉及演出/暂停/结局的断言都必须成对出现——逻辑状态断言 + 界面可见性断言。
固定时刻断言会与游戏自身的节律相撞。 亲历:「按住 1.2 秒判某状态」撞上自发的眨眼节拍 → 间歇性失败。修法:改成采样窗口断言(「窗口期间任一刻为某态即通过」)。
速率断言要除以真实耗时,而不是固定 sleep。 机器负载会拉长 sleep,除以固定值会让速率假性跳变。判据:断言写成
count / (t_end - t_start),且t_end - t_start落在期望区间内,否则这条断言作废(记为 INCONCLUSIVE 而不是 FAIL)。单帧敏感的机制测试要拆成独立的全新会话页。 长会话下帧饥饿会吃掉读数点;逐帧重触发取峰值只是缓解,隔离才是根治。判据:单帧敏感项一律单独一页,每页只测一项。
把渲染桩掉会让依赖循环刷新的断言永远读到旧值,表现为「清空了但数字没变」。修法:纯逻辑页停渲染提速是对的,但要在页头分工显式写清哪些断言依赖循环刷新、哪些不依赖。判据:停渲染的页只允许包含不依赖每帧刷新的断言。
重载类断言用两个同源 iframe:第二个 iframe 开机自然读取存档,全程不导航(避免导航丢失状态)。循环活性用「两次采样之间帧计数在涨」判定,不用绝对帧数——两局并存时 CPU 是分的。判据:活性断言写相对量。
shot 取景装置必须钉住演出计时,否则装置自己的清理逻辑会先把演出走完,取到的是演出结束后的画面。判据:取景装置在 begin 之前把演出时钟置为已知值并禁止推进。
不要把临时探针当结论。 「我能证伪所有猜测,但没能复现你那一帧」——如实说出来,比假装修好了有价值得多。判据:探针输出里区分 PASS / FAIL / NOT_REPRODUCED 三态;第三态必须显式出现,不许折叠成 PASS。
单帧计数与绘制计数要分开测。 前者递增、后者冻结 = 每帧抛异常把渲染吃掉(画面静止但心跳还在)。只看其中一个都会误诊。判据:两条断言并列写,输出里分别打印。
6. 验收基建契约
6.1 每页写机器可读的结果
把 JSON 写进页面(写进 DOM 或标题),跑完再抓出来。见到错误列表直接判 FAIL。失败断言连实测值一起打出来。
- 判据:结果 JSON 含
errors[],每项带name、expected、actual。抓取脚本在errors.length > 0时非零退出。 - 不要只输出「FAIL」字符串。实测值是后续取证的原料,丢了就得重跑一遍现场。
6.2 把「通过」当提交门
不通过就不提交。这条当场拦下过「启动即黑屏」一类缺陷——它是静态检查查不出的那类(见 §8.7)。
6.3 未证实的改动不提交
有两次探针写错,把正确实现判成失败、把空操作当成修复,全靠这条拦住。
判据:提交前必须有一条在改动后新跑过的断言记录,且该断言覆盖了改动触及的行为。
6.4 改动先过主回归页,再按影响面追加
| 改动类型 | 追加的页 |
|---|---|
| 房间 / 门禁 | 可达性页(地图洪水填充 + 逐交互物射线) |
| 演出 | 相机页(机位参数 + 界面可见性断言) |
| 状态锁 | 冻结页(状态↔界面一致性 + 持有预算) |
判据:每次提交能指出「这次跑了这几页」。说不出影响面的改动视为影响面未知,按最大集跑。
6.5 临时探针 → 实证 → 修 → 转正常驻回归页
这是标准动作链。临时探针不允许留在仓库里过夜。
判据:诊断/临时脚本目录在提交前为空,或每个残留文件带一行注释说明它服务于哪个未完成调查。
6.6 断言跟着设计走
设计改了(例如某个姿态不再回充),旧断言就是过期文档,必须同一次提交里改。留着旧断言等于制造下一次误判——它会持续报错或持续放过。
判据:断言页里每条断言带一行注释,写明它锁定的是哪条设计约定。设计文档改动提交里若含断言页未同步,CI 报错。
6.7 文档与实现里的硬编码数字对账
加一份可收集物必须同步设计文档与商店文案。做法:用一个 lint 页做持续集成闸——它枚举实现里的所有硬编码数字,与文档声明的集合做双向比对,两边不等即 FAIL。
6.8 派生产物与源码逐条对账
提交数、页数、条目数这类数字逐条重核。十轮逐段追加留下的旧口径会与代码并存,读文档的人分辨不出哪条是当前的。
6.9 正文只写当前状态,历史进附录
否则读者要自己分辨哪段是旧方案。文档结构见 §10。
6.10 时间纪律
- 把单页墙钟上限写进脚本。亲历纪律:单页不许超 3 分钟,1–2 分钟为宜。超时即设计缺陷——它意味着这一页在验收「跑得比玩家流畅」这件事上不成立,去改页而不是提高上限。
- 日常增量跑按改动映射受影响页(§6.4),全套留到发行前。
- 后台任务必须看着跑完,或连壳一起杀干净。 孤儿 shell 会继续把验收整套拉起来,把 CPU 顶满,让后续所有测量值失真(速率类断言尤其敏感)。
6.11 派生物不能替代源头
把「打包产物」当成交付物之前,先核对它是不是当前代码的产物。亲历:差点上传了修复前的旧包。
- 判据:把「打包产物与当前代码同源」做成一条断言或一个流程步骤(见引擎附录 §E.2 的具体形态)。
- 推广:任何派生物(截图集、关卡数据导出、报告)都要能回答「这是哪份源产出的」。
6.12 不要在验收里 mock 掉你要测的东西
这一条是 §5.7 与 §5.13 的上位原则。判据:验收页头注释列出「本次 mock 了什么」,且被 mock 的对象不得出现在本页断言覆盖范围内。要测的东西必须以真实配置运行,哪怕整页因此慢三倍。
6.13 分期实施与可回退提交
一轮工作切成若干期,每期只担一件事;每个关键改动单独成一条提交,可 revert。
- 推导:改地基(数值曲线)与改表现(阴影、剪影、镜头)混在同一批时,返工无法二分定位——只能整批推翻重做,因为说不清是哪一类改动把症状带进来的。这条推导决定了分期的粒度:粒度按「根因类别」切,不按「工作量」切。
- 顺序:每个改动之后先过主回归页,再提交;未过不提交(§6.2)。派生提交不能替代源头改动(§6.11):修了源头却只提交了重新生成的产物,等于没修。
- 判据 (a):一批提交里若有一条同时改了数值与美术,拆成两条。
- 判据 (b):一轮收尾能说清「这一轮可以 revert 哪几条」,说不清的视为边界含糊,下一轮还要重来。
6.14 调研前置
动手前先给调研结论,结论必须带可核查来源,并且直接推出取舍。
- 推导:一段没有落点的「业界做法」总结不改变任何决策行为,因此与没调研等价。调研的价值不在信息量,在于它排掉了哪个选项。
- 有效形态:「某来源说 X → 所以此处必须写成 Y 而不是 Z」。来源指得到位(能定位到具体出处或转述路径),取舍指得到位(Y 与 Z 是两个具体写法)。
- 推论:交付里要显式写出「我替你拍的、和你原话不一致的决定,以及原因」。不写这一段,对方无法发现自己的需求被改写,只会在验收时突然反弹。
- 判据:动手前的方案文档里每条设计决策都能指到来源,或指到某次实测;指不到任何一项的条目删掉或补调研。
7. 卡死韧性:先有诊断学,再谈防御
7.1 统一形状
所有卡死的形状都一样:状态把输入或画面接了过去,而释放只写在顺利那条路上。
写每一处接管时,把释放写成两处:正常路径一处,异常与超时路径一处。
- 交付目标不是「找到那一行」,而是让这一类缺陷不再等于死路。这是本节的验收标准:新增一个接管状态时,缺陷后果应从「永久死机」降级为「一次可记录的降级」。
7.2 四道网
- 锁预算(持有预算):任何「接管输入/画面」的状态必须有超时预算——演出、过场、倒计时、阅读界面各自一个秒数;超时强制交还并记一条泄漏账。
- 判据:看门狗从不触发才是通过条件。触发说明有 bug 被糊住了,需要追账而不是调大预算。
- 停摆看门狗(状态↔界面一致性):世界只在若干状态冻结,每一种都必须有一层界面盖着;缺了就立刻掰回(阅读状态却没有阅读层、暂停状态没有面板、死亡卡超过时限还没挂上、正常状态却连续多帧一帧都没画)。
- 判据:玩家看到的「停住的世界 + 什么都没有」有硬上限(如 1.6 秒)。这条上限要有断言:停摆起点到界面出现的间隔 ≤ 上限。
- 自救键:一个无条件、不丢进度的恢复动作——强制交还控制、收掉过场与窗口、关掉所有覆盖层、回到现场、重新接管输入。每次使用记账。
- 判据:自救后必须能继续玩到本局结束(一条 soak 页跑完自杀后的完整流程)。
- 逐帧子系统熔断:每帧各子系统单独异常隔离;连续 N 帧都炸的那个本局停用(世界降级、画面继续画),肇事者与报错写进诊断页并跨刷新留证。渲染层自身连炸则留证后整页重载一次(防循环)。
- 教训:任何未知的持续异常只能让游戏降级,不能让它冻结。 判据:熔断发生时,帧计数仍在涨、绘制计数仍在涨(§5.17)。
7.3 附加保险丝
任何原因导致画面全黑超过 T 秒,自动交还演出、淡入淡出、亮度倍率等所有接管项。黑屏是最劝退的死法。
判据:全黑超时的判据用「绘制计数停止 + 界面层全部不可见」,不要用「画面是黑的」这种需要人眼或像素分类的间接量。
7.4 看门狗豁免的写法纪律
豁免必须盖住整条冻结窗口,不能只盯主循环那几帧——窗口中段常有「两个哨兵值都为空」的空窗。
判据:豁免条件写成清单,逐个对。清单里出现「暂时为空」这类描述时,视为未覆盖。
7.5 循环外必须有安全网
上面四道网全在主循环内部。主循环挂死时它们一起陪葬——修的位置注定够不着这一类。
- 在循环外(独立线程或独立计时器)做心跳监测:主线程沉默超过 N 秒就落盘检查点并重载。
- 判据:能扛住「主循环完全挂死」这一形态才算覆盖完整。测试方式:注入一段无限循环,看外部监测是否触发。
7.6 时序回调必须可取消
所有延时回调(用定时器串起来的演出链)首行查状态,状态不对就直接返回。演出链要带代币,作废时整链掐断。
- 亲历:约十处回调缺守卫,后果是走镜半程被抓时把死亡黑场改写掉、把角色传进隐藏房间;结局链的定时器从不作废,回检查点后旧链把上一张结局卡的内容漏进新卡。
- 判据:每条延时回调链能指出它的代币字段与作废入口。指不出的视为未加守卫。
- 后台标签的动画帧回调被冻结(定时器正常但渲染循环不跑)这类外部原因,见 §5.5:判据是游戏内时钟是否推进。
7.7 诊断页要自己说话
玩家不会开控制台,但他们会截图。所以把下面这行直接印在暂停页与结局页:
这一夜画面停过:锁未交还 N 次 · 停摆自愈 M 次 · 异常帧 K · 自救 R ·
熔断了哪些子系统 · 最坏一帧发生在哪个房间
判据:下一次用户报障时,截图里已经带着答案。这条判据是可检验的——把诊断页截图给一个没开过控制台的人看他能否说出问题类型。
8. 数值工程纪律
以下每一条都对应过一次玩家可见的崩坏(NaN 黑屏、卡死、贴图全同、灯光瞄错地方)。
任何喂给外部 API 的数值先过有限性闸门。
Math.min/max钳制防不住 NaN(Math.max(0, NaN)仍是 NaN)。一个 NaN 会顺着调用链永生,直到撞上某个抛异常的 API,或把物体变成「看不见的幽灵」——表现为永久冻结。- 判据:可疑字段装写入间谍;对所有外部 API 参数做有限性断言。
「数值插值追目标」的字段初值必须是数。 未赋值 + 目标是数 = NaN → 全链路 NaN → 永久黑屏且自我维持。判据:所有插值字段在状态字面量里给数值初值。
跨数据形状传值时把字段名念一遍。 数组
[x,z]与对象{x,z}混用(写成best = c后按best.x读)= 激活即 NaN,这是藏了 12 轮的真机卡死元凶。- 判据:回归页断言产出是有限值且真的动了(位置在两帧之间变化)。
- 纪律形式:写这类赋值时,把两侧的字段名逐个念出来对(念不出来就是形状不一致)。
灯光不要挂在会被可见性切换的组里。 灯进出场景会导致全材质着色器重编译,肉眼可见的卡顿/冻住。用「强度 = 0」表达「不在场」,灯挂场景根。
把变量挂到场景根之后,所有还写着「本地偏移」的旧行语义都会变。 修完必须把语义重新推一遍并补几何断言。
- 亲历:灯挂回根后,本地摆动被当成绝对坐标,光束一直瞄着地图西端,而她在东端巡逻。
- 判据:给光束朝向写一条几何断言(方向向量与目标点差值 < 容差)。
搜索循环的哨兵初值必须以空值起步。
let ch = 'v'+!ch短路 = 循环体从未执行;十间房 45 个提交全用同一张走廊贴图,逻辑「看起来全对」。- 判据:这类静默短路只有枚举/像素断言能抓(§4.4 的颜色聚类)。
静态检查查不出的两类:暂时性死区(文件前半段的代码调用了后半段才声明的常量 → 启动即黑屏,已崩多次,
node --check完全查不出,必须启动一次);数据形状错位(§8.3)。- 判据:把「启动一次」做成提交门(§6.2),不要指望语法检查。
同一状态的多个初始化点会各写各的。 同一机制在开局/教程/重生/截图模式下有多个初始化点且各写一套值 → 语义漂移。判据:把初始化收口到单一出口;断言「进入该状态的入口只有一个」。
9. 真人才能给的东西(明确划界)
数值、档位、链路、像素全部自动自证。但这些只能由人给:
- 开局手感与劝退度(自动化测不出「第 3 分钟开始无聊」)。
- 混音是否刺耳(机器测不出「刺不刺耳」——峰值达标仍可能难听)。
- 隐藏层的发现率(统计能给出命中率,但「玩家为什么没找到」需要人看录像)。
- 演出是否有破坏注意力的地方。
准则:每轮收尾都要留下一个明确到「下一把该谁做什么」的清单,把「留给真人」的两三项点名。 判据:清单里每项写成「现象 + 已量到的部分 + 待人判断的部分」,不写「再体验一下」这种无主语条目。
10. 交接文档该长什么样
这一节是可迁移资产,写细。目标:让下一个接手的人(或下一个会话的 agent)不必重新分辨哪句话是当前的。
10.1 首句定调,只写现状不写愿望
开篇写死:「这份文档只写当前真实状态,每条数字都重新核对过代码与命令输出。」
判据:全文搜索不到「应该」「计划」「接下来要」这类未来时表述(作为断言清单的说明除外)。
10.2 权威性分层,并显式写出来
正文是当前唯一权威版本;历史轮次(含被推翻的)全部收进附录。文档间冲突有固定优先级,并在发现冲突时顺手改对。
判据:读者不必分辨哪段是旧方案——正文中不存在旧方案。
10.3 把阅读顺序写成契约
开头的验收清单 + 关键数字摘要 + 一句提示:「正文里的『第 N 轮』只是在说这件事是哪轮定的」。
10.4 每个数字都带来源与取证方式
机制数值标注实测值 + 断言页名。文档里反复出现的纪律句:「这条是量出来的,不是设计愿望,改之前先跑某页」。
判据:抽查任意一个机制数值,能在两跳内找到对应断言页与该页的输出。
10.5 把决策历史与推翻记录当资产保留
每条被推翻的设计都写清「为什么被推翻」,并提炼成教训。亲历教训样例(这些是本领域的高频回归点):
- 默认值是可玩性问题,不是实现问题。
- 测泄漏必须先热身。
- 量到机制失效之前,先怀疑探针的坐标系。
作用是让后来者不走回头路。
10.6 坑清单按类分组,写清判别条件
不要写「注意 X」,写成「看到 Y 现象 → 是这个坑 → 判据是什么」。并单独标注「哪些坑只有靠某类取证才抓得到」(像素枚举 / 截图 / 长会话压测 / 真实音频穷举)。
- 判据:清单每项都能回答「我凭什么认出它是这个坑」。