16 位输入序号回绕后,零为什么比 65535 更新
客户端已经发送输入序号 65534、65535、0、1,服务端却把后两个包判成过期。传输没有乱序,问题出在一句很自然的判断:candidate <= lastAccepted。对于 16 位序号,0 在数值上确实小于 65535,在时间上却是它的下一项。
把序号改成 32 位只能推迟错误。只要协议字段会回绕、连接存活时间可能跨过回绕点,接收端就不能用普通整数大小表达“更新”。它需要回答另一个问题:候选序号沿模 65536 的圆环向前走了多远,这段距离是否仍在本连接允许接受的窗口内。

整数大小在回绕点失去时间含义
探针把最后接受序号和窗口放进每连接的 InputSequenceGate。入口只有 TryAccept,玩法模拟只消费它返回 Accepted 的输入;网络收包线程不能先修改最后序号,再让后续逻辑补救。
前向距离使用 16 位无符号减法:
distance = (candidate - lastAccepted) mod 65536
当最后序号为 65535、候选为 0 时,结果不是负数,而是 1。当最后序号为 1、迟到旧包为 65530 时,结果为 65529。同一个公式同时保留了回绕后的连续性和旧包的反向关系。
// InputSequenceGate.cs / InputSequenceGate
public sealed class InputSequenceGate
{
private const int HalfRange = 1 << 15;
private readonly ushort maxLead;
public InputSequenceGate(ushort initialSequence, ushort maxLead)
{
if (maxLead == 0 || maxLead >= HalfRange)
throw new ArgumentOutOfRangeException(nameof(maxLead));
LastAccepted = initialSequence;
this.maxLead = maxLead;
}
public ushort LastAccepted { get; private set; }
public int AcceptedCount { get; private set; }
public AcceptResult TryAccept(ushort sequence)
{
var distance = (ushort)(sequence - LastAccepted);
if (distance == 0)
return AcceptResult.Duplicate;
if (distance >= HalfRange)
return AcceptResult.Stale;
if (distance > maxLead)
return AcceptResult.TooFarAhead;
LastAccepted = sequence;
AcceptedCount++;
return AcceptResult.Accepted;
}
}
这里的状态提交只有最后三行。重复、旧包和越窗包在到达提交点前返回,因此失败不需要回滚,也不会让后续合法输入基于错误序号继续计算。
半个序号空间用来区分前后方向
仅计算模差仍不够。任意两个 16 位值都有一个 0..65535 的前向距离;如果所有非零距离都算更新,那么一个很旧的包也会绕一整圈后被接受。
解决条件来自序号空间的半范围规则:小于 32768 的差值解释为向前,大于或等于 32768 的差值解释为反向或歧义。协议因此必须保证同时在途、仍可能被接受的序号跨度小于半个空间。若连接能够积压超过 32767 项输入,单靠 16 位字段已经无法唯一恢复先后关系,需要扩大字段或引入连接代次。
本文的实际接收窗口更窄,maxLead = 32。距离 1..32 可以推进状态,距离 0 是重复,距离 33..32767 虽然方向向前,却超出本连接允许的跳跃,被分类为 TooFarAhead。这个额外门槛阻止损坏、错误会话或未重置发送端用一个巨大跳号吞掉中间输入。

窗口大小不是延迟毫秒数。它表达接收端允许序号一次向前跨越多少项,必须覆盖协议允许的丢包和聚合形状。例如客户端每 Tick 发送一个输入,服务端允许连续丢失最多 31 项后接受第 32 项,这个窗口才有对应含义。若传输层会重发缺口,门还需要位图保存窗口内已见序号;当前切片只处理严格推进的最新输入,不声称解决选择确认。
窗口也约束连接恢复策略。若服务端观测到大量 TooFarAhead,更合理的动作是要求客户端重新同步会话基线,并记录期望序号与候选距离,而不是自动扩大窗口。窗口扩大到接近半空间,会让长时间滞留的旧包更难与真正的新包区分;直接把异常候选写成新基线,则会使随后仍在窗口内的合法输入全部变成旧包。接收门必须把分类结果交给连接控制层,由会话协议决定重同步,不能自行猜测丢失了多少 Tick。
失败包不能推进连接的时间边界
固定样本从 65533 开始,依次提交 65534、65535、0、1,再送入旧包 65530。测试还覆盖重复、窗口边缘 32、越窗距离 40 和正好半范围 32768。命令与决定性输出如下:
dotnet run --project \
$WORK_DIR/.tmp/daily-labs/input-sequence/InputSequenceProbe.csproj \
-c Release
input=65534 before=65533 result=Accepted after=65534
input=65535 before=65534 result=Accepted after=65535
input=0 before=65535 result=Accepted after=0
input=1 before=0 result=Accepted after=1
input=65530 before=1 result=Stale after=1
tests: pass=8 fail=0 skip=0 duration_ms=19
本次 Release 构建退出码为 0,0 警告、0 错误,构建耗时 6.13 秒;运行退出码为 0,外层进程耗时 1.97 秒。耗时只证明探针在本次环境完成,不构成网络吞吐或延迟结论。真正可复核的行为是回绕连续输入全部接受,而旧包到达后 LastAccepted 仍为 1。
让玩法层自己比较序号并不可取。输入缓冲、角色模拟和反作弊若分别维护“最新值”,同一个包可能在三处得到不同分类。每连接接收门应拥有序号时间边界,向下游交付已经裁决的输入及其原始序号;连接重建时则更换会话身份并重置门,不能把新连接的 0 与旧连接的末尾序号放进同一个圆环比较。
这套实现没有覆盖缺口重传、批量输入、加密重放防护或跨服务器迁移。它解决的是更窄却必须先成立的合同:16 位序号的时间方向由模差和半范围定义,可接受跨度再由业务窗口收紧。只要失败分类发生在提交之前,65535 → 0 就是一次普通前进,而不是迫使服务端丢掉接下来整圈输入的异常边界。