# 架构与决策记录

这份文档回答三个问题：每一层为什么存在、关键取舍是什么、哪些方案被明确否决了。

如果你只想跑起来，看 `README.md`；想改代码，先看这里的「一帧的生命周期」。

## 一句话定位

moonroll 把「确定性模拟 + 输入冗余重发 + 落后帧回滚重放」做成一个 sans-IO 库：
它不碰 socket、不碰渲染、不关心你的世界长什么样，只要求你提供一个能
`save` / `restore` / `step_players` / `hash` 的 `Sim`。

## 一帧的生命周期

```
前端采样输入
   │
   ├─ Session::advance(input)
   │     ├─ 记录本地输入到 rings[local]
   │     ├─ frame += 1
   │     └─ simulate(frame): sim.step_players(inputs) → 存快照 + 存哈希
   │
   ├─ 编码：一条 InputSpan（区间 + 变化掩码）+ 确认帧 + 校验和帧
   │        → encode_batch → 发送
   │
   └─ 收到某个玩家的包
        ├─ Input 帧：若该帧已模拟过 → replay_from(该帧)（回滚）
        │              否则 → 只记录，等模拟到该帧再用
        ├─ InputSpan 帧：expand() 成逐帧输入，之后同上
        ├─ Confirm 帧：更新对端水位
        ├─ Checksum 帧：只在本地也确认过的帧上和 hash_at 比对，不一致就报告分叉
        ├─ Active 帧：改在线集合（掉线 / 重连），立刻重算确认帧
        ├─ SnapAsk 帧：把「请求的那一帧」记下来，下一包回一条 Snap
        └─ Snap 帧：resync_from(帧号, 状态) → restore + 重放到当前帧（分叉恢复）
        ```

`sans-IO` 的代价是调用方要自己驱动收发；收益是整条链路能在单进程里跑，
所以 166 个测试里绝大多数都是「两个 Session + 两条模拟链路」的完整对局，
外加一组 4 人局、掉线场景、带宽策略与分叉重同步的会话测试。

玩家数是 `Session` 的构造参数（1..8），内部每个玩家一条 `InputRing`，
`step_players` 收到的是按玩家索引排好的输入表；确认帧只有在**所有在线玩家**的
输入都到齐时才前进，所以回滚深度由「最慢的那个在线玩家」决定。

## 逐模块

### `fixed`：Q16.16 定点数

跨后端可复现的前提。`wasm-gc` 和 `native` 对 `f64` 的 FMA 合并、中间精度处理
并不保证一致，一个 `a * b + c` 就可能出现末位差异，而回滚要求「同一输入
必须得到逐位相同的状态」。定点数把这类问题从根上消掉，代价是要自己实现
`mul` / `div` / `sqrt` 和饱和。

### `digest`：64 位状态哈希

用于两个目的：本地快照去重（可选）和两端一致性探测。用 64 位是因为它只是
「探测」而不是「防伪造」——1800 帧的对局里发生一次碰撞的概率可以忽略，
而 64 位刚好能塞进协议的一个 varint。

### `input`：输入环与快照环

帧号是连续的，所以两者都是定长环形数组：`slot = frame % capacity`，写入时
顺带记下 owner 帧号，读取时比对帧号就知道槽位是不是别人的。相比 `HashMap`
省掉了哈希和分配，且在 wasm 里行为完全可预测。

窗口之外的帧会被自动覆盖——这正是「快照最多回滚 N 帧」的实现，也是
`hash_at` 在超出窗口后返回 `None` 的原因。

### `session`：状态机

`Session` 持有 `Sim`、每个玩家一条 `InputRing`、每个玩家一个「连续收到到哪一帧」
的指针 `cont`、一个在线集合 `active`、一个复用输入表、`SnapshotRing`、`HashRing`。
玩家数在构造时固定（1..8），双人局仍可用 `with_window` + `receive`
的短写法，3 人以上必须 `receive_from(player, frame, input)`。

- **确认帧**的判定：`confirmed = min(cont[i]) for i in 在线玩家`。不需要单独的
  确认包——输入包里带玩家索引和帧号，收到即有信息。用每个人的连续指针取最小值，
  比「每帧从 confirmed 往后逐帧扫所有人的环」少一层循环，语义完全等价。
