Build dossier · 20

工程深度9 分钟阅读

Game 回滚联机:输入、Checkpoint 与状态同步

记录网页版 NES 联机如何用确定性状态、逐帧输入、Checkpoint 和状态哈希完成回滚与重新同步。

#回滚联机 · #WebRTC · #WebAssembly · #Rust · #状态同步 · #NES2026/GAME-CHECKPOINT

两台浏览器连上以后,最容易实现的同步方式,是每一帧都等双方按键到齐再运行。实际玩起来,这等于把网络延迟直接加到每次操作上;网络偶尔抖一下,整个游戏也会跟着停。

当前项目改成了一个小型回滚方案。本地输入到了就继续运行,远端输入没到时先预测;真实输入到达后如果与预测不同,就回到旧状态重新跑到当前帧。

这套做法依赖一个前提:同一个 ROM、同一个模拟器版本和同一串输入,必须得到同一个结果。状态同步不是把画面发给对方,而是让双方恢复到同一个模拟器状态,再各自在本地继续运行。

“同步状态”不是传视频

传统云游戏可以在服务器运行游戏,再把视频画面推给客户端。这里不是这种方式。两个浏览器各自运行一份完整 NES 模拟器,网络主要传手柄输入。

一帧 256×240 RGBA 画面约 240 KiB,连续发送既浪费带宽,也无法让客机真正获得 CPU、PPU 和 Mapper 的内部状态。相比之下,NES 每帧的两个手柄输入各自只有 8 bit。

因此联机的基本模型是:

初始状态 + 每帧输入 = 后续所有状态和画面

只要模拟器是确定性的,双方从同一起点读取同一串输入,就会独立算出相同画面。网络只在开始、恢复或发现分歧时发送完整状态。

确定性保证相同输入得到相同结果

这里的确定性不是“通常看起来一样”,而是模拟器下一步只由当前状态和输入决定。系统时间、随机数、浏览器帧率和音频设备状态都不能混进 NES 状态。

Rust 核心中的 Nes 包含 CPU、PPU、APU、Cartridge、RAM、控制器移位寄存器、总线状态和 DMA 状态。这些会影响后续执行的字段都参与序列化。

即时存档外面还有一层 envelope:

struct StateEnvelope {
    magic: [u8; 8],
    version: u16,
    build_id: String,
    rom_sha256: String,
    region: Region,
    payload: Vec<u8>,
}

载入时会依次检查 magic、状态格式版本、核心 build ID、ROM SHA-256 和区域。任意一项不同都拒绝恢复。

coreBuildId 不是手工写的版本号。构建脚本会读取 nes-core 的 Cargo 清单和 Rust 源文件,按稳定顺序计算摘要。核心源码变化后,旧存档和旧联机端会自动变成不兼容。

当前状态使用 bincode 1.3.3 序列化。这是项目内部格式,不是承诺长期兼容的公共文件标准;结构变化时,仍由外层 version 和 build ID 阻止误读。bincode 1.3.3 文档

Checkpoint 保存可重放的完整状态

Checkpoint 可以理解成模拟器在某一帧开头拍下的完整快照。它不是截图,而是 CPU 寄存器、内存、PPU 扫描位置、Mapper bank、IRQ counter 等可继续运行的状态。

WASM 层给 Rust 状态增加了一组 checkpoint 槽位。checkpoint() 保存当前状态并返回整数 handle,restore_checkpoint() 按 handle 恢复,使用完后再释放。

Worker 维护最近 12 帧 checkpoint:

  • 帧号;
  • WASM checkpoint handle;
  • 当时采用的 P1、P2 输入。

窗口之外的 checkpoint 会释放。12 帧是当前版本为浏览器内存和可恢复延迟做的取值,不是协议规定。差异超过窗口时,项目会转向完整状态重同步。

Game 回滚和状态同步流程:本地预测远端输入,输入不同时恢复 checkpoint 重放;hash 不一致或超出窗口时由房主发送权威状态
短距离差异使用 checkpoint 回滚;无法局部修复时暂停双方,由房主发送完整状态重新对齐。

远端输入预测

NES 手柄只有上、下、左、右、A、B、Start 和 Select 八个按键,当前帧可以压成一个 byte。绝大多数相邻帧的输入不会剧烈变化:玩家按住右键时,下一帧通常仍是右。

因此远端输入没到时,项目沿用最近一次收到的值。预测不需要永远正确,只需要在网络包到达前让本地先运行。预测错了再回滚。

当前联机固定 2 帧输入延迟。Worker 在运行帧 F 时,使用为 F - 2 准备的输入。主动留出两帧时间,会减少短暂网络抖动触发回滚的次数,但也增加两帧操作延迟。

输入包冗余最近几帧

