快照 ACK 位图跳过 32 个序号时,旧位不能继续保留

客户端只收到快照 99、100 和 132,发回的确认却声称 131 也到了。如果发送端据此删除 131 的重传记录,缺失数据就失去了恢复机会。这个错误不需要多线程竞争,也不需要序号回绕:一条没有检查移位距离的 C# 表达式,就能把旧包的确认搬成另一个包的确认。

把案例压缩到一个 32 位接收窗口。Latest 保存收到的最大序号,Bits 的第 k 位表示 Latest-k 是否收到,最低位包含 Latest 自己。收到 99 和 100 后,窗口是 (100, 0x00000003)。随后直接收到 132,距离恰好为 32;原来两个包均已离开新窗口,正确结果只能是 (132, 0x00000001)

接收历史移出窗口后,新窗口只保留当前收到的包

主视觉只表达旧历史退出、新包进入的关系,格子数量不表示协议位宽。下面的技术图与代码使用精确的 32 位合同。

一次左移为什么确认了从未收到的 131

容易写出的更新是 bits = (bits << distance) | 1u。在本次 C# 运行中,uint 左移使用移位计数的低五位,因此距离 32 实际执行零位移。原来的 3 仍是 3,最低两位现在却被解释为 132 和 131。编译启用 checked 也不会让这次移位抛出溢出异常,位宽边界必须由协议实现检查。

这和序号回绕是两个问题。本文使用同一会话内不回绕的 ulong 逻辑序号,先通过普通大小关系确定方向,再计算非负距离。压缩到 16 位的网络字段必须在进入此接口前恢复逻辑序号,或者单独定义环形比较合同;不能把这里的比较直接复制到回绕字段上。

新窗口允许保留的年龄是 0 到 31。前进距离小于 32 时,旧位向高位移动,移出整数的位自然退出;距离不小于 32 时,旧历史应全部清空。后到的包只有年龄小于 32 才能补位。两处检查共享同一个宽度,但分别约束窗口推进和乱序接收。

收到99和100后跃迁到132的正确位图与虚假确认对照

收包入口负责窗口,应用层另行决定是否使用快照

研究项目的 Ack.cs 包含完整核心类,调用链为 Program.Main → AckWindow.Receive / ContainsReceive 返回的布尔值表示该序号是否首次进入当前可记录范围;它不表示快照已经解码或已应用到角色。以下实现没有依赖游戏引擎或网络库。

public sealed class AckWindow
{
    public bool Initialized { get; private set; }
    public ulong Latest { get; private set; }
    public uint Bits { get; private set; }

    public bool Receive(ulong sequence)
    {
        if (!Initialized)
        {
            Latest = sequence;
            Bits = 1;
            Initialized = true;
            return true;
        }
        if (sequence > Latest)
        {
            ulong distance = sequence - Latest;
            Bits = distance >= 32 ? 1u : (Bits << (int)distance) | 1u;
            Latest = sequence;
            return true;
        }
        ulong age = Latest - sequence;
        if (age >= 32) return false;
        uint bit = 1u << (int)age;
        bool fresh = (Bits & bit) == 0;
        Bits |= bit;
        return fresh;
    }

    public bool Contains(ulong sequence)
    {
        if (!Initialized || sequence > Latest) return false;
        ulong age = Latest - sequence;
        return age < 32 && (Bits & (1u << (int)age)) != 0;
    }
}

初始化标志单独保存,序号 0 因而可以正常成为首包。新序号只能推进 Latest;较旧包只修改对应位,重复包返回 false,过期包也返回 false。接口需要区分这两种拒绝原因时,可以返回枚举,但不能因需要诊断而让过期包重新进入窗口。

这里由单个接收流串行拥有三个字段。代码没有并发同步;若网络线程写入、发送线程同时读取 ACK,应在串行队列中生成一份 (Latest, Bits) 快照,或在同一同步区内读取两者。只把 Bits 换成原子整数不能保证发送出去的最大序号与位图属于同一版本。

调用方还要选择确认发生在哪个阶段。如果 ACK 的含义是经过校验的包已收到,就在认证、长度和包完整性检查之后记录;若发送端把 ACK 当成“可以作为增量基线”的证据,则还需要独立的解码成功与基线驻留确认。把所有接收位直接当作可用基线,会越过这份类实际能证明的边界。

用集合检查位含义,避免测试重复同一条移位表达式

2026-09-12 在 .NET 9、C# CPU 环境运行,Release 构建退出码 0,零警告、零错误;测试进程退出码 0。命令在研究项目目录执行,耗时来自进程内秒表,只反映本次用例执行,不能推断网络吞吐或延迟。

cd "$PROBE_PROJECT"
dotnet build SeqLab.csproj -c Release --no-restore
dotnet run --project SeqLab.csproj -c Release --no-build
passed=12 failed=0 skipped=0 samples=414768 elapsed_ms=32.2545
latest=132 correct_bits=00000001 wrong_bits=00000003 false_ack=131
receive=101 bits=80000001 expired=100 rejected=true

FalseAckRegression 保留运行时变量 shift=32 的错误表达式,断言错误位图为 3,同时验证正确窗口不包含 131。这一项通过表示反例被稳定复现,不表示错误算法满足确认合同。

窗口到达 132 后,101 的年龄为 31,补位得到 0x80000001;100 的年龄为 32,被拒绝且不改变位图。Age31OldExpiredReorderedDuplicate 分别检查端点、过期保持与重复接收。另有首包 0、查询未来包和直接跳到 ulong.MaxValue 的用例,确保差值没有先被截断成较小整数。

EveryDistance 对前进距离 1 到 96 逐个建窗,用已收序号集合查询 133 个候选值,共 12,768 次检查。ReferenceStream 使用 q=(i*73)%401 的固定 1000 次到包流,逐次比较新包判定,并检查 0 到 401 共 402 个成员,得到另 402,000 次检查。参考答案来自集合成员资格与年龄范围,没有再次使用待测的位移公式。

32 个位只能记住有限历史。在固定每秒 60 个连续序号的假设下,最老保留包与最新包的跨度为 31/60 秒,约 0.517 秒;这是容量换算,不是实测网络抖动预算。实际序号若跨频道共用、发送速率变化或发生大跳跃,必须按真实序号距离评估覆盖范围。

对开头的 131,修复后的确认不会再替一个未收到的包作证。窗口之外的包则是“当前位图无法说明”,不能反过来解释为永远没收到。可靠消息需要重传和更长的去重历史,快照流还可能直接淘汰旧状态;应由上层明确选择。这里值得固定在收发双方协议中的,是位 0 的含义、窗口宽度、距离越界后的清空规则,以及确认究竟证明到了处理链的哪一步。