输入完全相同,回滚后子弹为什么射向相反方向?确定性模拟的状态闭包

玩家在 tick 100 向左移动,tick 101 松开方向键并按下攻击。正常运行时,子弹向左发射;客户端回滚到 tick 100,再重放完全相同的 tick 101 输入,子弹却向右飞了。

1
2
原始运行:moveX = 0, attack = true → 子弹向左
回滚重放:moveX = 0, attack = true → 子弹向右

输入没变,固定步长也没变,问题出在哪里?攻击方向还依赖“最近一次瞄准方向”。如果快照只恢复玩家位置,没有恢复这个看不见的历史状态,重放就不是从同一个世界开始。

这正是确定性模拟最容易被误解的地方:相同输入只有在完整初始状态、执行顺序、固定参数和运行环境也相同时,才应该得到相同结果。本文以 Fighting 模拟层为背景,从一次漏恢复 aimX/aimY 的错误出发,讲清 InputCmd、WorldSnapshot、Step、Restore、状态 hash 和环形历史如何形成可回滚的状态闭包。

项目使用 C++20,核心模拟代码不依赖 SDL、socket 或 libevent。当前实现使用 float,因此同一工具链和平台上的可重复运行不等于已经证明严格跨平台确定性。

1. 为什么“位置一样”不代表“状态一样”?

假设两个玩家当前都站在 (3, 5),画面完全相同:

1
2
玩家 A:最近朝左,开火冷却为 0
玩家 B:最近朝右,开火冷却还剩 1 tick

下一帧输入都是“按攻击”,结果会不同。A 可以向左发射,B 可能因为冷却无法发射。位置只是状态的一部分,未来行为还依赖速度、动作计时器、冷却、瞄准、弹道、地图等数据。

判断一个字段是否属于可回滚状态,可以问:

在后续输入完全相同的前提下,改变这个字段是否可能改变未来结果?

如果答案是“可能”,它就必须通过 Snapshot/Restore 闭环保存,或能由快照中其他字段唯一重建。调试 HUD 的颜色、窗口尺寸这类只影响显示的数据则不应混入模拟快照。

2. 确定性模拟需要同时固定哪些条件?

可以把一次模拟转移写成:

1
State(t + 1) = Step(State(t), Inputs(t), fixedDt, Rules)

这不是说 World::Step 是数学纯函数——它会原地修改 World。准确要求是:在相同状态、同 tick 全体输入、相同固定 dt 和相同规则下,状态转移可重复。

条件 常见破坏方式
完整起始状态 快照漏掉冷却、RNG、瞄准或弹道
同一输入序列 环形缓冲覆盖后读到错误 tick
固定步长 把每台机器的真实帧耗时传给 Step
固定执行顺序 遍历无稳定顺序容器并产生顺序相关碰撞
相同数值语义 不同浮点模式、平台数学库或未定义行为
无隐藏外部输入 Step 内读取墙钟、socket、窗口或自由运行随机数

缺少其中任何一项,“相同输入”都不足以证明结果应该一致。

3. 最小可运行版本:漏掉 aim 后,重放怎样分叉?

下面的一维示例保留位置、最近瞄准方向、开火冷却和弹道方向。RestorePositionOnly 是故意制造的错误恢复:它只恢复 tick 和位置,遗漏其余会影响未来的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <cassert>
#include <cstdint>
#include <iostream>
#include <vector>

using Tick = std::uint32_t;

struct InputCmd {
Tick tick = 0;
int move = 0;
bool attack = false;
};

struct Snapshot {
Tick tick = 0;
int position = 0;
int aim = 1;
int cooldown = 0;
std::vector<int> projectileDirections;

bool operator==(const Snapshot&) const = default;
};

class World {
public:
void Step(const InputCmd& input) {
state_.tick = input.tick;

if (input.move != 0) {
state_.position += input.move;
state_.aim = input.move;
}
if (state_.cooldown > 0) {
--state_.cooldown;
}
if (input.attack && state_.cooldown == 0) {
state_.projectileDirections.push_back(state_.aim);
state_.cooldown = 2;
}
}

[[nodiscard]] Snapshot Save() const {
return state_;
}

void Restore(const Snapshot& snapshot) {
state_ = snapshot;
}

void RestorePositionOnly(const Snapshot& snapshot) {
state_.tick = snapshot.tick;
state_.position = snapshot.position;
// 错误:aim、cooldown、projectiles 仍是对象原来的值。
}

private:
Snapshot state_{};
};

