← 全部文章

世界要能被「拍照」

存档那篇结尾留过一句话:状态不随快照走,是存档三大事故之一。这篇把”快照”这件事展开。

Outland 有八个世界,玩家切来切去。引擎在切走前必须把当前世界的状态整体拍下来(capture),切回来时再原样还原(restore)——火场烧到哪了、NPC 走到哪了、载具停在哪个坐标、尸体腐烂到什么程度、陷阱是否已触发、家具放没放、怪物身上的 JSON 状态……一样都不能丢,也不能凭空多出来。

这篇讲两件事:快照代码是怎么从散落状态抽成独立模块的,以及这个过程中踩的几个坑。

一、为什么要抽

快照逻辑最初散在 game.cpp 里——主文件当时已经有近三万行,快照的捕获、序列化、恢复逻辑穿插在各种世界切换的代码之间。这带来两个问题:

  1. 改不动:修一个”切回来火没了”的 bug,要在三万行里找到捕获和恢复两处对称的代码,确认它们字段一致。
  2. 测不了:快照行为没有独立的测试面,只能靠”切世界玩几把”这种手测。

抽法很克制:新模块 world_snapshot_entities(cpp/hpp),每次只抽一类实体,抽完过门禁,再抽下一类。抽取顺序按依赖从浅到深排:世界实体 → NPC → 车辆 → 尸体 → 陷阱 → 家具 → 怪物 JSON →(下一个)共享物品状态。

每个抽取单元的改动面被锁死在四个文件:game.cpp(删散落代码、调新模块)、模块的 cpp/hpp、一个新的专用门禁脚本。别的文件一律不碰。改动面小,review 和回滚都便宜。

二、验证方法:OLD/NEW 原始存档字节相等

快照抽取最怕的不是”功能坏了”,而是”行为悄悄变了”——抽完代码,快照内容差一个字段,切世界后世界就静默地不一样了。

这类 bug 用断言很难写(你不知道”正确的世界”长什么样),所以用的方法很笨但很硬:同场景跑两遍,旧代码存一份档、新代码存一份档,两份存档的原始字节必须完全相等。

每个抽取单元配一批真实场景(十几二十个),场景里把该类实体的状态铺满——尸体摆成不同腐烂度、陷阱触发与否、家具各种摆放——然后 OLD/NEW 各跑一遍,逐字节比存档。字节相等,说明抽取没有改变任何持久化行为。

在此基础上,每次还要跑无筛选全量套件(当时接近两百步,单轮实测跑 1.5~2 小时),确认抽取没有伤到别的地方;最后做一次独立闭合校验,对整个存档树算 SHA 留底。

三、坑 1:GPU 驱动让全绿套件变红

家具快照那轮,全量套件第一次跑挂了:跑了 7700 多秒(比正常一轮还慢),状态页的像素断言失败,而且1280×720 的截图整张全黑——18 项保护检查只过 15 项。

全黑截图 + 像素断言失败,第一反应应该是”渲染没出图”,而不是”我的快照逻辑错了”。排查路径是:

  1. 确认 exe 和门禁都没变(SHA 对得上)
  2. 用软件渲染(SDL_RENDER_DRIVER=software)单独跑渲染相关门禁 → 18/18 全过
  3. 用软件渲染从头重跑整条全量套件 → 5600 秒,全绿

结论:硬件渲染路径在那台机器上有驱动层问题(根因没最终定论),软件渲染下逻辑完全正确。

这条坑的教训值得单独记:渲染相关的门禁变红,先切软件渲染复跑,区分”逻辑错”和”驱动/硬件错”。 不这么做的话,你要么在逻辑里找不存在的 bug,要么把硬件问题当成代码问题反复修。另外注意:重跑必须从头跑完整套件,不能拿软件渲染下的单项绿 + 硬件下的单项绿拼出”全绿”——那条 V2 全绿是真实从头跑出来的,不是拼的。

四、坑 2:门禁的保护列表写死了路径

怪物 JSON 那轮,门禁的第一版跑挂了 170 多项检查里的 7/8,原因很蠢:门禁的只读保护列表(验证运行时不许碰的文件清单)里写了固定 build 路径,而实际构建目录是环境变量导出的,路径对不上。

修法是把保护路径全部改成从环境导出(BUILD_DIR_ABS 等),不再硬编码。

教训是通用的:门禁里一切”环境相关”的东西(路径、exe 位置、构建目录)必须从同一套环境导出,和被测程序用同一个来源。 硬编码的路径在换目录、换机器、换 CI 的时候都会红,而且红的样子和”被测代码错了”一模一样,很难第一时间分清。

五、坑 3:证据丢了,不能重造

还有一类坑不发生在代码里,发生在证据管理上。

有一轮,用于验证的临时目录(TEMP 里的旧存档、旧日志)丢了。处理方式是从证据档案逐字节恢复:项目里维护着一份只追加的 manifest,记录每个证据文件的原始 SHA。丢失的 108 个文件按这份清单从归档里恢复,恢复后算 SHA 和清单核对,生成一份 receipt(恢复回执)留档。

关键原则:恢复是恢复,不是重跑。 丢了旧存档,重新生成一份”看起来一样的”存档,等于把证据重造了一遍——那个新证据和旧证据在逻辑上是两回事,后面的”OLD/NEW 相等”就失去了意义。宁可恢复,不可重造;实在恢复不了,就得老老实实承认”这一条没有原始证据”,而不是用新数据顶替。

配套的纪律还有两条:

  • 保留原始 RED。 每一轮失败的原日志、原截图、原存档都留档,重跑成功也不删。后来的某次排查里,正是靠原始那份”全黑截图”才定位到是驱动问题而不是逻辑问题。
  • 准备完成 ≠ 已验证。 每轮的文档里明确区分”准备完成”(代码抽完、门禁写好、场景备好)和”实际发布”(全量从头跑过、闭合 SHA 留底、docs 推送)。下一轮的”发布父”必须指向真实发布过的那一轮,不能拿准备态顶替。

六、链式回归

每个抽取单元发布后,文档里固定写一句话:下一项是什么。 比如”家具快照已发布,下一项:怪物 JSON”,“怪物 JSON 已发布,下一项:共享物品状态”。

这个”下一项”链条看起来简单,作用很大:它把一条很长的抽取线拆成了可逐个验证的小单元,同时保证永远知道下一步是什么、上一步是否真的完成了。链条上任何一环是”准备态”而不是”发布态”,后面的环就不能启动——这比”做到哪算哪”的推进方式少了很多”我以为做完了其实没有”的意外。

七、收尾

快照抽取到现在还没结束(下一项是共享物品状态,再后面还有一整列)。但方法论已经稳定下来了:一次抽一类、四文件改动面、OLD/NEW 字节相等、全量从头跑、证据只追加不重造、发布态和准备态严格分开。

回头看,这套东西和存档那篇是同一棵树:存档解决”时间维度的状态不丢”,快照解决”空间维度的状态不丢”,两者共用同一条纪律——状态要么在模型里,要么在证据里,不能只在某次运行的内存里。


下一篇想写美术素材管线——这个项目有一大批怪物贴图是批量生成的,生成管线、验收门禁、和”资产增长门禁”反复打架的过程,坑也很典型。