input DataChannel 配置为无序、零次重传。每个 packet 不只带当前帧,而是附带最近几帧:

{
  type: "input",
  frame: 104,
  values: [
    { frame: 104, input: 0x01 },
    { frame: 103, input: 0x01 },
    { frame: 102, input: 0x00 }
  ]
}

一个包丢失后,后续包仍可能把缺失输入补回来,不用等待底层重传已经过期的实时消息。完整状态和同步控制则走可靠有序的 control DataChannel。

这种冗余不是纠错码,只是利用输入数据很小的特点重复携带最近历史。它不能解决长时间断网。

输入分歧触发回滚

每帧运行前,Worker 记录这次采用的双方输入并保存 checkpoint。真实远端输入到达后,再逐帧与历史记录比较。

如果输入一致,不做任何事。如果不同,就找到最早的分歧帧:

  1. 恢复该帧之前的 checkpoint;
  2. 把预测值替换成真实远端输入;
  3. 从分歧帧重新运行到当前帧;
  4. 中间帧不输出画面和音频;
  5. 到当前帧后恢复正常渲染。

回滚的本质不是撤销某一个按键,而是恢复完整状态后,用修正过的输入重放。GGPO 对 rollback 的说明也是这个思路:先预测未到输入,出现差异后从旧状态重新模拟。GGPO

如果回滚 5 帧,浏览器要在一个显示周期附近额外执行这 5 帧。NES 核心足够小,才有机会把重放藏在正常画面之间。回滚太深或设备性能不足时,仍可能表现为卡顿。

状态 hash 发现运行分歧

输入一致不代表实现一定没跑偏。浏览器暂停、恢复错误、遗漏状态字段或者联机代码 bug,都可能让两边从某一帧开始产生不同结果。

Worker 每 60 帧对完整 Nes 状态序列化并计算 SHA-256,截取前 64 bit 作为状态 hash。双方通过 control DataChannel 交换 frame 和 hash。

Hash 可以把很大的状态压成一个短指纹。它不能告诉我们具体哪个字段错了,只能快速回答“这两个状态是否很可能相同”。同一帧 hash 不同后,应用不继续猜原因,而是进入完整恢复。

64 bit hash 用于在线发现差异,不是密码学身份,也不替代 ROM 的完整 SHA-256。理论上仍可能碰撞。

完整状态分块传输

房主是权威状态来源。两条 DataChannel 第一次打开时,房主暂停模拟器,导出当前状态并发送给客机。hash 不一致、回滚窗口不够或 RTC 重建后,也走同一套流程,只是类型从 initial 变成 recovery。

状态按 48 KiB 分块。每块有固定二进制 header,记录:

  • magic;
  • initial 或 recovery;
  • sync ID;
  • 状态对应帧号;
  • chunk index;
  • chunk count。

接收端限制单次状态最多 4 MiB、最多两个未完成 assembly,并在 10 秒后释放未收齐的分块。收齐后按 chunk index 拼装,再交给 Worker 载入。

分块不是因为 DataChannel 完全不能发大消息,而是为了控制单条消息、内存占用和异常输入。接收端在分配完整缓冲区前就能拒绝超过上限的 header。

ack 与 resume 完成同步闭环

状态发完不等于双方已经对齐。客机还要经过分块组装、WASM 载入、Worker 历史清理和 framebuffer 重绘。

当前过程分成三步:

  1. 房主发送状态并保持暂停;
  2. 客机载入完成后发送 sync-ack;
  3. 房主收到 ack 后发送 resume,双方才继续。

每轮都有递增的 syncId。迟到的旧 ack 和 resume 不会推进当前同步。应用还会等载入后的第一张有效帧真正绘制出来,避免状态提示已经恢复,画面却仍是旧帧。

如果状态发送、ack 或渲染等待超时,当前实现会尝试恢复 RTC 会话或报告失败,不会让其中一端单独继续跑。

当前还没解决完的部分

  • 它依赖模拟器本身确定性;遗漏一个计数器,网络层无法补救;
  • 房主是权威来源,当前没有仲裁,也不能判断房主状态是否更正确;
  • 12 帧 checkpoint 和 2 帧输入延迟是固定参数,没有按 RTT 动态调整;
  • 还没有系统记录不同延迟、抖动和丢包率下的回滚次数与体验;
  • 64 bit 在线 hash 存在理论碰撞可能;
  • 完整状态重同步会暂停游戏,不是无感修复。

目前这套实现把三件事分开:DataChannel 负责送数据,checkpoint 负责修复短距离输入差异,完整状态同步负责把已经跑偏的两端重新拉回同一帧。真正让它成立的,还是底下那个可以保存、恢复和确定性重放的 Rust NES 核心。