外观
05 · 坑与复盘
这一章是完整清单。挑出来的标准:编译通过、跑得动、结果是错的。
一览
| # | 症状 | 根因 | 类型 |
|---|---|---|---|
| 1 | 判断永远为假,整段效果被跳过 | 无 context 的成员调用,接收者发成 Val.Nothing | 调用约定 |
| 2 | 整张卡抛 KeyNotFound | 局部变量直读 L["x"];Kismet 局部槽是零初始化 | 语义差异 |
| 3 | 条件判断走反 | 宿主规则回调只回 bool,函数返回值被丢掉 | 返回值通道 |
| 4 | 遍历型卡拿不到元素 | Array_Get 走 out 形参,宿主没回写 | 返回值通道 |
| 5 | 卡面文本变成类型名 | EX_TextConst 要按 TextLiteralType 取字段 | AST 细节 |
| 6 | 栈溢出 | 依赖蓝图只当签名表、没转译 | 架构 |
| 7 | 栈溢出(第二类) | 转发壳:A.Foo → B.Foo,B 没有就绕回 A.Foo | 分派 |
| 8 | 编译报 CS0131 | SetArray/SetSet/SetMap 的属性是赋值目标,被当右值发射 | 发射器 |
| 9 | 原语读到错位的参数 | a[0] 是接收者,实参从 a[1] 起 | 调用约定 |
| 10 | Intel 数值永远为 0 | 语义理解错(三层,见下) | 数据模型 |
| 11 | 触发点"61/61 完整"是假的 | 静态审计把死代码算进去了 | 审计方法 |
| 12 | 未实现调用静默变成 0 | 容错设计本身是最大的错误源 | 设计取舍 |
分类看
A 类:调用约定 —— 最容易反复踩
四条坑(#1 #3 #4 #9)是同一个根源:KARDS 的"返回结果"有两条通道, 参数数组的 0 号位是接收者。
这类错误的特点是:表现为"值不对"而不是"崩了"。
- 接收者错 → 判断永远为假 → 效果静默跳过
- out 没回写 → 拿到
Nothing→ 当 0 用 - 返回值丢了 → 条件反转
- 偏移错位 → 参数全错
防御办法只有一个:写原语时逐个对照 UHT 签名。 void SpawnCardInHandBySide(ESideEnum side, FName card_name, ...) → 那就一定是 a[1] 和 a[2],不能凭感觉写 a[0]。
B 类:源语言语义差异(#2 #5 #8)
Kismet 不是 C#,有些默认行为必须显式补齐:
- 局部槽零初始化 →
GetLocal(L, "x")而不是L["x"] EX_TextConst是结构体 →Value.ToString()得到的是类型名, 要按TextLiteralType分支再解包SetArray的属性是赋值目标 → 不能当右值发射,得走Assign
判断标准很简单:凡是"看起来像字面量/变量但其实有内部结构"的 AST 节点, 都不能直接 ToString()。
C 类:架构(#6 #7)
两个栈溢出都是"看起来能跑"的:
- 依赖蓝图只当签名表 → 宿主按名字找不到 → 退回同名转发壳 → 绕回自己
- 单例之间反向指针没建全 → 同上
为什么栈溢出比崩溃更糟
Stack overflow 会直接把进程带走,没有异常可 catch, 自对弈跑到第 300 局才炸,前面的结果全丢。
所以除了修根因,还加了同名调用深度防护(连续 8 次即截断并记为未实现)。 这是纵深防御:根因可能还有没发现的,但至少不会带走进程。
D 类:审计方法(#11)
这一条我认为最值得记。
我写完 43 个触发点包装后,静态审计报告"61/61 完整" —— 那是假的, 因为其中 23 个包装函数从来没被调用过,而静态审计扫的是字面量。
核心教训
单一审计维度一定会说谎。 静态覆盖看不见"从没被调用",运行期命中看不见"其实能跑"。 必须多个维度交叉,而且要去追它们互相矛盾的地方 —— 矛盾就是线索。
E 类:设计取舍(#12)
宿主对不认识的函数返回 Val.Nothing 继续跑。这是必需的(否则一张卡 能带崩整局),但也是所有静默错误的放大器。
处理方式不是去掉容错,而是让容错可观测:
- 未实现调用全部登记(函数名 + 次数 + 调用栈)
smoke把未实现当作失败返回非零,而不只是打印apicheck给出"卡牌逻辑可达的缺口 = 0"这个硬指标
三条通用原则
1. 能拿到结构化数据,就不要用文本
伪代码是人读的,AST 是机器读的。用文本做程序翻译,边界情况永远补不完。
2. 先确认源数据是对的,再动手翻译
反编译器自己的 EX_ComputedJump 边界 bug 修完,效果调用点 +32%。 如果直接拿有 bug 的输出去翻译,翻译器写得再对也白搭。
3. 能失败的断言,比能跑通的程序重要
PASS Intel 翻牌张数 = cipher ← 功能对不对
PASS Intel 不改对手手牌顺序 ← 没破坏别的东西后一条往往更重要,也更容易被忘。
复盘:当初哪里想错了
| 想法 | 后来发现 |
|---|---|
| "读卡面文本解析效果就够了" | 只能覆盖简单卡,且永远有偏差。必须转译 |
| "反编译伪代码可以拿来翻译" | 有损渲染,边界丢信息。必须走 AST |
| "触发规则得自己手写" | 游戏自己就有完整的触发分发表(63 个 Execute*Events) |
| "自对弈跑通就说明接完了" | 随机采样抽不到那些卡。要逐卡强制演练 |
| "静态覆盖 61/61 说明接完了" | 把死代码算进去了。要运行期实测 |
| "Intel 是个布尔标签" | 是数值字段 cipher,链路横跨三层 |
最后一条尤其值得记:我在这上面连错三层,每一层都是"看起来合理"的。 数据模型猜错比代码写错更隐蔽 —— 代码错了会崩,模型错了只会一直算错。