五十七个提交,关键词是同一个:持久化
上一篇讲物品实例,结尾我说存档是”同一类问题的另一面”。
这篇把它展开。
先说个数字:给项目的提交历史按类别数的时候,围绕持久化的提交有 57 个——其中 28 个是修复(fix: persist… / fix: save… / fix: reload…),剩下 29 个是这些修复各自的记录提交(docs: …,项目规矩是每个修复都配一份证据文档)。
57 个什么概念?我数过配方,全游戏现在也就几十条。这个数量说明一件事:存档不是”一个功能”,是几十次事故。 而且它还在长——写这篇的时候,最近一条持久化修复还是今天的。
一、为什么存档是重灾区
Outland 是一个”一直在模拟”的游戏:体温在变、冷却在走、火在烧、NPC 在走动、魔变在累积。
这意味着状态散落在引擎的每个角落,而存档要做的事,是把散落的状态在某个瞬间全部”拍”下来,读档时再”还原”。
问题在于:散落的状态里,有一类你自己都不知道的。
它们有个共同特征:不是”状态”,是状态之间的差额。我叫它余数。
- 冷却还剩 37 秒——这 37 秒是累加器里还没到 100 的那部分
- 尸体腐烂进度走到 55%——55 是累计器还没满一个周期的部分
- 消化还剩一半——代谢累加器里没结算完的那截
整数部分好存:它是”结果”。余数难存:它是”过程”。 你存档的时候,脑子里想的是”现在冷却值是 999”,但真正决定下次行为的,是”这 999 里有多少是这次行动攒的、多少是上次留下的”。
二、三个典型事故
1. 余数不进存档,读档等于偷时间
最典型的一批 bug 都是这个:
同样 10800 秒,连续等待,尸体腐烂度 decay = 5;分三段 3600 秒、中间存读档,decay = 0。
同一个游戏时间,走法不同,结果不同。
原因:周期结算的累加器(“攒了多少秒还没满一个周期”)没写进存档。每读一次档,攒到 3599 秒的那部分余数就清零一次——三次存读档,等于白送了将近 10800 秒里的所有”零头”。
更隐蔽的变体:
同样 100 秒,一次施放法术,冷却 = 999;分两次 50 秒,冷却 = 1000。
1 秒的差。这种差不会崩溃、不会报错,它只是让”存档续玩”和”一直挂着玩”变成两个略微不同的游戏。
修法就一条原则:每个累加器,余数必须和整数部分一起进存档。 后来存档里多了一组专门的字段,把七种 100 秒周期和四种特殊周期的余数全存下来。
2. 整数状态存得下,浮点余数存不下
代谢系统更细:热量、水分、胃容量这些池子,整数部分早就进存档了——但小数部分没有。
实测:同样 35 秒,一次走完,热量池 = 2999;17 秒存一次档再走 18 秒,热量池 = 3000。
1 个热量,听着无所谓。但它暴露的是结构性问题:存档格式里只有整数槽位,六个池子的小数余数在格式里根本不存在。
修法是把六个浮点余数显式加进存档对象,并且——这是重点——读档时对它们做严格校验:
- 不是对象 → 拒
- 字段不是数字 → 拒
- 不是有限数(NaN/Inf)→ 拒
- 负数 → 拒
- ≥ 1 → 拒(余数的定义就是”不足一”,≥1 说明这个值已经不是余数了)
拒绝,不是修正。 读到坏值就抛异常,原档文件保持不动,而不是”帮你 clamp 一下继续跑”。
为什么这么硬?因为存档是玩家唯一的数据。一个被”智能修正”过的坏档,玩家永远不知道自己丢了什么;一个被明确拒绝的坏档,至少玩家知道”这一档读不了,但还在”。
3. 状态不随快照走
第三类是空间维度的:状态跟着”世界”走,但存档只拍了”玩家”。
Outland 有八个世界,切世界时引擎会拍一张世界快照再还原。事故长这样:
- 火场:火的演化状态不随世界快照 → 切走再切回来,火全没了
- NPC:不随快照 + 还原时不清理旧实体 → 切回来 NPC 重复出现,或者消失
- 载具:实体坐标不随快照 → 切回来,载具还停在上一个世界的坐标上
这类 bug 的共同点:单个世界里玩完全正常,一切世界就出鬼。 所以它们藏得特别久——不切世界的人永远看不到。
修法是给世界快照补上对应的状态段:火场状态、NPC 状态、载具状态(含部件网格),存档序列化时一起走。
三、一条验证方法:存读档不变量
修这类 bug 靠”多存读几把试试”是不行的。余数 bug 的窗口期可能就几秒,手测根本撞不上。
后来固定下来的验证方法是存读档不变量:
同一个输入、同样的总时长,分「连续跑」和「分段存读档跑」两条路,终态必须逐位相同。
比如代谢那批:
- 连续 35 秒 vs 17 秒 → 存 → 读 → 18 秒
- 两个进程、独立存档,最后比较六个池子的值
- 整数部分完全相等,浮点余数允许 1e-6 容差
再比如周期结算:7200 秒,分别用「一次」「两段(存读档)」「三段」推进,冷却、回合数必须逐位相同。
这条不变量现在是一个独立门禁。它的好处是把”余数”从直觉问题变成了断言问题——不用理解累加器的内部,只要”存读档前后世界没变”,就对了。
四、旧档兼容的两条纪律
改了存档格式,旧档怎么办。项目里定过两条纪律,都是被事故逼出来的:
1. 旧档缺字段,默认 0——但不要用时钟去”猜”已经丢了什么。
比如旧档里没有代谢余数,读档时把它设 0 是诚实的(确实不知道)。但设成”根据游戏时钟推算你大概攒了多少”是撒谎——那个数字看起来合理,实际是编的,而且会污染后续结算。宁可少算,不要猜算。
2. 部分失败不是正常继续。
存档读了一半发现某个段坏了(比如余数对象不合法),不能”好的部分先读上、坏的部分跳过”。那会让玩家带着一个半真半假的世界继续玩,后面所有的”为什么数值不对”都无从查起。正确做法:整档拒绝,原文件不动,告诉玩家哪一段坏了。
五、和上一篇的关系
物品实例那篇说过,存档是”同一类问题的另一面”。现在可以说得更具体:
- 物品实例是”个体状态”没建模 → 操作错了对象
- 存档是”过程状态”没建模 → 时间被偷了
两者是同一个病根:你以为模型里的字段就是全部状态,但”还没结算完的那部分”永远不在字段里,直到你专门给它一个槽位。
这也是为什么这类修复有 57 个而且还会继续有——每加一个新系统,就会新造一批累加器,每批累加器都欠一次”余数进存档”的债。 好在现在有那条不变量门禁兜底:新的余数欠债,门禁会先于玩家发现。
下一篇想写多 agent 舰队。这个项目有相当一部分代码是 agent 干的,而且同时有多条线在跑——那套调度、额度、交接文档,坑比写代码本身还多。