Client - 副本 (13).log · 2026-08-12
16:41 那次传送的实测时间线(同图,无缝路径):
QA 报的「场景异常回退」,就是 34.996 → 36.437 这 1.44 秒:玩家被放出来自己动了一下,然后 20 秒前的旧落点把他拉回去。
补充:TimerSystem 是帧驱动的。流送期间只有 10~12 fps,还夹着 2.1s / 1.5s 的巨型卡帧,
所以「15s」实际在 19.0s 和 23.8s 才触发,「8s」遮罩实际 12.1s 才关。阈值调参不能按墙钟算。
UEDPIE_0_,是编辑器 PIE,带 DDC SectionInfoMap 回填和
3719 条 Waiting on static mesh 阻塞。打包版大概率快很多,但快多少需实测。
下文所有阈值建议都应在打包版复测后再定稿。
实证 即使同图远距离传送也照样黑屏 loading。协议是标准三段握手:
服务端 → 客户端:SMSG_NEW_WORLD(传送指令)
同时置位 IsBeingTeleportedFar() 信标,此后拒收该玩家移动包
客户端:黑屏 → 加载 → 完成
客户端 → 服务端:MSG_MOVE_WORLDPORT_ACK
服务端:SetSemaphoreTeleportFar(false),恢复收包
从 TrinityCore MovementHandler.cpp 的 HandleMoveWorldportAck() 能看到几个关键设计:
| 设计点 | 做法 |
|---|---|
| 幂等 / 防伪 | if (!player->IsBeingTeleportedFar()) return; —— 非预期的 ACK 直接丢弃,防重复和伪造 |
| ACK 超时 | 原版没有专门的 worldport ACK 计时器。玩家就一直挂在 IsBeingTeleportedFar() 状态里等,
由会话层的连接超时兜底 |
| 落点非法 | IsValidMapCoord() 不过就 LogoutPlayer() —— 踢下线,而不是把人放到坏地形上 |
| 其它 ACK 的超时 | 移动类 ACK 有 CONFIG_PENDING_MOVE_CHANGES_TIMEOUT,超时行为是
KickPlayer(...) —— 同样是踢,不是放行 |
观察 这类是 UE / 自研 + 大世界流送 + 传送锚点,和我们最像。做法是用一段表演把加载时间藏起来,且表演时长跟随实际加载。
原神有一个很能说明问题的细节:
推断 「可点击切换的提示文字」是为吸收时长不定的加载而设计的典型手法 —— 设计上就假定了这个界面可能要停留很久,而不是播固定秒数后撤掉。
Star Citizen 的量子跳跃同理:把玩家扔进一条不需要加载的隧道场景,加载完了才推出去。隧道能开多久开多久。
实证 Epic 在 World Partition 文档里给的标准传送流程是:
1. 在目标点激活一个 World Partition Streaming Source Component
2. 轮询 IsStreamingCompleted()
3. 返回 true 后才把玩家传过去
4. 传完 Deactivate 该 Source
也就是先让地形加载,再移动玩家。我们现在是反过来的:先 SafeMoveTeleport 把人挪过去,由玩家自身隐式产生 streaming source,然后等。
另外 Epic 文档明确提到一个陷阱:IsStreamingCompleted 只表示格子进入 Activated,
不等同于物理 collision 已 build。这正是我们之前踩过的坑(3x3 贴地预检就是为它加的)。
实证 UE5 有个已知 bug:seamless travel 时如果客户端比服务器先加载完, 服务端永远看不到该客户端「已加载」,PlayerController 不切换,服务器和所有客户端一起挂死。
HasSentSameSceneLoadDone 是客户端侧的锁存,方向对,
但下面会讲它带来的一个副作用。
三种模式路子不同,但下面三条是共通的:
| # | 铁律 | 理由 | 我们 |
|---|---|---|---|
| 1 | 加载没完成,玩家绝不可控 | 放到未加载地形上必然穿地、掉出世界、触发反作弊。而且因为网络延迟, 服务器纠正要一个 RTT 之后才到,这期间玩家能在虚空里跑出很远 | 违反 |
| 2 | 服务端超时 ≠ 客户端解锁 | 服务端超时的含义是「我不等你了,你自己看着办」,不是「你可以自由移动了」。 这是两个独立状态机 | 违反 |
| 3 | 位置校正带事务号,晚到即丢弃 | TrinityCore 的 IsBeingTeleportedFar() 前置判断就是干这个的。
没有它,旧指令会「拽人回去」 |
违反 |
TeleportController.OnTeleportSelfTimeout()(约 1012 行):
private OnTeleportSelfTimeout(): void {
Log.Error(..., `15s 兜底触发:超时仍未完成,强制结束并 Done`);
// ↑ 日志自己都写着「仍未完成」
const snap = ModelManager.TeleportModel!.GetLocalPlayerServerMoveSnapshot();
this.SendSameSceneLoadDoneIfNeeded(snap.ServerMoveVersion); // ← 对:服务端该放行
this.UnlockLocalPlayerMovementForTeleport(); // ← 错:地形还没好
this.CleanupAfterTeleport(); // ← 里面还把 IsStreamingLevel 置 false
}
协议名字里的 LoadDone 就是「加载完成」。日志文本承认「仍未完成」,下一行就发这个包。 16:39 那次更极端:23.8 秒发出「我加载完了」,真正完成在 38.4 秒 —— 提前 14.6 秒。
而且注释里写得很清楚:
// 即便没收到 ServerMove,也用快照里现有的 version 回报;服务器对落后 version 会丢弃
「即便没收到 ServerMove」 —— 连服务器让我传送到哪儿都还不知道,就先回一句「我到了」。
然后为了自圆其说,只好把玩家也放开 —— 都跟服务器说「我到了」了,总不能还锁着。 一句谎话逼出了第二个错误动作。
流送 executor 的 Success 回调(约 1299 行起)完全不知道超时已经发生过,无条件执行:
if (result === ECommandExecutorResult.Success) {
moveSyncComp?.SafeMoveTeleport(targetPos, targetRot, ...); // 约 1320 行 ← 拽回去
...
this.ResetPlayerCameraRotation(); // 约 1326 行 ← 第 2 次扭相机
targetPos / targetRot 是 20 秒前闭包捕获的。没有版本号校验,没有「这次事务还有效吗」的判断。
对比 TrinityCore 那句 if (!player->IsBeingTeleportedFar()) return; —— 我们缺的就是这一句。
| 超时 | 配置值 | 16:41 实测触发 | 说明 |
|---|---|---|---|
| 无缝遮罩兜底 | 8s | 12.1s | 撤屏 |
| 传送流程兜底 | 15s | 19.0s | 发 LoadDone + 解锁 |
| 流送真完成 | — | 20.4s |
三者顺序是错的。遮罩比传送兜底早 7 秒撤,比流送完成早 8.3 秒撤。 遮罩超时的注释写的是「防止流送回调没触发把屏幕卡死」, 但它现在的实际效果是在流送正常进行中提前撤屏。
SendSameSceneLoadDoneIfNeeded 有 HasSentSameSceneLoadDone 幂等保护。
所以 15s 撒谎发完之后,流送真完成时那次诚实的上报会被直接吞掉。
服务器从头到尾只收到过那句谎话,永远收不到更正。
15s 超时到了:
├─ 照常 SendSameSceneLoadDone ← 服务端该放行放行,不拖累别人
└─ 玩家怎么办?看 IsStreamingLevel:
├─ true → 不解锁。起一个宽限定时器继续等
│ 等 finalize 正常收尾时放人(顺便取消宽限)
│ 宽限也耗尽 → 才强制解锁,交坠落重生兜底
└─ false → 快照缺失等异常,没有后续回调会来放人,就地收尾
+ 加 TeleportFinalizedByFallback 标志(对应 TrinityCore 的 IsBeingTeleportedFar 判断)
超时置位、每次新传送清零
回调 Success 分支先查标志,置位就只走 finalize,跳过:
SafeMoveTeleport / ResetPlayerCameraRotation / 贴地预检
+ OnClear 补清 TeleportSelfTimeoutTimerId(现在漏了)
finalize 里这四件事必须继续跑:
| 动作 | 为什么不能跳 |
|---|---|
IsStreamingLevel = false |
不复位就复现「跨图传送必卡」那个老坑 |
CloseSeamlessTransitionMask | 幂等,兜底不能省 |
EndCache | 同上 |
SendSameSceneLoadDoneIfNeeded | 有幂等保护,天然安全 |
宽限期建议 45s(覆盖实测最慢 38.4s,且考虑帧卡会让超时更晚触发)。
注意 TimerSystemConstants.MaxTime = 90000,CheckInterval 超上限会
静默返回 undefined、定时器根本不注册 —— 若哪天要调到 90s 以上,
必须显式传 needCheckMaxInterval = false,否则玩家会被永久锁死。
方案 A 本质上是客户端撒谎换服务器放行,终究是补丁。干净的做法是让客户端能如实上报:
ClientSameSceneLoadFailedCSReq { serverMoveVersion, reason }
服务器收到后自己决定怎么处置这种玩家:踢下线(WoW 做法)、强制回上一个安全点、 或者继续等但不阻塞其他玩家。「我加载完了」和「我放弃了,别等我」是两件事,应该有两个表达。
| 手段 | 说明 |
|---|---|
| 预测性流送 | 按 UE 官方模式 3:传送锚点是有限已知集合,在传送开始前就在落点插一个
StreamingSource 预热,而不是等 SafeMoveTeleport 把人挪过去才隐式产生 |
| 清脏资产 | 日志里 6 个唯一材质缺失(MI_ISL_01_A_Roc03d_01_LOD1/2/3 等,
全在 Scene/Assets/Temp/ 下),被 16 个 WP cell 引用,共 60 条警告 |
| 确认 PIE 水分 | 打包版实测同图流送耗时分布,再回头定 15s / 45s 这两个阈值 |
| UE 5.6+ 调优 | s.UseUnifiedTimeBudgetForStreaming:统一 ProcessAsyncLoading 与
UpdateLevelStreaming 的帧预算,闲置时间互相让渡 |
15s 兜底触发 之后不应该紧跟 传送-本地玩家移动解锁传送-解锁前贴地预检完成 应出现在 等待WP场景流送完成(结束) 之后 100ms 内传送-重置相机旋转-目标位置HandleMoveWorldportAck 实现)IsStreamingCompleted)