帧同步丢一包不该停住:三帧冗余、空洞检测与补帧

这是帧同步系列第 2 篇。第 1 篇已经把每个座席输入压成 4 字节,并证明 LockstepWorld.Step(FrameInputs) 能用同一输入得到同一哈希。本篇不改这份输入,只解决它怎样跨过网络抖动。
// server/crates/battle/src/room.rs
match msg {
c2s::Msg::InputCmd(input) if self.state == RoomState::Running => {
if !self.arbiter.is_kicked(seat) {
self.pending[index] = input.input;
}
}
c2s::Msg::FrameRequest(request) => {
self.send_frame_range(seat, request.from, request.to)
}
_ => {}
}
fn on_tick(&mut self) {
let frame = encode_frame(&self.pending);
self.frames.push(frame);
self.broadcast_frames_tail();
}
服务端没有等待“客户端声明的目标帧”。InputCmd.frame_index用于延迟统计,真正定格边界是房间的 30Hz Tick;同一座席在 Tick 前连续上报多次,最后到达的状态覆盖前值。
定格最新输入
输入在这里是状态量,不是必须逐条执行的事件。摇杆从向左变成向右,只需要 Tick 到来时保存最新方向;若把中间每次采样都当动作,网络频率反而会改变逻辑结果。
// plans/active/tank-moba/common/protocol.md
message InputCmd {
uint32 frame_index = 1; // 只做客户端延迟统计
fixed32 input = 2; // buttons | angle | strength | aim
}
message FrameRequest {
uint32 from = 1; // 闭区间
uint32 to = 2;
}
message Frames {
uint32 first_frame = 1;
repeated bytes frames = 2;
}
Rust 聚焦测试在 4 个座席各写入 20 次输入后连续触发 4 个 Tick;生成的 4 帧全部复用各座席最后一次输入。另一个暂停时间测试先让房间在 Loading 停留 2 秒,再开始战斗;首个逻辑帧仍从 0 开始,没有补发加载期间的 60 个 Tick。
三帧冗余

服务端每次不只发新帧,而是从历史尾部取最近 redundant_frames 帧;当前配置为 3。客户端即使漏掉一个包,也能从下一包重新拿到缺失帧。
// server/crates/battle/src/room_send.rs
pub(super) fn broadcast_frames_tail(&mut self) {
let count = self.cfg.redundant_frames.max(1) as usize;
let len = self.frames.len();
let start = len.saturating_sub(count);
let frames = self.frames[start..]
.iter()
.map(|frame| frame.to_vec())
.collect();
self.broadcast_frames(Frames {
first_frame: start as u32,
frames,
});
}
代价很直接:同一帧最多重复发送三次。收益是常见的单包丢失不需要往返请求;FrameRequest 留给连续丢包或重连后的大空洞。当前源码把一次请求限制在最多 600 帧,并按 128 帧分块发送,不能沿用旧设计文档中的 300 帧描述。
只吃连续帧
客户端用有序字典缓存未来帧,但只消费 lastConsumed + 1。旧帧和重复帧直接拒绝;未来帧可以先放进缓存,不能越过空洞推动逻辑世界。
// client/Assets/HotUpdate/Game.Net/Battle/FrameBuffer.cs
public bool AddFrame(FrameInputs frame)
{
if (_lastConsumed >= 0 && frame.FrameIndex <= (uint)_lastConsumed)
return false;
if (_frames.ContainsKey(frame.FrameIndex))
return false;
_frames.Add(frame.FrameIndex, frame);
ReportGap();
return true;
}
public bool TryConsumeNext(out FrameInputs frame)
{
uint next = NextFrame();
if (!_frames.TryGetValue(next, out frame))
return false;
_frames.Remove(next);
_lastConsumed = next;
_hasLastGap = false;
ReportGap();
return true;
}
这个约束比“包已经可靠到达”更重要。传输层可以保证字节最终送达,却不能保证应用层现在已经拥有第 N 帧之前的所有输入;重连、批量补帧和重复尾包都会改变到达顺序。
空洞会收缩
ReportGap 只报告当前期望帧到已缓存最小未来帧之间的区间。同一空洞不重复通知;消费或补入中间帧后,缺口会重新计算。
// FrameBuffer.ReportGap 的核心分支
uint expected = NextFrame();
if (_frames.ContainsKey(expected)) return;
foreach (uint frameIndex in _frames.Keys)
{
if (frameIndex <= expected) continue;
uint from = expected;
uint to = frameIndex - 1;
if (!_hasLastGap || _lastGapFrom != from || _lastGapTo != to)
{
_hasLastGap = true;
_lastGapFrom = from;
_lastGapTo = to;
_onGap(from, to);
}
return;
}
默认客户端立即请求一次缺失区间;如果 300ms 后仍未补齐,就重发同一个 FrameRequest。服务端同时验证 from <= to < current_frame 并执行席位级频控,避免补帧接口变成无限历史下载器。
固定探针
探针先收到 [2,3],于是报告空洞 0-1;补入 0 并消费后,空洞收缩为 1-1;补入 1 后严格按 0,1,2,3 消费。再次插入已消费的第 2 帧会返回 false。
encoded_sparse_bytes=10
encoded_empty_bytes=2
after_first_packet expected=0 buffered=2 gaps=0-1
consumed=0,1,2,3
gap_events=0-1,1-1
duplicate_old_frame_accepted=False
three_frame_tail_ready=3
稀疏帧只有两个座席有输入:2 字节位图加两份 4 字节输入,共 10 字节;全空帧只剩 2 字节位图。这里统计的是帧载荷,不含 Protobuf、长度前缀和传输头。
当天验证
cd "$PROJECT_ROOT"
dotnet test shared/GameNet.Tests/GameNet.Tests.csproj \
--filter 'FullyQualifiedName~FrameBufferTests' --no-restore --nologo
# 自定义 Runner 实际执行全套
Total tests: 49. Passed: 49. Failed: 0.
real 0.86s
cargo test --manifest-path server/Cargo.toml \
-p battle room_tick_tests --locked -- --nocapture
test result: ok. 2 passed; 0 failed; 49 filtered out
real 0.90s
这些结果证明当前实现覆盖乱序、重复、空洞收缩、连续批量消费、burst Tick 和 Loading 起帧边界;它们不是公网丢包率或吞吐性能结论。
取舍
三帧冗余用少量重复带宽换掉常见单包丢失的请求往返;有序缓冲用内存换取乱序容忍;300ms 重试降低请求风暴,却延长连续丢包时的恢复时间。当前规模下,帧历史保留在房间内存并提供区间补发最简单。只有对局更长、每帧更大或重连并发明显上升时,才需要检查点、分层缓存或独立回放存储。
下一篇会继续复用 FrameInputs 和 30Hz Tick,但把焦点放到确定性失败:浮点、容器遍历和系统时间怎样让两个已经收到相同输入的客户端仍然走向不同哈希。