[P1][CR-S4~S7] 锁步联机:desync 不处理、超时非按 peer、状态哈希覆盖不足、Transport 细节、net-simulation 自创数据 #519

Open
opened 2026-09-29 06:43:54 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 S4–S7|基线 e475c2e

如果 M20 决定用 D2GS 服务端联机取代 P2P 锁步,本 issue 可以在做出决定时直接关闭。

S4 desync 只记录不处理;超时是全局的

  • desync 分支是死代码。StepOutcome 的 desync 变体从来没有被产生过,全仓只有类型定义(lockstep.ts:63-66)。
  • hash 消息的 peer 字段被忽略。远端 hash 只按 tick 存储(netplay.ts:502-504),导致:
    • 3 人以上时,不同 peer 的 hash 互相覆盖;
    • desync ??= 只记第一次,一条伪造的 hash 就能永久占住报告。
  • 超时是全局的。收到任何 peer 的消息,silentTicks 都会清零(netplay.ts:469-470),超时判断(netplay.ts:515-521)因此不区分 peer。4 人局里一人掉线,只要其他人还在发消息,就永远不会超时,锁步会一直卡在等那个人。
  • bye 处理过粗。任一 peer 发 bye,整个会话就被标记为 closed(netplay.ts:506-508),但锁步仍在等那个人的输入。

S5 状态哈希覆盖不足,实现重复

问题都在 computeLockstepHash(lockstep.ts:266-370)里:

  • HP 小数被截掉:mix(player.hp) 经 & 0xFFFFFFFF 截掉了小数(lockstep.ts:269-272、lockstep.ts:288),怪物 HP 也一样(lockstep.ts:297)。HP 的小数分歧检测不到。
  • 覆盖不全:没有纳入 mana、RNG 状态、投射物和状态效果。
  • 量化不一致:背包坐标没有量化,地面坐标却做了 round(lockstep.ts:311-312 对比 lockstep.ts:337-338)。
  • 不必要的 any:(entry.item as any).uniqueId(lockstep.ts:321),这里的类型其实可以补齐。
  • 重复实现:FNV-1a 在同一个函数里写了 5 遍,而且在 25Hz 的热路径上为每个条目新建闭包(lockstep.ts:307-309)。
  • 宽容判断:对必传参数做 if (inventories) 这类判断(lockstep.ts:302),与 AGENTS.md §3 相悖。

S6 客户端 Transport 细节

中继侧的问题见 #517。客户端这边:

S7 net-simulation 自创数据

单机路径遇到未知怪物会直接 throw(engine.ts:1241),这里的策略正好相反。

