#1 由编译器守护的确定性核心
· Nick
现在还没什么可看的,所以第一篇要写的是没人会看到的部分:模拟核心。这款游戏的目标是以每秒 20 个 tick 的速度运行最多 1000 名囚犯和狱警,并且能解释每个人为什么那样做。只有当相同的输入永远得到相同的世界时,这才行得通。我决定从第一天起,就把这一点做成由机器来检查的属性。
目前进度
我正处在第一个里程碑,也就是核心骨架。模拟是一个纯 .NET 库,里面没有 Godot 代码,而 Godot 4 客户端目前只会打开一个空的 3D 场景。还没有世界模型,没有瓦片,没有角色,也没有存档。1000 个角色的预算只是目标;我还没有在这个规模上测过任何东西。
Tick
核心里的时间是整数 tick 计数,每秒 20 个。没有浮点时间。一个 tick 分三个阶段:应用外部命令,按写在同一处的顺序运行各系统,然后提交延后的变更并推进计数器。如果 tick 中有错误逃逸出来,世界会被标记为故障并停止。目前系统列表是空的;这个循环先建起来,是为了给第一个系统留好位置。
命令与日志
世界只通过命令改变。命令是一条小记录,带有稳定的类型 ID 和手写的二进制编码。对每一条命令,世界会先编码,打上 tick 和序号的戳,进行(只读的)校验,写入日志,然后才应用。被拒绝的命令同样会记入日志,这应该对排查 bug 有帮助。种子加日志,理应能精确重现一次运行。回放文件的格式还没有写。
随机数
我自己写了随机数生成器(SplitMix64 和 xoshiro256**),并在核心中禁用了标准库的 Random。世界有四个具名的随机流(命令、需求、AI、事件),由种子派生并保存在世界状态里,因此会被存档,也会被哈希。对于顺序不应影响结果的工作,还有一个基于域、实体和 tick 的无状态带键生成器。种子 42 的输出被固定了下来,因为这实际上就是回放格式:一改生成器,所有旧回放都会失效。
世界的哈希
一个对顺序敏感的小型哈希器,会把种子、tick、随机流和命令日志的摘要折叠成 64 位。本该一模一样的两次运行,可以用一个数字来比较。目前它只涵盖这些部分,因为世界里还没有别的东西。下一步是在运行中每隔 N 个 tick 比较一次哈希。
让非确定性变成构建错误
我不想靠记住规则来保证正确。核心是用禁用 API 的分析器来构建的:系统时钟时间、不带种子的随机数、随机 GUID、随机化的字符串哈希、线程和并行辅助工具、SIMD,以及依赖区域设置的字符串 API,一旦使用就会让构建失败。另外的检查会确认没有可变的静态状态,并扫描编译后的代码,找出漏网之鱼。我的机器使用俄语区域设置,小数点是逗号,这正是禁用依赖区域设置的 API 的充分理由。
后来我又更进一步,把标准的哈希集合(Dictionary、HashSet 等)也从核心中禁掉了,因为它们的遍历顺序是出现偏差的典型来源。核心只能用数组、列表,以及我们自己写的两个顺序有文档说明的小集合。它并非滴水不漏:通过泛型接口返回哈希集合的 API 仍有可能溜进来,而且部分保护只在测试中起作用。
它是怎么搭起来的
代码周围的工具链同样是自动化的,相同的脚本在 Windows 和 macOS 上都能运行。总体思路见幕后制作。
接下来
这个里程碑还剩下:带楼层的世界模型、存档和回放文件、确定性测试,以及带基准测试基线的无头场景运行器。之后是 Godot 的建造视图。参见路线图。