hrxdev
简体中文

#2 世界、存档、回放,以及藏在存档格式里的一个 bug

· Nick

自第一篇以来,核心长出了可以放东西的世界、用来保存它的文件,以及证明两次运行结果一致的办法。依然没有图形,也没有角色。这篇的大部分内容是底层管道,其中有一部分是一个值得讲一讲的 bug。

由瓦片构成的世界

世界是由 1×1 米的瓦片组成的网格,分布在从零开始编号的离散楼层上。墙或门就是一块瓦片;楼梯将是连接两个楼层的特殊链接,但楼梯还没有建。在内部,世界是整张地图上的一组平面图层(地板、结构、结构标志),外加一个派生出来的可通行性图层。地图按 16×16 的区块追踪,每个区块带有版本计数器,这样渲染器或寻路就可以问“这片区域变了吗?”,而无需逐块比较瓦片。

世界的大小在创建时选定。建造通过接受矩形的命令来完成:在一片区域上放置地板、墙或门,或者移除它们。如果矩形的一部分被挡住,其余部分仍会建成,命令会报告跳过了什么。目前有三种内置的瓦片:地板、墙、门。从现在起,地图属于世界哈希和存档的一部分。它会拿数千条随机命令,和一个很慢但显然正确的参考模型进行对照。

存档与回放

存档和回放共用同一种文件容器。它分成带标签的小节,每节都有类型和版本,所以世界的新部分(角色、需求、关系)以后可以作为新的小节加入,而不会破坏旧文件。头部和正文都有校验和,正文经过压缩,每个大小在分配任何内存之前都会对照上限检查,因此损坏或恶意的文件无法让游戏吃光你的内存。瓦片种类按名称而不是按编号保存,所以即使种类列表变了,存档仍然可用。

回放正是我从一开始就想要的:种子加命令日志。录制时,核心还会每 100 个 tick 写一个校验哈希。回放时,它会沿途比较每条命令的结果和每个校验哈希,并在第一个出现分歧的 tick 处停下。回放还会记住是哪个版本的核心录制了它,因此来自不同构建的回放会被拒绝,而不是悄悄地走偏。

核心本身从不接触文件:它读写的是流,存档放在哪里由游戏来决定。这个边界由前面提到的同一套禁用 API 机制来保证。

还没做的:一个播放回放文件的命令行工具,以及存档在游戏端的部分(存档文件夹、自动存档、安全写入)。

证明两次运行一致

确定性测试现在会在两个独立的进程中运行同一个场景:一个种子、一段建造命令脚本(其中一些故意写成无效的),以及一个会从每个随机流里取数的测试系统。每个进程用三种方式运行它——一口气跑完、经过一次存档与恢复、经过对录制字节的回放——并在每个 tick 比较哈希。然后两个进程每隔 10 个 tick 互相比较各自的哈希。

我故意把它弄坏,以确保它真的会失败。偷偷混进 tick 的一个不带种子的 Random,让测试变红了。一个随机化的字符串哈希,.NET 在每个进程里给它的种子都不同,在单个进程内没被发现,只有拿两个进程相互比较时才被抓住。这就是用两个进程的全部原因。

我写下的规则是:核心的同一个构建,必须在 x64 和 arm64 处理器上给出相同的哈希,所以在搭载 Apple 芯片的 Mac 上录制的回放,必须能在 Windows PC 上播放。固定的参考哈希,以及示例存档和回放文件,正是为这项检查而存在的。浮点的三角函数和指数函数在不同平台上结果不同,所以在任何会影响世界的代码里都不能用;第一次有系统需要它们时,我就得准备一个确定性的替代品。这些参考文件只会被有意地修改:如果哈希出现了漂移,必须先解释清楚原因,才能接受这个改变。

一个会说“不”的基准测试

现在有了一个无头运行器,它接收场景、种子和 tick 数,输出一份 JSON 报告:每个 tick 耗时的中位数和第 99 百分位、智能体数量、世界哈希,以及每个 tick 分配了多少字节。第一个场景在 16 个楼层上构建一张 512×512 的地图,大约有 52000 条建造命令。

基线是按机器保存的,因为笔记本上的耗时放到台式机上毫无意义。回退的判定是:中位数比基线高出超过 10%,或第 99 百分位高出超过 35%,而且增幅要大于 0.05 毫秒,因为几乎为空的 tick 已经处在计时器的分辨率极限上。有一道门槛在每台机器上都成立:每个 tick 分配零字节。我得老实说一下它如今究竟测了什么。没有系统也没有角色时,一个 tick 大约 35 纳秒,所以时间门槛基本上还在沉睡,要等到移动里程碑中第一批真正的系统出现。现在真正起作用的是内存分配这道门槛。

那个其实并不“偶发”的偶发失败测试

在做基准测试时,一个存档格式的测试开始在部分构建上失败,尽管核心并没有改动。这个测试会依次翻转压缩后回放的每一个字节,并要求加载器拒绝每一种情况。在某一个构建上,靠近文件末尾的一个字节被接受了。而在上一个构建上,同一个测试是通过的。

它时有时无的原因是:回放带有写入它的构建的标识符,所以压缩后的字节会因构建而异,被翻转的那个字节也就跟着变了。这个漏洞一直都在;只是测试恰好碰巧撞上的时候才发现了它。

漏洞本身是这样的:校验和覆盖的是解压后的数据,而 .NET 的解压器比较宽容。它会接受一个从不声明自己结束的流,并忽略结束之后的任何内容。翻转最后一个字节里的一个填充位,会得到另一个仍然有效的流,解压出的数据完全相同,所以校验和依然匹配,一个损坏的文件就被当成好文件加载了。

修复方式是推出新的容器版本。校验和现在覆盖文件中实际存储的字节,并在解压之前检查,而且解压必须恰好结束在头部所指明的位置。空的正文以未压缩的形式存放,因为压缩器对空输入什么也不写。现在的检查使用与构建无关的固定压缩流,外加对损坏文件的模糊测试。示例文件已按新版本重新生成;世界哈希没有变化。

其他

你正在读的这个网站,首页现在显示两个访客计数器:累计的独立访客数,以及最近 24 小时的独立访客数。它们是根据网络服务器的页面日志统计出来的,不用 JavaScript,不用 Cookie,也不用任何外部服务。地址只以加盐哈希的形式保存,机器人按其 user agent 过滤,数字会滞后几分钟。同一个网络后面的多个人只算作一个。

接下来

第一个里程碑,也就是核心骨架,已经完成。接下来是 M1,Godot 建造视图:根据世界模型生成墙、门和房间,楼层切片,以 90° 为步进旋转的相机,以及用普通方块代替美术资源。这部分工作还没有开始。参见路线图。

← 全部开发日志 · 下一篇:#3 →