int main() {
World authoritative;
authoritative.Step({100, -1, false});
const Snapshot rollbackPoint = authoritative.Save();
authoritative.Step({101, 0, true});
const Snapshot expected = authoritative.Save();

World correctReplay;
correctReplay.Restore(rollbackPoint);
correctReplay.Step({101, 0, true});

World brokenReplay;
brokenReplay.RestorePositionOnly(rollbackPoint);
brokenReplay.Step({101, 0, true});

std::cout << "expected_direction="
<< expected.projectileDirections.back() << '\n'
<< "correct_replay_direction="
<< correctReplay.Save().projectileDirections.back() << '\n'
<< "broken_replay_direction="
<< brokenReplay.Save().projectileDirections.back() << '\n';

assert(correctReplay.Save() == expected);
assert(brokenReplay.Save() != expected);
}

在 macOS 或 Linux 上使用 Clang 编译:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic \
deterministic_world.cpp -o deterministic_world
./deterministic_world

预期输出:

1
2
3
expected_direction=-1
correct_replay_direction=-1
broken_replay_direction=1

错误恢复后的 World 使用默认 aim = 1,所以同一个“无移动 + 攻击”命令向右开火。这个例子说明 Snapshot 不是截图,而是未来状态转移所需的完整检查点(checkpoint)。

4. InputCmd 为什么只能描述意图?

当前项目的 InputCmd 包含 tick、按钮位、水平和垂直方向。它不包含最终坐标、命中目标或 HP:

1
2
InputCmd:我想向左、向上、跳跃或攻击
World::Step:根据规则算出位置、碰撞、弹道和伤害

如果客户端把结果直接放入命令,服务端无法确认这个结果是否由同一规则产生,回放也失去意义。命令保持小而离散,还有三个实际收益:适合 UDP 高频冗余发送;可以按 tick 去重;压力测试可以不依赖键盘直接生成输入序列。

输入本身也有边界。所有玩家交给同一次 World::Step 的命令数必须等于玩家数,tick 必须一致。当前实现发现不一致时记录警告并直接返回,没有向调用者返回失败状态。Demo 可以依靠上层校验和测试保证前置条件,若用于更复杂系统,返回显式错误或在内部测试构建中断言通常更容易发现“某一帧根本没有推进”。

5. 当前 WorldSnapshot 怎样形成状态闭包?

当前快照包含以下数据:

类别 关键字段 为什么影响未来
时间 tick 决定历史与网络状态的对应关系
玩家运动 x/yv/vyonGround 下一帧位置、摩擦与碰撞起点
玩家动作 HP、action、stateTimer、atkActive 伤害、硬直和动作时序
攻击历史 shotCooldown、aimX/aimY 能否开火以及静止开火方向
弹道 位置、速度、life、owner、alive 下一帧移动、撞墙和命中
世界拓扑 mazeSeed、宽高、完整网格 墙体碰撞与出生位置

World::Restore 不只执行 snap_ = snapshot。它还要把快照数据恢复到内部表示:

  • 使用完整 maze 重建内部网格;
  • 根据 aimX/aimY 恢复 lastDirX_/lastDirY_
  • 把快照弹道重新构造成内部 Projectile
  • 再同步快照中的迷宫与弹道视图。

这揭示了另一个常见陷阱:一个状态可能在类内部和 Snapshot 中有两种表示。新增字段时,只给 Snapshot 加成员而没有在 Restore 中重建内部缓存,仍然不是闭环。

6. World::Step 的执行顺序为什么也属于规则?

当前一次 Step 大致按以下顺序执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
验证全体输入数量与 tick

处理玩家硬直计时、目标速度和摩擦

按 X、Y 两轴尝试移动并检测墙体

更新朝向、瞄准、冷却并生成弹道

两两处理玩家 pushbox 重叠

推进弹道,处理撞墙与命中

同步弹道和迷宫到 Snapshot

先移动玩家再推进弹道,与先处理弹道再移动玩家可能产生不同命中结果;先 X 后 Y 的分轴碰撞也会在墙角形成特定滑动行为。执行顺序不是无关的实现细节,一旦进入网络同步规则,就需要由测试守住。

6.1 移动为何不是直接设置速度?

玩家速度向目标速度插值:

1
2
alpha = clamp(friction × dt, 0, 1)
velocity += (targetVelocity - velocity) × alpha

当前速度上限约为 4.5,friction 为 8,固定 dt = 1/60。这种离散插值让启停较平滑。若换成真实帧耗时,不同客户端会执行不同 alpha 序列,即使总时间接近,累计结果也可能不同。