修复指南

  1. desync:

    • 要么真正产生 desync 变体:step() 比较到分歧时返回它,pump() 停止推进并上报;
    • 要么删掉这个变体和相关文档。

    推荐前者,并配一个 resync 策略。最简单的做法是回退到最近一次一致的快照重放;或者直接结束对局,上报 tick、双方 hash 和双方快照的 diff。

  2. 按 peer 记账:

    • 为每个 peer 记录 lastHeardTick[peer]、hashes[peer][tick]、byeFrom[peer],超时按 peer 判断。
    • 某个 peer 发 bye 或超时后,用确定性的规则处理:填空输入,或把它移出 peers。
    • 所有 peer 必须在同一个 tick 做出相同的决定,所以"移除 peer"本身也要作为一条锁步输入。
  3. 哈希:

    • 抽出一个 Fnv32 工具,热路径上不新建闭包(和 #527 的 FNV 去重一起做)。
    • 最先哈希 RNG 状态(loot、castRng)。这是最廉价、最早能暴露分歧的信号。然后再加 mana、投射物和状态效果。
    • 所有浮点先按统一的定点规则量化(例如 Math.round(v * 256)),背包坐标也一样。
    • 参数改为必传,去掉 if (inventories) 这类判断。
  4. Transport:

    • message 监听器里出错时,用 socket.close(1003) 或 socket.close(1009) 关闭连接并回调会话,不要 throw。
    • 用一个 closed 标志保证 closeHandler 只调用一次。
    • send 在连接未打开时返回 false 或排队,由调用方决定,不要静默丢弃。
    • CONNECTING 状态下的 close() 也要生效。
  5. net-simulation:

    • 怪物、经验、TC 全部经 data-registry 读取(MonStats.txt、Experience.txt、TreasureClassEx.txt),未知 key 直接 throw。
    • 地图尺寸来自真实关卡。
    • 长期应让联机和单机共用同一个模拟内核(#525)。

验收标准

  • 注入一条分歧的 hash 后,pump() 返回 desync 并停止推进;报告里包含 tick 和双方的 hash。
  • 4 人局中 1 人静默:该 peer 在 timeoutTicks 后被判超时,其他人不受影响。
  • 哈希对以下两种差异都敏感:
    • 只改 RNG 种子的两个世界,hash 不同;
    • HP 只差 0.5 的两个世界,hash 不同。
  • error 和 close 同时发生时,closeHandler 只被调用一次(有测试覆盖)。
  • net-simulation.ts 中不再有 DEMO_* 常量和兜底 TC。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #518:输入校验。
  • #517:中继。
  • #523:模拟的确定性。
  • #525:统一模拟内核。
> 来源:#516|优先级 P1|评审编号 S4–S7|基线 `e475c2e` > > 如果 M20 决定用 D2GS 服务端联机取代 P2P 锁步,本 issue 可以在做出决定时直接关闭。 ## S4 desync 只记录不处理;超时是全局的 - **desync 分支是死代码**。`StepOutcome` 的 `desync` 变体从来没有被产生过,全仓只有类型定义([lockstep.ts:63-66](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L63-L66))。 - 但 `pump()` 的文档说它会返回 desync([netplay.ts:323-328](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L323-L328))。 - 实际发现分歧后,只是写入 `desyncReport`([lockstep.ts:204-212](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L204-L212)),世界照常运行。 - **hash 消息的 `peer` 字段被忽略**。远端 hash 只按 tick 存储([netplay.ts:502-504](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L502-L504)),导致: - 3 人以上时,不同 peer 的 hash 互相覆盖; - `desync ??=` 只记第一次,一条伪造的 hash 就能永久占住报告。 - **超时是全局的**。收到**任何** peer 的消息,`silentTicks` 都会清零([netplay.ts:469-470](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L469-L470)),超时判断([netplay.ts:515-521](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L515-L521))因此不区分 peer。4 人局里一人掉线,只要其他人还在发消息,就永远不会超时,锁步会一直卡在等那个人。 - **bye 处理过粗**。任一 peer 发 `bye`,整个会话就被标记为 closed([netplay.ts:506-508](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L506-L508)),但锁步仍在等那个人的输入。 ## S5 状态哈希覆盖不足,实现重复 问题都在 `computeLockstepHash`([lockstep.ts:266-370](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L266-L370))里: - **HP 小数被截掉**:`mix(player.hp)` 经 `& 0xFFFFFFFF` 截掉了小数([lockstep.ts:269-272](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L269-L272)、[lockstep.ts:288](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L288)),怪物 HP 也一样([lockstep.ts:297](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L297))。HP 的小数分歧检测不到。 - **覆盖不全**:没有纳入 mana、RNG 状态、投射物和状态效果。 - **量化不一致**:背包坐标没有量化,地面坐标却做了 `round`([lockstep.ts:311-312](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L311-L312) 对比 [lockstep.ts:337-338](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L337-L338))。 - **不必要的 `any`**:`(entry.item as any).uniqueId`([lockstep.ts:321](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L321)),这里的类型其实可以补齐。 - **重复实现**:FNV-1a 在同一个函数里写了 5 遍,而且在 25Hz 的热路径上为每个条目新建闭包([lockstep.ts:307-309](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L307-L309))。 - **宽容判断**:对必传参数做 `if (inventories)` 这类判断([lockstep.ts:302](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L302)),与 AGENTS.md §3 相悖。 ## S6 客户端 Transport 细节 中继侧的问题见 #517。客户端这边: - 非二进制或超长的消息,在事件监听器里直接 throw([transport.ts:383-384](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L383-L384))。 - `error` 和 `close` 都会调用 `closeHandler`([transport.ts:387-388](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L387-L388)),与接口注释的 "called once"([transport.ts:33](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L33))矛盾。 - 连接未打开时,`send` 静默丢弃消息([transport.ts:391-394](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L391-L394))。 - CONNECTING 状态下调用 `close()` 什么都不做([transport.ts:397](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L397)),socket 之后照样会连上。 ## S7 net-simulation 自创数据 - 兜底 TC `'Act 1 H2H A'`([net-simulation.ts:126-128](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/net-simulation.ts#L126-L128))。 - `DEMO_MONSTERS` / `DEMO_EXPERIENCE`([net-simulation.ts:28-35](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/net-simulation.ts#L28-L35))。 - `widthPx: 10000`([net-simulation.ts:59-61](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/net-simulation.ts#L59-L61))。 - 联机模拟只消费 movement / attack / pickup;`skill` 和 `talk` 字段被发送了,却被忽略。它和单机的 `GameEngine` 不是同一套代码(见 #525)。 单机路径遇到未知怪物会直接 throw([engine.ts:1241](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1241)),这里的策略正好相反。 ## 修复指南 1. **desync**: - 要么真正产生 `desync` 变体:`step()` 比较到分歧时返回它,`pump()` 停止推进并上报; - 要么删掉这个变体和相关文档。 推荐前者,并配一个 resync 策略。最简单的做法是回退到最近一次一致的快照重放;或者直接结束对局,上报 tick、双方 hash 和双方快照的 diff。 2. **按 peer 记账**: - 为每个 peer 记录 `lastHeardTick[peer]`、`hashes[peer][tick]`、`byeFrom[peer]`,超时按 peer 判断。 - 某个 peer 发 `bye` 或超时后,用确定性的规则处理:填空输入,或把它移出 `peers`。 - 所有 peer 必须在同一个 tick 做出相同的决定,所以"移除 peer"本身也要作为一条锁步输入。 3. **哈希**: - 抽出一个 `Fnv32` 工具,热路径上不新建闭包(和 #527 的 FNV 去重一起做)。 - **最先哈希 RNG 状态**(`loot`、`castRng`)。这是最廉价、最早能暴露分歧的信号。然后再加 mana、投射物和状态效果。 - 所有浮点先按统一的定点规则量化(例如 `Math.round(v * 256)`),背包坐标也一样。 - 参数改为必传,去掉 `if (inventories)` 这类判断。 4. **Transport**: - `message` 监听器里出错时,用 `socket.close(1003)` 或 `socket.close(1009)` 关闭连接并回调会话,不要 throw。 - 用一个 `closed` 标志保证 `closeHandler` 只调用一次。 - `send` 在连接未打开时返回 `false` 或排队,由调用方决定,不要静默丢弃。 - CONNECTING 状态下的 `close()` 也要生效。 5. **net-simulation**: - 怪物、经验、TC 全部经 data-registry 读取(MonStats.txt、Experience.txt、TreasureClassEx.txt),未知 key 直接 throw。 - 地图尺寸来自真实关卡。 - 长期应让联机和单机共用同一个模拟内核(#525)。 ## 验收标准 - [ ] 注入一条分歧的 hash 后,`pump()` 返回 `desync` 并停止推进;报告里包含 tick 和双方的 hash。 - [ ] 4 人局中 1 人静默:该 peer 在 `timeoutTicks` 后被判超时,其他人不受影响。 - [ ] 哈希对以下两种差异都敏感: - 只改 RNG 种子的两个世界,hash 不同; - HP 只差 0.5 的两个世界,hash 不同。 - [ ] `error` 和 `close` 同时发生时,`closeHandler` 只被调用一次(有测试覆盖)。 - [ ] `net-simulation.ts` 中不再有 `DEMO_*` 常量和兜底 TC。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #518:输入校验。 - #517:中继。 - #523:模拟的确定性。 - #525:统一模拟内核。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#519
No description provided.