· 5 分钟阅读 · 1945 字

快照序号从 65535 回到 0:新旧比较只能在半圈内成立

客户端已接受序号 65535 的角色快照,下一包序号是 0、位置从 11 前进到 12。如果接收条件写成 incoming > current,新位置会被丢弃。包没有丢,解码也没有失败,角色状态却停在回绕之前。继续发送 1、2、3,普通整数比较仍然拒绝更新。

改成“数字小就算新”又会让迟到的旧包倒灌。问题在于网络字段只保存有限位宽的计数余数;判断先后需要恢复局部方向,不能把这个余数当成无限增长的时间轴。

有限计数环上的顺序快照与迟到数据包概念图

让接收端沿前进方向量距离

本篇的独立 C# 探针 SeqLab 使用单一快照流。发送端每产生一个快照,序号增加一次,超过 65535 回到 0。Position 是用于观察提交结果的整数位置,不参与序号运算;不讨论预测、插值或增量解码。接收端只保存已接受的最新快照。

设当前序号为 c、输入为 s,先计算 d=(s-c)&65535,也就是在长度 65536 的环上向前量距离。65535 到 0 的模差是 1;0 到迟到的 65535,模差则是 65535。

模差 d 判定 接收端动作
0 同一序号 保持状态
1 至 32767 较新 提交快照
32768 方向歧义 拒绝并保留状态
32769 至 65535 较旧 保持状态

相差半圈时,两个方向的距离都为 32768,字段本身没有信息可以决定先后。这里将其暴露为 Ambiguous,方便上层记录或触发恢复;把它悄悄归入“较新”会制造一个并不存在的先后依据。

下面是 Sequence.cs 的完整实现。Receiver.Receive 先校验会话,再处理初始化和新旧比较,只有 Accepted 分支能够替换 Current。初始状态用可空值表达,不能拿序号 0 充当“尚未收到”。

namespace SeqLab;

public enum Order { Same, Newer, Older, Ambiguous }
public enum Decision { Accepted, Duplicate, Stale, Ambiguous, WrongEpoch }
public readonly record struct Snapshot(uint Epoch, ushort Sequence, int Position);

public static class Serial16
{
    public static Order Compare(ushort incoming, ushort current)
    {
        int delta = (incoming - current) & 0xffff;
        return delta switch
        {
            0 => Order.Same,
            < 32768 => Order.Newer,
            32768 => Order.Ambiguous,
            _ => Order.Older
        };
    }
}

public sealed class Receiver
{
    public uint Epoch { get; private set; }
    public Snapshot? Current { get; private set; }
    public Receiver(uint epoch) => BeginSession(epoch);

    public void BeginSession(uint epoch)
    {
        Epoch = epoch;
        Current = null;
    }

    public Decision Receive(Snapshot incoming)
    {
        if (incoming.Epoch != Epoch) return Decision.WrongEpoch;
        if (Current is { } current)
        {
            var order = Serial16.Compare(incoming.Sequence, current.Sequence);
            if (order == Order.Same) return Decision.Duplicate;
            if (order == Order.Older) return Decision.Stale;
            if (order == Order.Ambiguous) return Decision.Ambiguous;
        }
        Current = incoming;
        return Decision.Accepted;
    }
}

C# 会把两个 ushort 提升为 int 后再相减,差值范围落在 [-65535,65535],不会溢出;按位与保留低 16 位,因此负差也能得到正确模差。实现没有依赖窄整数强制转换在 checked 配置下的溢出行为。

BeginSession 是控制层入口,不能因为收到一个不同 Epoch 的数据包就自动调用。它清空旧快照,并绑定已经由会话建立过程确认的标识。本探针使用 uint 表达该标识,只演示隔离关系;实际协议必须确保旧包可能存活期间不复用,并完成身份与完整性校验。

同一串包,只有提交结果能够驱动表现

固定输入属于会话 7,依次送入序号 65534,65535,0,65535,1,携带位置 10,11,12,11,13。前三包正常推进,第四包是迟到数据;接收端保留位置 12,第五包再推进到 13。

