相同输入仍会失步:帧上下文必须隔离容器顺序与系统时间

两台客户端已经收齐同一批输入帧,网络空洞也补完了,状态哈希却在第 120 帧分叉。此时继续检查封包没有意义:输入相同只约束了 Step 的参数,没有阻止逻辑内部读取系统时间、沿容器当前顺序遍历实体,或者临时创建一份未纳入世界状态的随机数发生器。
这是“帧同步:从输入帧到回放协议”第 3 篇。前两篇留下了两个可直接复用的边界:每个座席输入保持 4 字节布局,客户端只把连续 FrameInputs 交给 LockstepWorld.Step。本篇不修改网络协议,而是收紧这个唯一入口能够观察到的环境。
同帧竞争会把遍历顺序变成玩法规则
固定样本中,实体 3 与实体 7 在一次 33ms 逻辑帧后同时到达拾取点,规则是“本帧第一个处理到的实体取得物品”。若世界直接遍历 Dictionary.Keys,那么插入顺序 7、3 会让实体 7 获胜,插入顺序 3、7 又会让实体 3 获胜。两端即使拥有完全相同的实体集合,加载、销毁再创建或反序列化顺序不同,也足以改变结果。
探针把排序放在权威入口,而不是只在计算哈希时排序:
// .tmp/daily-labs/2026-08-25-1930/Program.cs
public StepResult StepStable(FrameContext frame)
{
Validate(frame);
return Step(frame, fighters.Keys.Order().ToArray());
}
private StepResult Step(FrameContext frame, IReadOnlyList<int> order)
{
var pickupOwner = 0;
foreach (var id in order)
{
var fighter = fighters[id];
var moved = fighter with {
Position = fighter.Position + frame.DeltaMillis
};
if (pickupOwner == 0 && moved.Position >= 33)
pickupOwner = id;
fighters[id] = moved;
}
var random = new XorShift32(frame.RandomSeed ^ frame.Tick);
var roll = random.Next(100);
return new(pickupOwner, roll,
StateHash.Compute(frame.Tick, pickupOwner, roll, fighters));
}
只把序列化字段排序并不能修复玩法分叉。哈希排序最多让“相同状态的字节表示”稳定;一旦拾取物已经被不同实体拿走,稳定序列化只会更可靠地报告两个不同结果。凡是具有首个命中、优先占用、冲突裁决或逐项累计语义的循环,顺序本身就是规则,必须由稳定 ID、显式优先级或预先定义的系统顺序决定。
固定 Tick 需要一份封闭的帧上下文
FrameContext 只有三个权威输入:逻辑帧号、固定步长毫秒数和对局随机种子。它没有 DateTime.Now、Stopwatch、Unity Time.deltaTime,也没有从设备环境取得种子的入口。
readonly record struct FrameContext(
uint Tick,
int DeltaMillis,
uint RandomSeed);
private static void Validate(FrameContext frame)
{
if (frame.DeltaMillis != 33)
throw new ArgumentOutOfRangeException(nameof(frame));
if (frame.RandomSeed == 0)
throw new ArgumentOutOfRangeException(nameof(frame));
}
33ms 是本文探针的离散协议值,不是精确表示 30Hz 的物理时间。真实工程可以用有理数 Tick、定点秒或累计余数处理 1000/30,但必须让所有参与者消费同一表示。把渲染帧测得的 32ms、34ms 直接送进玩法层,会使不同设备把同一输入积分成不同位置;把当前时间混入随机种子,则会让重放失去复算前提。
随机状态还必须是世界状态的一部分。探针为压缩案例,用 seed ^ tick 构造一次性 XorShift32;持续战斗更适合让发生器状态随世界推进,并像第 1 篇的 StateHash 一样写入哈希。两种方式共同要求的是:种子、调用次数和调用顺序都可复现,表现层粒子随机不能借用这条权威序列。
哈希分叉应定位到规则泄漏

同一个 FrameContext(120, 33, 0xC0FFEE) 分别运行两种实体插入顺序。稳定入口先按 ID 排成 3、7,两次都由实体 3 获得拾取物,随机结果都是 43,状态哈希完全一致。删除排序后,两次运行的拾取者变成 7 与 3,哈希在这一帧立即分叉:
stable tick=120 owner=3 roll=43
hash=0x9B60913498DE46D1
reordered_hash=0x9B60913498DE46D1
failure unordered owners=7/3
hashes=0x5651B2EF660BDD55/0x9B60913498DE46D1
failure wall_clock_delta=34 result=Rejected retained_tick=120
这组失败样本也说明失步诊断不应只保留帧末哈希。至少要同时记录 Tick、系统阶段、稳定实体 ID、随机状态和当前输入摘要,才能判断差异来自输入空洞、系统顺序、实体遍历还是随机调用次数。哈希用于找到第一次分叉,结构化追踪才用于解释分叉。
验证的是合同,不是跨平台确定性承诺
cd "$WORK_DIR/.tmp/daily-labs/2026-08-25-1930"
dotnet build LockstepDeterminismLab.csproj -c Release --no-restore
dotnet run --project LockstepDeterminismLab.csproj -c Release --no-build
# 决定性输出
Build succeeded. 0 Warning(s). 0 Error(s).
tests pass=8 fail=0 skip=0 duration_ms=5.11
本次构建退出码为 0,墙钟耗时 2.83s;运行退出码为 0,墙钟耗时 2.82s。八项检查覆盖稳定顺序收敛、容器顺序分叉、哈希字段排序、固定步长、外部时间拒绝、零种子拒绝、同种子复现与 Tick 参与随机流。耗时只描述这次命令,不构成性能结论。
当前证据证明的是一条可执行边界:在这个整数状态探针中,帧上下文封闭、实体顺序稳定、随机种子显式时,不同插入顺序会收敛到同一状态。它没有证明 float 在所有 CPU、编译器和 Unity 后端上天然一致,也没有覆盖物理引擎、并行归约或第三方数学库。接入完整客户端时,仍需用跨构建逐帧哈希测试逐项确认这些边界。
网络层现在可以把连续输入安全交给一个更严格的 Step(FrameInputs, FrameContext)。下一篇应沿用这份入口,处理失步已经发生后的证据协议:哪些帧保存检查点,怎样从首次不同哈希定位到具体系统与实体,而不是把某个客户端的状态直接宣布为真相。