- **掉线**（V0.7）：`set_player_active(player, false)` 把人移出在线集合，确认帧
  立刻跳到当前帧，不再等那个永远不来的输入；`true` 是重连，把该玩家的连续指针
  抬到当前确认帧，于是掉线期间的空洞不会被误判成「缺帧」，确认帧也不会回退。
  「什么时候算掉线」不进库：超时阈值、房主踢人属于上层策略，库只保证
  **所有会话用同一个在线集合推进**，并把决定通过 `Active` 帧同步出去。
  掉线者的槽位仍然保留（`step_players` 的输入表长度不变），其输入退化为
  「窗口内最近一次已知值、否则 0」——纯函数式的确定性规则，两端算出来必然相同。
- **对端水位**（V0.8）：收到 `Confirm` 时 `receive_confirm(player, frame)` 把
  `heard[player]` 抬到 `frame`（只前进，且钳到本地已模拟帧）。它不参与确认帧的计算，
  只回答一个问题：**「这个包要重发到哪一帧为止」**。所以迟到的、重复的、伪造的确认
  最多让区间变短或变长，不会破坏状态一致性——这一点由 `bandwidth` 示例的五种策略
  逐项相等的回滚/重放次数证明。

- **输入表**：每帧模拟前把 `rings[i]` 里已知的输入填进表里，缺失的做预测，
  然后调一次 `step_players`。表由会话复用，所以 `Sim` 实现不能把它存下来。
- **回滚**：`replay_from(base)` 先 `sim.restore(snapshots[base])`，再逐帧
  `simulate` 到当前帧。所以「恢复必须精确」不是可选项：漏一个字段，重放就会
  得到不同的世界。
- **历史哈希**（V0.4 新增）：每次 `simulate` 都记录 `hash_at(frame)`。分叉判定
  必须比**已确认帧**上的哈希，因为两端当前帧的预测输入合法地不同。
- **分叉恢复**（V0.9）：`export_state(frame)` 取快照窗口内某一帧的完整状态字节，
  `resync_from(frame, state)` 用它做 `restore`，再把 `frame+1..当前帧` 逐帧重放，
  最后把在线玩家的 `cont` 抬到 `frame`。两个前置条件都不是保守，而是正确性要求：
  恢复点必须在快照窗口内（更早的输入已被 `InputRing` 淘汰，重放会读到预测值，
  等于换一个地方重新分叉），且必须 ≤ 当前帧（未来帧还没有输入可重放）。
  抬 `cont` 是为了让后续回滚的 base 永远 ≥ 恢复点，不会退回去用分裂前的快照；
  确认帧本来就只前进，所以这一步不会造成回退。
  注意它**不是**回滚：回滚从本端快照恢复，而本端快照里就是那份错的世界，再算一遍
  只会得到同一个错；整段替换是唯一能真正对齐状态的动作。代价是恢复点之前的历史
  哈希不会重写——重同步修的是未来。

### `protocol`：字节编码

七类消息：`Input`（玩家索引 + 帧号 + 输入位图）、`InputSpan`（玩家索引 + 起始帧 + 帧数 + 变化掩码 + 值）、
`Confirm`（帧号）、`Checksum`（帧号 + 哈希）、`Active`（玩家索引 + 上/下线标志）、
`Snap`（帧号 + 长度 + 状态字节）、`SnapAsk`（帧号）。
`Input` 里的 `inputs[i]` 是玩家 `player + i` 的输入，所以「一个人发一帧」是
`player=自己, count=1`，将来要做多人合并发包也不用改格式。
全部 varint（LEB128），一个批包以消息条数开头，后面按 `tag` 分派。
`decode_batch` 对截断输入返回「已解析的部分」，这样半包不会毒化整条连接。

`InputSpan`（V0.8）解决的是另一个维度：**同一个玩家、连续多帧**。回滚同步必须重发
最近若干帧的输入，而「一个人一帧一条 `Input`」要为每帧付 1 字节 tag + 1 字节玩家 +
帧号，16 帧就是 64 字节。换成一条区间帧后只剩一份头，再叠一层变化掩码：

```
InputSpan : [0x05][varint player][varint start][varint count][varint mask][varint bits] * popcount(mask)
```

`mask` 第 i 位为 0 表示 `start + i` 帧沿用包里前一个值。这样「按住同一个方向 16 帧」
在线上是「1 个值 + 1 个掩码」。解码端用 `expand()` 展开成逐帧输入，
校验四件事：首帧必须置位（否则这段没有起点值）、`count ∈ 1..64`（掩码只有 64 位）、
`start ≥ 1`、掩码不能越出 `count`。任何一条不满足就返回 `None` 而不是猜。