6.2 分轴墙体碰撞解决了什么,又留下什么边界?

代码先尝试 X 位移,再尝试 Y 位移;某轴撞墙就保留该轴位置并清零速度。这比一次性移动到 (nx, ny) 更容易沿墙滑动,也足够支持当前方格迷宫。

不过玩家之间的 ResolvePushbox 在墙体碰撞之后执行,它会直接把重叠玩家向两侧移动,当前没有再次检查新位置是否进入墙体。因此原笔记提出的“pushbox 后重新约束墙体”是有效改进方向。修复时应为墙边多人挤压建立确定性测试,不能只在最后随意 clamp 坐标。

6.3 弹道是离散采样,不是连续碰撞

当前弹道每 tick 先减少 life,再按 velocity × dt 移动到新位置,然后检查墙体和玩家圆形范围。这个次序决定寿命与命中边界。

当弹速或 dt 增大时,弹道可能跨过薄目标而没有在离散终点发生重叠,即 tunneling(穿透)。当前参数下是否出现要用具体地图和速度测试;若未来提高速度,应考虑 swept collision(扫掠碰撞),而不是只增大碰撞半径掩盖问题。

7. 迷宫只同步 seed 为什么不够?

当前迷宫用 std::mt19937std::shuffle 驱动 DFS carve。相同 seed 在同一实现环境中通常能复现,但 C++ 标准并不要求不同标准库的 std::shuffle 得到同一排列序列。因此 seed 不能天然成为跨平台地图协议。

当前项目已经采用更稳妥的边界:StartPacket 发送 maze seed、尺寸和完整权威网格;客户端以该网格初始化世界。周期 State 只携带 seed,客户端从当前对局快照保留完整网格并验证 seed 一致,而不是每次仅凭 State 重新生成地图。

World::Restore 在传入快照没有 maze 时仍保留了“按 seed 重新生成”的回退路径。这个路径适合同版本本地场景,不应在未经跨平台测试时当作网络一致性保证。

如果未来模拟过程中还会随机生成掉落物或散布,只有迷宫 seed 仍不够:要么把随机数生成器状态放入 Snapshot,要么把每次随机结果变成权威事件。当前 RNG 主要用于初始化迷宫,尚未自由参与每 tick 规则。

8. Hasher 为什么先把 float 量化成毫米?

直接 hash float 内存有多个问题:微小舍入差异会产生完全不同 hash;对象表示和字节序不是跨平台协议;-0.00.0 等数值细节也可能制造无意义差异。

当前 Hasher 采用:

1
int32_mm = round(float_value × 1000)

再按明确字节顺序把 tick、玩家、弹道、迷宫等字段混合。网络 State 同样使用毫米整数,因此服务端原始快照和客户端解码快照在同一量化边界对账。

量化的边界必须说清:

  • 小于半毫米的差异可能得到相同 hash;
  • 漏掉的字段不会因为“有 hash”就自动被检测;
  • hash 一致不能证明没有碰撞,也不能证明规则正确;
  • hash 只负责报告量化状态是否一致,不会让浮点计算变得确定。

因此 Hasher 是分叉探测器。定位问题还需要逐字段快照 diff、输入日志和首次 mismatch tick。

9. InputBuffer 与 StateHistory 为什么都要校验真实 tick?

两者都用固定容量环形存储:

1
index = tick % capacity

InputBuffer 保存命令,StateHistory 保存快照。容量为 4096 时,tick 5 和 tick 4101 会落入同一槽位。读取若只看 index,就会把被覆盖的数据误认为目标帧;所以槽位还必须保存 valid 标记和完整 tick,并在 Get 时验证。

两种历史解决的问题不同:

历史 回滚中的作用
InputBuffer 从权威点向后重新执行输入
StateHistory 比较同 tick 预测/权威结果,保存检查点

容量并非越大越好。Snapshot 包含玩家、弹道和迷宫 vector,复制 4096 份可能有明显内存和分配成本。实际项目应根据最大 state delay、玩家/弹道规模和内存分析选择容量,并考虑把不变地图与每 tick 动态状态分离,而不是机械扩大环。

10. 从最小示例走向工程实现,还要守住哪些边界?

10.1 模拟层不能读取不可回放的外部状态

SDL 输入应先转换成 InputCmd,网络包应先转换成确定命令或权威快照。World::Step 不应读取 socket、墙钟、文件或事件循环状态,否则离线 stress test 无法重演同一过程。

10.2 容器顺序必须稳定

