快照 11 丢了,快照 12 还能解码吗:增量状态必须引用已确认基线
服务端连续发送角色快照 10、11、12。快照 11 在网络中丢失,客户端仍持有 10;此时 12 到达了。如果 12 是相对“最近发送的 11”编码的增量,包本身虽然完整,客户端却没有 11,位置和生命值都无法还原。这个错误不在序列化算法,而在服务端把“已经发送”误当成了“双方共同拥有”。
本文的固定状态只有两个字段:快照 10 是位置 100、生命 80,快照 11 是 106、75,快照 12 是 111、70。字段很少,足以把基线所有权讲清,也避免把压缩率与协议正确性混在一起。

发送完成不会建立共享状态
SnapshotBaselineStore 为一条客户端连接维护两类状态:Acknowledged 是客户端已经确认、双方都能读取的基线;_sent 保存已经发出但尚未取得确认的快照。编码器可以保留多个待确认候选,却只能从前者计算 delta。
这里的 ACK 不是“客户端收到了某个 UDP 数据报”的泛化布尔值,而是对一个确定序号及其完整状态的确认。服务端收到 ACK 11 后,才能断言客户端拥有 S11。若传输协议提供的是累计确认,推进到 11 还意味着 10 及以前都不再需要作为回退基线;若使用选择确认位图,则清理策略可以更细,但编码某个后续包时仍要选择客户端明确持有的那一项。ACK 的表达方式可以变化,“共同拥有”这层语义不能省略。
下面是探针的完整核心实现,路径为 .tmp/daily-labs/2026-08-14-1930/SnapshotBaselineStore.cs,入口是 SnapshotBaselineStore.Encode:
public readonly record struct EntityState(int X, int Health);
public readonly record struct Snapshot(long Sequence, EntityState State);
public readonly record struct SnapshotDelta(
long Sequence, long BaselineSequence, int DeltaX, int DeltaHealth);
public sealed class SnapshotBaselineStore
{
private readonly SortedDictionary<long, Snapshot> _sent = [];
public Snapshot Acknowledged { get; private set; }
public SnapshotBaselineStore(Snapshot initial) => Acknowledged = initial;
public SnapshotDelta Encode(Snapshot current)
{
if (current.Sequence <= Acknowledged.Sequence || _sent.ContainsKey(current.Sequence))
throw new ArgumentOutOfRangeException(nameof(current));
_sent.Add(current.Sequence, current);
return new SnapshotDelta(
current.Sequence,
Acknowledged.Sequence,
current.State.X - Acknowledged.State.X,
current.State.Health - Acknowledged.State.Health);
}
public bool TryAcknowledge(long sequence)
{
if (sequence <= Acknowledged.Sequence || !_sent.TryGetValue(sequence, out var snapshot))
return false;
Acknowledged = snapshot;
foreach (var obsolete in _sent.Keys.Where(key => key <= sequence).ToArray())
_sent.Remove(obsolete);
return true;
}
}
public static class SnapshotDecoder
{
public static Snapshot Decode(Snapshot baseline, SnapshotDelta delta)
{
if (baseline.Sequence != delta.BaselineSequence)
throw new InvalidOperationException("baseline mismatch");
return new Snapshot(delta.Sequence, new EntityState(
baseline.State.X + delta.DeltaX,
baseline.State.Health + delta.DeltaHealth));
}
}
包必须携带 BaselineSequence。它既让客户端选择正确基线,也让不满足前提的包显式失败。若客户端只有 9,而包声明基线 10,解码器拒绝处理;静默套用错误基线会产生一个格式合法但状态错误的角色,之后很难区分网络丢包与玩法分歧。
这也解释了为什么仅给快照包增加自己的 Sequence 不够。包序号 12 只能回答“当前结果是哪一版”,不能回答“这些差值应加到哪一版”。完整状态包不需要基线,增量包却同时依赖结果序号与基线序号;缺少后者时,解码器只能猜测。协议一旦允许重排、重传或丢失,猜测就不再具有确定性。
丢失的候选不能成为下一包的前提
测试先编码 11,但不提交它的 ACK,再编码 12。两个包都引用 10,因此客户端可用 (100,80) + (11,-10) 得到 (111,70)。当 11 确实被确认后,服务端才把基线推进到 11;同一个 12 随后可写成更小的 (5,-5)。
对比一个常见错误实现:它在 Encode(11) 返回后立刻把 latest 改成 11,于是 12 被编码为 (5,-5)。客户端用手中的 10 套用这组差值,会得到 (105,75);如果没有基线序号校验,这个错误状态甚至会继续参与插值,看起来只像角色轻微回弹。明确拒绝比“尽量解码”更安全,因为拒绝会触发完整快照恢复,而错误状态可能被当作合法事实传播到预测、命中判定和回放记录。