`Snap`（V0.9）把「某一帧的完整状态」原样搬上线路：`[0x06][varint frame][varint len][len 字节]`。
协议层不解释这段字节——它是 `Sim::save` 的产物，只有跑同一个 `Sim` 的对端读得懂，
所以 `Snap` 是**可信对端之间**的帧，不是公开数据。解码端只做三件事：`frame ≥ 1`、
`len ≤ 64 KiB`、`len` 不能超过包里剩余的字节数。限长是为了让收到 `len` 的瞬间就能
拒绝畸形包，而不是先分配一块任意大的内存。`SnapAsk` 是同一件事的请求半边，
单独成帧是因为「报告不一致」可以每帧都做，而「请求重同步」只该在确认落后时做一次。

### `sim_net`：确定性链路模拟

`Link(delay, jitter, loss_pct, reorder_pct, seed)` 内部是 LCG，同种子同发送序列
必然得到同到达序列。抖动是通过在 `[0, jitter]` 上抽取额外延迟实现的，所以
后发的包确实可能先到——测试里专门断言了「逆序交付确实发生」。

这不是为了「好看」：如果链路不确定，端到端测试就只能 flaky。

### `adapters/phys`：moonphys 适配

`PhysSim` 把 `moonphys.World` 包成 `Sim`：

- `save` → `World::save_state()`（位姿、速度、暖启动冲量、世界时钟）
- `restore` → `World::load_state()`（**重建**一个从未运行过的世界，而不是原地改字段）
- `hash` → `World::state_hash()`

这一步暴露了一个重要细节：如果 `save` 只存「刚体引用」，`restore` 之后两端
共享的还是同一份内存，回滚就成了空操作。存成字节、由 `load_state` 重建，
是唯一能保证「恢复即隔离」的做法。

### `demo`：wasm-gc 演示引擎

两个独立物理世界 + 两条链路，每帧互发最近 8 帧输入。导出全是数值 ABI
（`i32` / `f64`），因为 JS 侧不需要理解 MoonBit 的字符串或结构体——这层
胶水越薄，页面越不容易和库版本漂移。

`mr_selftest()` 内置了 1800 帧的验收跑：200ms 延迟 + 50ms 抖动 + 25% 乱序，
要求 `desync == 0`。

## 关键取舍

| 决策 | 被否决的方案 | 为什么 |
| --- | --- | --- |
| 快照是不透明 `Bytes` | 要求 `Sim: Clone` 并 clone 对象 | clone 出来的对象往往共享内部 `Arc`，`restore` 后状态还是脏的；强制序列化能逼作者想清楚「哪些字段属于状态」 |
| 全量快照 + 重放 | 增量快照 / 逆操作回滚 | 逆操作要求每个系统都写对称的 undo，耦合度高、容易漏；128 帧窗口每帧几十字节，内存完全可接受 |
| 定点数 Q16.16 | `f32` / `f64` | 浮点在 wasm 与 native 之间的 FMA、舍入差异会让回滚后的世界对不上；这是「能不能用」的问题，不是精度问题 |
| 分叉判定比已确认帧历史哈希 | 比当前帧哈希 | 两端当前帧的预测输入合法地不同，比当前帧会产生大量假分叉。V0.4 实测 1800 帧里报了 517 次假阳性 |
| 输入冗余 8 帧 | 每条输入单独重发直到确认 | 冗余 8 帧后一次性补齐，包数从 O(帧数) 降到 O(1)/帧，实现也简单 |
| 校验和单独发 `Checksum` 帧 | 塞进 `Input` 包 | 混在一起会让「输入」和「状态确认」耦合，出问题时无法区分是输入错还是模拟错 |
| 链路模拟器用 LCG | 系统 `rand()` | 端到端测试必须可复现，否则 CI 会随机红 |
| wasm 只导出数值 ABI | 导出结构体 / 字符串 | 页面侧零胶水，且不会因为库内部结构变化而崩 |
| `step_players(Array[UInt64])` 单一入口 | 保留 `step(a, b)` 两人签名 + 另一套 N 人 trait | 两套签名会让「2 人特化」和「N 人通用」走不同代码路径，回滚时最容易出分叉的就是这种分叉路径；统一成输入表后 2 人局只是长度 2 的特例 |
| 输入表由会话复用 | 每帧新建数组 | 128 帧回滚 × 8 玩家的小数组分配在 wasm 上没有必要；代价是 `Sim` 不能持有该数组，已写进文档与 trait 注释 |
| 确认帧要等**所有**玩家 | 少数服从多数 | 少数服从多数会把「还没算过的帧」当成已确认，直接毁掉回滚的正确性 |
| 掉线 = 移出在线集合，槽位保留 | 从玩家数组里删掉那个人，`step_players` 少一路输入 | 删槽位会改变 `step_players` 的入参形状，所有 `Sim` 实现和已存快照都要跟着变；保留槽位把「谁是第几个玩家」钉死，掉线只影响确认帧与预测，状态结构不动 |
| 掉线的输入用「最近已知值 / 0」 | 让模拟器跳过这个玩家这一帧 | 跳过等于改变 `step_players` 的语义；用确定性预测规则可以让两端算出同一个世界，不需要额外同步 |
| 在线集合的变更由上层触发、库只提供同步点 | 库内部按超时自动踢人 | 超时阈值是产品决策（语音掉线 vs 手滑暂停）；库自动踢人会让「谁在什么时候掉线」变得不可测，也违反 Sans-IO |
| 区间帧 + 变化掩码，而不是给整包做通用压缩 | 把整包丢给 deflate | 包只有十几字节，通用压缩的字典头就吃掉收益；而且掩码让「哪一帧变化」显式可读，解码端能逐帧校验 |
| 冗余区间长度可由对端水位决定 | 固定重发 N 帧 | 固定值只能在「省带宽」和「抗丢包」之间二选一，而且要人肉调参；水位法在链路正常时自动收窄、丢包时自动撑开，代价是多了 `Confirm` 的记录逻辑 |
| 校验和降到每 8 帧一次 | 每帧都带 8 字节哈希 | 每帧校验把 12 字节的包撑到 22 字节，而分叉是「一旦发生就一直在」，晚 8 帧发现不会更糟 |
| 分叉用整段状态替换 | 加大回滚深度 / 重放更多帧 | 回滚的起点是本端快照，错的世界重放多少次还是错的；只有外部权威状态能真正对齐 |
| 恢复点必须在快照窗口内 | 允许回到任意历史帧 | 更早的输入已被输入环淘汰，重放会读到预测值，结果是换个地方重新分叉 |
| 状态帧不透明且限长 64 KiB | 协议层定义世界的字段 / 允许任意长度 | 协议与 `Sim` 解耦，代价是只有同 `Sim` 读得懂；限长让畸形包在读到长度时就被拒绝 |
| 谁算权威由上层决定 | 库内自动选源（多数票 / 房主） | 两人局里「本地执行了 A 的那端」是显然的权威；多人局的投票规则属于产品策略，且两边同时互相恢复会来回覆盖 |