玩家当前按 vector 槽位迭代,弹道也保持 vector 顺序。若未来把碰撞对象换成 unordered_map 并在迭代中产生顺序相关副作用,不同运行的哈希桶顺序可能改变谁先命中。需要稳定排序或设计与顺序无关的冲突解决规则。

10.3 删除对象的时机属于语义

当前弹道全部推进后,通过 remove_if 删除 life 为 0 的对象。若改成遍历中 swap-and-pop,弹道顺序可能变化,随后 hash 和同 tick 多弹道命中顺序也会变化。性能优化前必须定义哪些顺序是规则的一部分。

10.4 Snapshot 版本要与协议和回放文件协同演进

在类中新增字段容易,旧 StatePacket、旧录制文件和旧 hash 版本却不知道它。若需要长期兼容,应给协议/回放格式明确版本和迁移策略;当前 v5 网络协议只能在匹配版本间解释。

10.5 跨平台确定性需要专门证据

固定 dt 和 state hash 是必要条件,不是充分证明。需要在目标编译器、架构和优化等级上回放同一输入日志,比较每 tick 的量化状态,并排除未定义行为。不能根据一次同机压测就声称 Windows、macOS 和 Linux 必然完全一致。

11. 应该怎样测试状态闭包?

最有价值的测试不是只调用 Step 看位置,而是验证 restore/replay 等价性:

1
2
3
4
5
6
7
8
路径 A:从初始状态连续执行输入 0..N

路径 B:执行到 K,保存 Snapshot
创建一个全新的 World
Restore(Snapshot K)
重放输入 K+1..N

断言:A 与 B 的完整量化 Snapshot/hash 一致

必须使用“全新 World”,因为在原对象上 Restore 可能意外保留未进入 Snapshot 的隐藏成员,测试仍会假通过。还应覆盖:静止后攻击方向、冷却边界、弹道即将命中、硬直最后一帧、迷宫墙边移动、玩家重叠、环形历史覆盖和最大回滚长度。

项目依赖准备完成后,可以构建并跳过较长性能套件运行核心与压力测试:

1
2
3
4
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure \
-E lab_performance_standard

压测通过说明其覆盖场景没有发现分叉,不代表所有输入空间都已证明。每增加确定性字段或改变 Step 顺序,都应补一个能在旧错误实现上失败的回归用例。

12. 什么时候适合使用快照加确定性重放?

它适合状态规模可控、Step 足够快、输入可离散记录、需要预测校正、复盘或断线恢复的实时模拟。除了游戏回滚,同样思想也常见于事件溯源、仿真调试和确定性测试。

如果模拟依赖大量不可逆外部副作用、单步成本无法在帧预算内重算、状态巨大到无法保存,或者第三方物理引擎不能提供所需确定性,就需要采用插值、局部权威、状态增量或其他同步方案。不要把 Snapshot 理解为免费复制整个进程内存。

13. 总结:可回滚的不是画面,而是完整状态转移

开头的子弹之所以改变方向,不是输入日志失效,而是回滚起点缺少最近瞄准方向。要让重放可靠,需要同时守住:

  1. InputCmd 只记录带 tick 的玩家意图,所有结果由统一 Step 计算。
  2. Snapshot/Restore 覆盖所有影响未来的显式状态和内部缓存。
  3. 固定 dt、稳定执行顺序和可回放随机性共同构成确定性边界。
  4. 环形历史用完整 tick 防止槽位覆盖误读,Hasher 用量化状态检测分叉。
  5. restore/replay 等价测试必须从全新 World 开始,才能暴露隐藏状态。

最直接的实践建议是:每新增一个模拟字段,先写一句“它是否影响下一帧”,再检查 Step → Snapshot → 网络/录制 → Restore → Hasher → replay test 六个位置。只改其中一处的状态,迟早会在回滚后变成另一条历史。

14. 当前源码阅读入口

以下路径相对于 Fighting 项目根目录:

  • include/lab/sim/InputCmd.h:tick、按钮和移动输入;
  • include/lab/sim/StateSnapshot.h:玩家、弹道与世界快照;
  • include/lab/sim/World.hsrc/sim/World.cpp:Step、Snapshot、Restore、迷宫与弹道;
  • include/lab/sim/Rules.h:玩家 pushbox 规则;
  • include/lab/sim/InputBuffer.hsrc/sim/InputBuffer.cpp:输入环形缓冲;
  • include/lab/sim/StateHistory.h:状态历史;
  • include/lab/sim/Hasher.hsrc/sim/Hasher.cpp:毫米量化状态 hash;
  • tests/core_tests.cpptests/stress_tests.cpp:基础一致性和回滚压力验证。