Sign in

    moonroll

    Sans-IO deterministic rollback netcode for MoonBit: input ring buffer, prediction, rollback re-simulation, state checksums, Q16.16 fixed-point math.

    netcode
    rollback
    determinism
    fixed-point
    game
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    6 hours ago
    Downloads
    6

    No dependencies

    #moonroll

    Sans-IO deterministic rollback netcode for MoonBit.

    MoonBit 生态里 ECS、物理、寻路、渲染、行为树都已经有人做,但"把两个玩家连起来还不漂移"这一层是空白。 moonroll 补的就是这一层:不绑定任何引擎的确定性回滚同步(rollback netcode)。

    状态:V0.3(字节协议与网络模拟)。定点数、状态哈希、输入环、预测 / 确认 / 回滚重放、 紧凑字节协议、确定性延迟 / 抖动 / 丢包 / 乱序模拟器已可用。


    #为什么需要定点数

    回滚同步的前提是:同一串输入,在任何后端、任何 CPU 上必须产生逐位相同的轨迹。

    浮点不是不能用:本项目实测 js 与 wasm-gc 后端对同一份代码输出完全相同的位模式 (12 个采样 + 1000 / 10000 步非线性迭代)。但 native 与跨平台仍会受编译器优化、 FMA 收缩、超越函数实现差异影响,所以提供一条纯整数的路径:

    • 加减乘除、开方、插值全部是整数运算,语义由 MoonBit 的 Int64 保证;
    • 不依赖任何浮点库,也就不存在"换个编译器结果就变"的问题。

    #接入方式:4 个方法

    ///|
    pub(open) trait Sim {
    fn save(Self) -> Bytes // 编码完整状态
    fn restore(Self, Bytes) -> Unit // 与 save 严格互逆
    fn step(Self, UInt64, UInt64) // 推进一帧,p0 / p1 是双方输入位图
    fn hash(Self) -> UInt64 // 状态哈希,不一致即 desync
    }

    接入成本约 30~50 行。库不假设任何引擎结构,只在每帧调用这四个方法。

    #快速开始

    moon add weopqrst/moonroll

    // 1) 实现 Sim(略去 save/restore/hash)
    impl @session.Sim for World with fn step(self, p0, p1) {
    self.x = self.x * 31UL + p0 + (p1 << 32)
    }

    // 2) 每个对等体一个会话,local_id 是本地玩家(0 / 1)
    let peer = @session.Session::with_window(world, 0, 128)

    // 3) 每帧推进;收到对端输入时自动回滚重放
    ignore(peer.advance(my_input))
    peer.receive(remote_frame, opponent_input)

    // 4) 双方定期比对
    if peer.hash() != remote_hash {
    // desync
    }

    底层三个包可单独使用:

    let a = @fixed.Fixed::from_double(1.5)
    let b = @fixed.Fixed::from_double(2.25)
    let c = a * b

    let d = @digest.Digest::new()
    d.feed_bytes(b"state")
    let checksum = d.finish()

    let ring = @input.InputRing::new(64)
    ring.set(frame, 0b101UL)

    #字节协议

    线上格式(小端;varint 为 LEB128,帧号小的时候只占 1 字节):

    帧布局
    Input[0x01][varint frame][varint count][varint bits] × count
    Confirm[0x02][varint frame]
    Checksum[0x03][varint frame][8 bytes hash]

    let wire = @protocol.encode(@protocol.Message::Input(
    @protocol.InputFrame::new(42, [0b101UL]),
    ))
    let msg = @protocol.decode(wire) // Some(Input(...))

    // 一条链路每帧只发一个包时,把多个帧塞进同一个包
    let batch = @protocol.encode_batch(msgs)
    for m in @protocol.decode_batch(batch) { /* ... */ }

    encode 不做数量校验(count 由调用方决定),decode 对截断、未知标签、垃圾字节、 超过 8 个玩家的帧一律返回 None,不会 panic。这是「网络输入不可信」的底线。

    #网络模拟器

    let net = @sim_net.NetSim::new(
    @sim_net.Link::new(delay, jitter, loss_pct, reorder_pct, seed),
    )
    net.send(frame, payload) // 可能被丢弃
    for packet in net.recv(frame) { /* ... */ } // 到达的包,按到达时间排序
    net.delivered() // 已交付包数
    net.dropped() // 丢弃包数
    net.in_flight() // 在途包数

    时间单位是逻辑帧(60fps 下 1 帧 ≈ 16.7ms),内部是确定性 LCG: 同 seed、同发送序列必然得到同到达序列,所以整条链路可以进单元测试和 fuzz。

    #性能

    moon bench -p weopqrst/moonroll/session(wasm-gc,Windows x64):

    场景耗时(均值)
    每帧预测 + 深度 16 回滚重放0.51 µs
    每帧预测 + 深度 128 回滚重放14.4 µs

    基准里的状态是 8 字节的 UInt64 计数器,真实引擎的重放成本按「快照大小 × 回滚深度」线性放大, 可以直接用这个数估上限。

    #包结构

    包内容状态
    weopqrst/moonroll/fixedQ16.16 定点数(+ - * /、开方、lerp、clamp)V0.1
    weopqrst/moonroll/digestFNV-1a 64 状态哈希V0.1
    weopqrst/moonroll/input帧号 -> 输入位图的环形缓冲V0.1
    weopqrst/moonroll/sessionSim 协议 + 预测 / 确认 / 回滚重放V0.2
    weopqrst/moonroll/protocol输入帧、确认帧、校验和帧的字节编解码V0.3
    weopqrst/moonroll/sim_net延迟 / 抖动 / 丢包 / 乱序模拟器V0.3

    #验证

    moon test --target wasm-gc moon test --target js moon run cmd/main --target wasm-gc

    43 个测试,两个后端全绿。核心断言:

    • 定点数与 Double 对拍误差不超过 4 个 LSB;
    • FNV-1a 64 对 "hello" 命中标准向量 0xa430d84680aabd0b;
    • 双对等体 3000 帧、2 帧固定延迟:终态哈希与顺序参考实现完全一致;
    • 乱序 + 0~3 帧抖动(不丢包)600 帧:终态哈希仍然一致;
    • 8 条链路曲线(0 / 50 / 100 / 200 / 300ms、±50ms 抖动、5% 丢包、25% 乱序)× 300 帧, 两端终态哈希都等于顺序参考实现;
    • 1000 组随机 (delay, jitter, loss, reorder, seed) fuzz:两端哈希一致;
    • 协议 round-trip:1000 组随机输入帧 + 500 组确认 / 校验和帧逐位还原; 所有前缀截断与随机垃圾字节只返回 None,不 panic;
    • 模拟器同种子可复现(同发送序列 → 同到达序列),抖动确实产生逆序交付;
    • 输入环 / 快照环回绕后只保留窗口内帧,旧帧不会误命中。

    #Roadmap

    • V0.1 基础层:定点数、状态哈希、输入环(已完成)
    • V0.2 回滚会话:预测、确认、快照/恢复、重放(已完成)
    • V0.3 字节协议 + 延迟/抖动/丢包/乱序模拟 + fuzz(当前)
    • V0.4 接入 moonphys 的 wasm-gc 可视化演示(帧时间线、回滚计数、延迟滑块)
    • V1.0 CI 三后端一致性、文档、mooncakes 发布

    #明确不做

    • 真 UDP/TCP 联机(moonbitlang/async 的 socket 不支持 wasm-gc,浏览器内不可用)
    • 服务端权威模式、房间匹配
    • 具体引擎绑定(只提供 Sim 接口,接入成本目标 30 行)

    #License

    Apache-2.0

    Digest

    FNV-1a 64 位状态哈希。

    用途:回滚同步里两侧每 N 帧交换一次哈希,不一致即 desync。 只依赖整数运算与固定的字节序,跨后端天然一致。

    Fixed

    moonroll —— 确定性回滚同步(sans-IO),纯 MoonBit 实现。

    基础层:
    • weopqrst/moonroll/fixed:Q16.16 定点数,整数运算、跨后端逐位一致
    • weopqrst/moonroll/digest:FNV-1a 64 状态哈希,用于检测 desync
    • weopqrst/moonroll/input:帧号 -> 输入位图的环形缓冲

    回滚层(V0.2):
    • weopqrst/moonroll/session:Sim 接入协议 + Session 预测/确认/重放

    协议层(V0.3):
    • weopqrst/moonroll/protocol:输入帧 / 确认帧 / 校验和帧的紧凑字节编解码
    • weopqrst/moonroll/sim_net:确定性延迟、抖动、丢包、乱序链路模拟器

    InputFrame

    紧凑字节协议:输入帧 / 确认帧 / 校验和帧。

    线上格式(小端,varint 为 LEB128):
    Input : [0x01][varint frame][varint count][varint bits] * count Confirm : [0x02][varint frame] Checksum : [0x03][varint frame][8 bytes hash]

    InputRing

    固定容量的输入环形缓冲:帧号 -> 输入位图。

    回滚需要按帧随机存取历史输入,又不能让历史无限增长, 因此用「帧号取模定槽位 + 槽位记录真实帧号」的方式实现: 覆盖写字面量级的常数开销,且不需要链表或动态扩容。

    输入用 UInt64 位图表示(64 个按键 / 摇杆量化位), 一帧一个玩家 8 字节,便于后续直接序列化进协议包。

    链路参数。时间单位是「帧」(逻辑 tick),60fps 下 1 帧约 16.7ms。

    NetSim

    确定性链路:同 seed、同发送序列 -> 同到达序列。

    Session

    回滚会话:持有模拟器、双玩家输入环、窗口内快照,负责预测、确认与重放。

    帧号从 1 开始,0 表示初始状态。frame 是本地已模拟到的最新帧, confirmed 是双方输入都已到达的最大帧(其之前的帧不会再回滚)。

    典型循环(每帧一次):
    1. 本地推进:advance(本地输入)
    2. 把 player_input(本地玩家, 帧) 发给对端
    3. 收到对端输入时调用 receive(帧, 输入)

    Sim

    接入协议:任何可确定性模拟的系统实现这 4 个方法即可接入回滚。

    • save:把当前状态编码成字节快照,必须完整
    • restore:从快照恢复,必须与 save 严格互逆
    • step:推进一帧,p0 / p1 是两个玩家按玩家索引排列的输入位图
    • hash:状态哈希,用于两端比对,不一致即 desync

    四个方法都必须是纯确定性的:同样的输入序列必须得到同样的状态与哈希。 这是回滚同步的唯一硬约束,库无法替你检查,但 Session 会对每帧调用 save,所以「忘了恢复某个字段」会立即表现为哈希不一致。

    SnapshotRing

    帧号 -> 快照字节的环形缓冲。容量固定、按帧号取模寻址, 槽位记录真实帧号,被覆盖后旧帧查询返回 None。

    version

    fn version() -> String

    Source Files