## 已知限制

- **带宽已经不再是「每帧重发 8 帧」的固定成本**（V0.8）：4 人局从 105 字节/包
  降到 12 字节/包。但每帧对每个对端仍要发一个包，没有做「一包多对端广播」，
  也没有做跨包压缩。
- **掉线要上层显式声明**：V0.7 给了 `set_player_active` 与 `Active` 帧，但「多久没
  发包算掉线」不在库里。上层忘了调，全房确认帧依然会停在那一帧（`examples/drop_out`
  就是把这两种情况并排跑给你看）。
- **重连期间的空洞靠上抬指针跳过**：`set_player_active(player, true)` 把连续指针
  抬到当前确认帧，掉线那段输入按预测规则处理；如果上层要求「重连者补齐历史输入」，
  需要自己追发那一段。
- **玩家数仍然固定**：掉线不减少槽位，也不支持中途加入新的第 N+1 人
  （那要求 `Sim` 支持可变输入表长度，是 1.0 之后的事）。
- **重同步只修未来**（V0.9）：恢复点之前的旧哈希留在本地，不会被重写；要更早一致，
  只能在更早的确认帧上恢复，而那要求输入还没被输入环淘汰。
- **没有权威选举**：`resync_from` 由上层指定「谁的状态算数」。两人局里很直观，
  三人以上要么按房主定、要么按哈希多数票——库内不做，避免两个对等体同时互相恢复。
- **不做反作弊**：64 位哈希只用于一致性探测，不防伪造；也没有延迟/输入频率分析。
- **物理快照不支持关节**：`moonphys::World::load_state` 对含关节的世界直接拒绝
  （关节约束需要额外的求解器状态，当前没有序列化）。
- **回滚深度未做上限自适应**：窗口固定 128 帧（约 2.1 秒），超过就退化为
  「放弃回滚、直接接受分叉」。

## 还没做的

V1.0 收尾（在线演示、一键验收、mooncakes 发布）都已完成，剩下这些是刻意留白：

- 中途加入：把「玩家集合」真正做成可变集合（输入表长度随之变化），并提供 join 的显式 API。
- 权威选举：V0.9 的重同步由上层指定恢复源，多人局应该升级成「比对哈希、按多数票选源」。
- 包合并：把「输入 + 确认 + 校验和」合成一个定时器触发的突发包，进一步摊薄头开销。