决定性回归位于 .tmp/daily-labs/2026-08-14-1930/Program.cs:
var store = new SnapshotBaselineStore(new(10, new(100, 80)));
var lost = store.Encode(new(11, new(106, 75)));
var delivered = store.Encode(new(12, new(111, 70)));
var decoded = SnapshotDecoder.Decode(store.Acknowledged, delivered);
Equal(10L, lost.BaselineSequence);
Equal(10L, delivered.BaselineSequence);
Equal(new EntityState(111, 70), decoded.State);
当天在 Release 配置下执行:
$ dotnet build $WORK_DIR/.tmp/daily-labs/2026-08-14-1930/SnapshotBaselineProbe.csproj -c Release
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:07.32
$ dotnet run --project $WORK_DIR/.tmp/daily-labs/2026-08-14-1930/SnapshotBaselineProbe.csproj -c Release --no-build
loss: ack=10 lost=11 delivered=12 baseline=10 delta=(11,-10) decoded=(111,70)
ack: ack=11 next=12 baseline=11 delta=(5,-5)
mismatch: clientBaseline=9 packetBaseline=10 result=rejected
tests: passed=4 failed=0 skipped=0 elapsedMs=57.6102
这些耗时只证明本次探针完成,不代表网络延迟或编码吞吐。运行事实是:丢失 11 不会改变服务端的确认基线,12 仍能由客户端现有状态解码;未知 ACK 也不会推进基线。
正确性会占用一段历史窗口
确认基线不能随发送指针移动,代价是服务端必须保留待确认快照。当前 SortedDictionary 的空间与未确认数量 U 成正比,确认时清理的总工作可摊还到每个快照一次;本文没有给出吞吐实测,不讨论它是否适合大规模连接。
生产协议还需要基线保留上限。当 U 超过配置窗口、序号即将回绕,或客户端长期没有 ACK 时,继续堆积不是恢复策略。可选择发送不依赖旧基线的完整快照并重建确认点,或断开已失去同步能力的连接。无论选哪一种,都不能删除仍被后续 delta 引用的基线。
窗口上限应由内存预算与最大可容忍恢复跨度共同决定,而不是照搬发送频率。假设每条连接保留 U 份、每份状态为 B 字节、连接数为 C,仅历史状态的理论占用就是 C × U × B;共享世界快照或结构化增量能改变常数,却不会消除上界。观察到窗口持续触顶时,正确的演进信号是 ACK 停滞或网络路径退化,不是继续无限扩大字典。
完整快照也不是每次丢包都必须发送。只要后续包仍引用最后确认基线,单个丢失包可以自然跳过;当基线已被淘汰、客户端显式报告 mismatch,或差值体积接近完整状态时,再切换为全量更合理。这样恢复机制负责重新建立共同状态,增量机制负责在共同状态之上节省字节,两者职责不会互相污染。
这个探针只覆盖单实体整数状态,没有处理多实体稀疏字段、可靠传输层重排和 ACK 位图。但架构边界已经足够明确:发送队列拥有待确认候选,连接复制状态拥有确认基线,客户端解码器复核包中基线序号;三者不能用一个“latest snapshot”指针替代。只要后续增量的前提来自双方已确认的共同状态,单个数据包丢失就只损失一次更新,不会破坏后续包的可解码性。