固定快照输入、状态提交结果与全部模差分类

Program.cs 的回归不仅检查返回枚举,也检查拒绝后整个 Current 保持不变。否则即使日志写着 Stale,先写位置再判断序号的调用顺序仍可能造成回退。以下失败样本在本次运行中通过:

var saved = receiver.Current;
Check(receiver.Receive(new(7, 1, 999)) == Decision.Duplicate && receiver.Current == saved, "duplicate immutable");
Check(receiver.Receive(new(7, 32769, 999)) == Decision.Ambiguous && receiver.Current == saved, "ambiguous immutable");
Check(receiver.Receive(new(6, 2, 999)) == Decision.WrongEpoch && receiver.Current == saved, "old epoch immutable");

2026-10-17 在 .NET 9、Release 配置执行。构建退出码 0,零警告、零错误,耗时 6.68 秒;运行退出码 0,23 项通过、0 失败、0 跳过,测试批次计时 32.1141 毫秒。这是功能验证耗时,不是网络吞吐或客户端帧耗时基准。命令在研究目录执行:

dotnet build SeqLab.csproj -c Release --nologo
dotnet run --project SeqLab.csproj -c Release --no-build
passed=23 failed=0 skipped=0
65534 Accepted current=65534 position=10
65535 Accepted current=65535 position=11
0     Accepted current=0     position=12
65535 Stale    current=0     position=12
1     Accepted current=1     position=13
incoming=32769 current=1 delta=32768 accepted=false
allDeltas=65536 origins=6 pairs=393216 comparisons=786432
Same=1 Newer=32767 Older=32767 Ambiguous=1

穷举检查覆盖全部 65536 个模差,基准序号取 0,1,32767,32768,65534,65535;每组同时验证正向分类与反向关系,共 393216 对输入、786432 次比较。分类应只依赖模差,改变环上的起点不能改变结果。另有会话 7 切到 8 的回归:清空状态,拒绝会话 7 的迟到包,接受会话 8 的首个序号 0。

比较正确,仍可能接错历史

模差没有恢复被截掉的圈数。如果当前是 0,发送端实际已经前进 40000 步,算法会将 40000 判成旧包;前进整整 65536 步后再收到 0,又与重复包完全相同。对应反例也纳入测试,它们用于证明输入合同的边界。

成立条件是:每次比较的两个包,在同一会话中的真实有符号序号跨度必须严格小于 32768。这里既约束接收端落后多久,也约束旧包能迟到多久。一个真实落后超过半圈的旧包,同样可能被误认为新包。只限制客户端缓存长度,无法约束网络、发送队列或重连后仍在途的数据。

若协议规定最多每秒产生 60 个序号,32768 步对应约 546.13 秒;严格小于半圈才可比较。这个数来自序号推进上界的换算,不是建议把超时设为九分钟。突发补发是否产生新序号、队列保留多久、进程恢复是否沿用会话,都必须进入预算;以平均包率换算会掩盖最坏情况。

在上述窗口失效前,连接管理层应重新获取可信基线或建立新会话,再清空旧接收状态;新基线的建立过程必须隔离旧包。单纯扩大允许的模差,会同时扩大把旧历史当成新状态的机会。改用更宽序号可以延长窗口,仍然需要会话与生命周期规则。

这个比较也不能直接交给任意历史集合排序。环上可出现 A 比 B 新、B 比 C 新、C 又比 A 新的关系,只有共同落在受约束的局部时间窗内,才能按锚点展开成线性顺序。快照插值缓冲区应先限定可接受窗口,再安排时间顺序,不能依赖一个全局无条件的“小于”比较器。

角色能否越过 65535 继续移动,最终取决于两个合同:接收函数只提交已确认较新的状态,连接生命周期保证参与比较的历史没有跨越半圈。探针验证了单流状态提交与会话隔离,没有验证实际 UDP 传输、并发接收或游戏引擎表现;接入多线程客户端时,还需由单一接收所有者或同步机制把比较与提交作为一次完整操作。