本文属于系列「帧同步:从输入帧到回放协议」第 1 / 2 篇

帧同步不是同步状态:4 字节输入如何驱动唯一的逻辑帧

多个相同战斗世界由同一输入流同步推进

这是“帧同步:从输入帧到回放协议”第 1 篇。先不谈预测、补帧和重连,只建立最小闭环:网络交付输入,逻辑世界按固定顺序推进,帧末计算权威状态哈希。

// 组合调用:FrameInputs.cs + LockstepWorld.cs + StateHash.cs
uint packed = PlayerInput.Pack(
    PlayerInput.BtnFire,
    moveAngle: 64,
    moveStrength: 255,
    aimAngle: 128);

FrameInputs inputs = new FrameInputs();
inputs.SetPacked(seat: 0, packed);
world.Step(inputs);
ulong hash = new StateHash().Compute(world);

如果客户端直接发送“我现在位于哪里”,服务器收到的是结论;如果只发送输入帧,每个参与者则能从同一前提复算结论。后者才给失步检测、重连追帧和回放留下共同基础。

四字节输入

实际输入不是一组布尔字段,而是一个固定布局的 uint。按钮、移动角度、摇杆力度和瞄准角度各占 1 字节;十个座席使用固定槽位,未输入就是零。

// client/Assets/HotUpdate/Game.Core/World/FrameInputs.cs
public struct PlayerInput
{
    public byte Buttons;
    public byte MoveAngle;
    public byte MoveStrength;
    public byte AimAngle;

    public static uint Pack(
        byte buttons,
        byte moveAngle,
        byte moveStrength,
        byte aimAngle)
    {
        return (uint)buttons |
            ((uint)moveAngle << 8) |
            ((uint)moveStrength << 16) |
            ((uint)aimAngle << 24);
    }
}

public sealed class FrameInputs
{
    public const int MaxPlayers = 10;
    readonly uint[] _packed = new uint[MaxPlayers];

    public void SetPacked(int seat, uint packed)
    {
        _packed[seat] = packed;
    }
}

固定布局有两个价值:协议长度可复算,逻辑层也不再读取 Unity 输入设备、浮点摇杆值或系统时间。本文固定样本被编码成 0x80FF4001;它是输入事实,不是角色状态。

唯一入口

输入帧进入固定 Tick 并生成权威状态哈希

LockstepWorld.Step 是推进权威世界的唯一入口。它先复制本帧输入,再按注册顺序运行系统,最后统一处理实体创建、销毁和逻辑事件。渲染帧率不能调用另一条“补更新”路径。

// client/Assets/HotUpdate/Game.Core/World/LockstepWorld.cs
public void Step(FrameInputs inputs)
{
    Frame++;
    _stepped = true;
    _inputs.CopyFrom(inputs);

    for (int i = 0; i < _systems.Count; i++)
    {
        _systems[i].Tick(this);
    }

    FlushEntityBuffers();
    PublishEvents();
}

这里有两条不变量:系统顺序在开局前固定;本帧内部产生的实体变化到帧尾才统一可见。若某个 MonoBehaviour 在 Update() 中直接修改生命值,同一输入复算就不再保证同一结果。

哈希只验结果

状态哈希不推动世界,也不参与“多数票决定真相”。它只把帧号、下一个实体 ID、随机数状态和稳定顺序的实体字段写成字节,再计算 xxHash64。

// client/Assets/HotUpdate/Game.Core/World/StateHash.cs
static void WriteWorld(LockstepWorld world, ref StateWriter writer)
{
    writer.WriteInt(world.Frame);
    writer.WriteUInt(world.NextEntityId);

    world.Rng.GetState(out ulong s0, out ulong s1,
        out ulong s2, out ulong s3);
    writer.WriteULong(s0);
    writer.WriteULong(s1);
    writer.WriteULong(s2);
    writer.WriteULong(s3);
    writer.WriteInt(world.EntityCount);

    for (int i = 0; i < world.EntityCount; i++)
    {
        WriteEntity(world.Entities[i], ref writer);
    }
}

粒子、镜头和动画播放进度不能进入这份字节流。否则两台逻辑完全一致、GPU 负载不同的设备也会被误判为失步。

首帧分歧

固定探针连续运行同一个 10,000 帧输入脚本两次,并逐帧比较哈希;随后只翻转第 5000 个输入的最低位。验收标准不是“最终哈希不同”,而是第一次分歧必须精确落在被修改的输入帧。

ulong[] first = Run(script);
ulong[] second = Run(script);

int mismatches = 0;
for (int i = 0; i < 10000; i++)
{
    if (first[i] != second[i]) mismatches++;
}

uint[] mutated = (uint[])script.Clone();
mutated[5000] ^= 1U;
ulong[] changed = Run(mutated);
int firstDivergence = FirstDifferent(first, changed);
packed_input=0x80FF4001
frames=10000 mismatches=0
final_hash=0x64AC6A760FBD7D92
mutated_input_frame=5000 first_divergence=5000

这个结果证明当前探针的输入边界可以定位分叉起点;它不证明不同 CPU、不同编译器或完整 Unity Player 已经天然确定,那些要在后续确定性章节单独验证。

当天验证

项目使用自定义 NUnitLite Runner,传入的过滤参数没有缩小用例集合,因此命令实际运行了完整核心测试,而不是把 263/263 写成单个测试结果。

cd "$PROJECT_ROOT"
dotnet test shared/GameCore.Tests/GameCore.Tests.csproj \
  --filter 'FullyQualifiedName~WorldTests' --no-restore --nologo

# 实际输出
Total tests: 263. Passed: 263. Failed: 0.
real 19.45s

dotnet run --project "$PROBE_PROJECT/CoreProbe.csproj" --no-restore

# 实际输出
frames=10000 mismatches=0
mutated_input_frame=5000 first_divergence=5000
real 2.40s

耗时只记录本次复核命令的墙钟时间,不作为性能结论。核心门禁是 263 项回归通过、双运行逐帧零差异,以及单输入修改能在同一帧被哈希发现。

下一步

当前世界已经能消费输入帧,但还没有回答网络抖动:服务端在 30Hz Tick 到来时定格哪一份输入?客户端先收到第 2 帧、后收到第 0 帧时能不能推进?下一篇沿用同一个 4 字节输入,进入服务端帧时钟、三帧冗余和空洞补帧。