首页 / 周报 / 2026-W39

2026-W39 周报

覆盖 2026/09/21(周一)– 2026/09/27(周日)。 四大类固定顺序:1 需求 · 2 Bug · 3 经验总结 · 4 问题复盘,大类下面直接 1. 2. 3. 排。

周期09/21 – 09/27
需求5 个
Bug4 条
经验总结0 条
问题复盘0 条
分支zhaozibo_071

要直接交周报?跳到 §5 飞书周报(一键复制)。

0. 本周速览

大类#一句话
需求 1 【关卡工具】行为树新增「当传送完成」条件节点,带可选超时时间(默认 10 秒),传送判定与计时全在服务器
2 【关卡工具】「发射关卡子弹」的子弹类型Id 加了编辑期存在性校验,读哪张子弹表由伤害来源与子弹类型共同决定
3 【主线】同图传送的完成判据从「满 15 秒放行」改成「落点地面确认可站」,卡住不设客户端上限、只每 100 秒打一条告警
4 【关卡工具】技能集合表导出 JSON 交给服务器,复用宝箱那套 FGameDTJsonExporter,行名为数字 Id、列含技能 Id 集合与集合自有 Tag
5 【日常】删掉客户端 GM 命令 TeleportPlayer(传送玩家到位置{X,Y,Z}),只删定义、不动调用方
Bug 1 【主线】传送功能未开启时客户端仍发包,被服务端拦截后没退出传送状态,只剩镜头可操作
2 【主线】坠落重生完成后角色只能转镜头,输入掩码残留;诱因是低层黑屏无动画关闭时异常逃逸,打断了完成回调
3 【主线】传送 loading 之后地形流送「判据达成」的那一瞬画面闪一下:那一刻的关遮罩其实是空操作,相机硬切直接露了出来
4 【衍生】服务端反馈集合传送后不到半秒就收到 LoadDone;核对后确认客户端没抢跑,是 reason=15 下发了 (0,0,0),玩家被送到世界原点后坠落重生

1需求5 个

本周做的新功能 / 改造 / 配置支持。需求卡字段单元:模块 / 需求 / 实现细节 / 结果 / 验收。

1. 【关卡工具】 任务s1179923 行为树新增「当传送完成」条件节点,带可选超时时间(默认 10 秒),传送判定与计时全在服务器
模块:行为树条件节点 · 传送 / 条件注册表 Condition(条件)· ChildQuest(子任务)
需求
策划需要在玩法行为树里等「玩家传送完成」再往下推进,于是新增条件节点「当传送完成」。
节点在 Condition(条件)节点的「条件类型:」下拉和 ChildQuest(子任务)节点的「子任务类型:」下拉里都要能选到;带一个可选参数「超时时间」,默认 10 秒(10000 毫秒),超时从第一个玩家客户端加载完成上报成功起算。
传送范围、完成状态、超时判定全部由服务器实现,客户端本期只交付编辑器侧的条件配置,不新增任何运行时判定逻辑。
实现细节
客户端的条件节点分两套互不相干的注册表,两边都得登记,只在一边加是选不到的:Condition(条件)节点走 ECondition,ChildQuest(子任务)节点走 EChildQuest。
每套注册表各有三张 Scheme(配置结构)表要挂:任务树、玩法编辑器(LevelPlay)、野外事件(WildEvent)。节点本体是一个 createDynamicChildScheme:只出两个字段,ServerType 固定为本次新增的类型(判定在服务器,客户端不下发自己的结果),加上可选参数「超时时间」,用 createFloatScheme 配 Optional: true 与 CreateDefault: () => 10000,OnSchemeCheck 把 ≤ 0 拦成校验错误。
两个坑:① ECondition 的枚举新增后,必须同步补 CommonConditionAllDefault(全量映射)里的 undefined 条目,少一条 tsc 直接编不过;② 客户端节点类型表里,这个条件映射到的仍服务器节点,不要在客户端新建一个空壳节点类。
结果
「条件类型:」和「子任务类型:」两个下拉都出现「当传送完成」;不填超时按 10000 毫秒走,服务器从第一个玩家客户端加载完成上报成功开始计时。客户端未新增判定代码,条件同时配了一份测试用例 JSON,供策划导入后直接验证。
验收
npx tsc -p tsconfig.json --noEmit 退出码 0;npx eslint --fix 对改动文件 0 error。
编辑器读的是 Content/Aki/JavaScript/** 下的编译产物、不是 TS 源码,产物已自动增量编译,所以要重启编辑器才能在「条件类型:」下拉里看到新节点。
起止
提出并完成 2026/09/20(W39)
环境
changelist 828382
2. 【关卡工具】 任务s1183537 「发射关卡子弹」的子弹类型Id 加了编辑期存在性校验,读哪张子弹表由伤害来源与子弹类型共同决定
模块:关卡工具 / 关卡子弹 · 发射关卡子弹 LaunchBullet · 子弹表配置
需求
「发射关卡子弹」Action 里的「子弹类型Id」原来是纯数字输入,填了不存在 / 已删除的 Id 也能存盘,要等运行时才炸。需求是在编辑期就拦住:填的 Id 必须能在对应的子弹表里找到行。
麻烦的地方在于读哪张表不是固定的——由同级字段「伤害来源」和「子弹类型」(子弹 / 发射器)共同决定,所以不能简单挂一个静态表路径了事。
实现细节
① 字段改成从表里选行 —— 「子弹类型Id」用 createDataTableRowScheme(数据表行字段,Int 形态)替换原数字输入,下拉展示 RowName / Describe,DataTablePath 写成函数形态,按同级字段动态解析表路径。
② 按伤害来源分三支解析表路径:
· 玩家(Player):固定走玩家的 DT_CommonBulletActor / DT_CommonBulletEmitter;
· 怪物实体(MonsterEntity):表在运行时由 actorID 决定,静态解析不出来,返回空路径、跳过成员校验,只做非空;
· 子弹类型Id > 10000:这是通用子弹,走 CDT_CommonBulletActors / CDT_CommonBulletEmitters(阈值与运行时 BulletConfigCache.IsCommonId 同一份口径);
· 其余(环境怪 Default):ConfigId → 怪物总表的 CharacterConfigId → 角色总表 CDT_CharacterConfig 的 BulletTableConfig.BulletActorTable / BulletEmitterTable。
「子弹类型」字段决定读子弹表还是发射器表,两张表都要读。
③ 父对象层再补一次成员校验 —— 字段形态的 DataTablePath 在 check 侧拿不到同级字段,只能做非空;而且改完伤害来源后,旧 Id 在新表里非法也检测不到。所以在父对象层补了 checkBulletConfigId,挂进 Action 的 OnSchemeCheck(校验钩子),用 ue.DataTable.Load 加 KuroDataTableFunctionLibrary.DataTableRowExists 查行是否存在。
④ 顺带放宽了字段形态 —— DataTableRowScheme 原来要求 Int 模式必须配 ValueColumn;子弹表没有整型列、行名本身就是要存的子弹类型Id,于是改成「Int 模式下不配 ValueColumn 就把 RowName 当数字存」。
⑤ 分层纪律 —— 这段代码在 EditorCommon,不可顶层 import Editor 与 ue:ModelConfigManager 用 require 懒加载,ue 只在 UE 分支内 require;非 UE 环境(Jenkins 数据校验)没有 DataTable 模块,只做非空校验。
结果
「子弹类型Id」从手填数字变成带下拉的表行选择;填了表里不存在的 Id,编辑期直接报「子弹类型Id[x]在子弹表[...]中不存在,请检查是否填了已删除的Id」。切伤害来源 / 切子弹类型时,下拉读的表会跟着变。npx tsc -p tsconfig.json --noEmit 退出码 0。
总结
① 表路径依赖同级字段时,校验要写两处 —— DataTablePath 给下拉用(render 侧能拿到 props.Parent.Value),成员校验得在父对象的 OnSchemeCheck 里补一次,否则「改了伤害来源、旧 Id 留在新表里非法」这种组合情况永远检测不到。
② 子弹类型Id 只能是 5 位以内的数字 —— 子弹服务器Id 是 15 位:归一化角色Id(6) + 子弹类型Id(5) + 子子弹Id(3) + 配置位(1)。客户端拼 Id 用的是 padStart(5, '0')(只补不截),7 位数会拼出 17 位,直接命中「子弹服务器Id 必须 15 位」的校验。后续谁想在玩家分支加 Id 区间硬校验,先过这一条。
③ 编辑器与运行时的分流阈值必须同源 —— 通用子弹的 10000 阈值在编辑器、TS 运行时、C++ 导出三处各有一份,改任一处成不同口径,就会出现「编辑器能配、运行时读不到」。
起止
提出并完成 2026/09/20(W39)
环境
changelist 829293(与同周「传送监听节点」合并提交)
顺带发现
策划文档《关卡子弹配置》里玩家分支写的是 2000001-2000999 区间,与实现对不上:7 位数拼不出 15 位服务器Id,表里玩家子弹的实际行名是 1000001 起,本工程内也搜不到 2000xxx 段。判断那句是在约束「玩家的实体配置id」而不是 Action 里的子弹类型Id,故未按文档加区间硬校验,属文档口径问题。
3. 【主线】 b1275298 同图传送的完成判据从「满 15 秒放行」改成「落点地面确认可站」,卡住不设客户端上限、只每 100 秒打一条告警
模块:传送 / 同图传送安全等待 · TeleportSameSceneFlow · TeleportGroundProbe
需求
同图传送的「完成」原来由计时器说话:TeleportSelfFallbackTimeoutMs = 15000 到点就强制结束等待、上报 LoadDone(14716)、清理移动锁与遮罩。这套做法产生三种「伪成功」:
① 报早了 —— LoadDone 的语义是「本端准备好了,可以让所有人继续」,地形还没流送出来就发,等于骗服务器:别人开始动,本端脚下还是空的。
② 解锁早了 —— 遮罩撤掉、移动解锁,但地面确认这时候才刚开始,玩家当着镜头的面穿地下坠,再靠坠落重生兜回来。
③ 放行早了 —— 贴地预检 20 次(约 1 秒)用完就放行,落点地形慢一点,就把「地形没好」当成「可以落地」。
改造把「传送完成」的判定权从计时器交还给物理系统:上报 LoadDone、解锁移动、关无缝遮罩全部排在落点地面确认之后。卡住时不设客户端退出上限(宁可等,不伪造成功),但要保证线上能发现——每 100 秒打一条红色告警。
实现细节
① 新增「等地面就绪」:TeleportGroundProbe.WaitForGroundReady(targetPos, finalize) 先立刻查一次,通过就直接 finalize('ready-on-first-check');没通过先打一条 Log.Error「传送-找不到合适的地面,九点探地未通过,继续等待」(带 targetX / targetY / targetZ),再用 TimerSystem.Forever 每 50 毫秒(GroundReadyCheckIntervalMs = 50)复查,通过后取消计时器并回报 ready-after-N-retries。判据本身没改:以服务器下发的目标坐标为心,±132 厘米的九宫格各点向下 200 厘米做 LineTrace(射线检测),九点全部命中才算就绪。
② 收尾顺序固定:集中在 TeleportSameSceneFlow.StartLevelStreamingFromSnapshot 的结果回调里——执行器 Success → SafeMoveTeleport → 等地面就绪 → 清安全等待标记 + 相机复位 → 上报 LoadDone(仍是 5/5/10/20/40 秒、最多 5 次、总窗口 LoadDoneTotalWindowMs = 80000)→ 解锁移动(带 groundConfirmed 时恢复 MOVE_Walking,不再落回坠落态)→ 关无缝遮罩 + 清告警 + 结束协议缓存。每一步之间复查 FlowGeneration(流程代次),代次变了立刻退出,不再往下推进。
③ 100 秒告警用轮询累计实现:TimerSystemConstants.MaxTime = 90000 限制单次定时器最长 90 秒,所以 SafetyWaitWarnIntervalMs = 100000 不能直接开成一个定时器,改成 TimerSystem.Forever 每 10 秒(SafetyWaitWarnTickMs = 10000)跑一次、累计到第 10 次才输出。输出一行带 7 个诊断字段:已等待毫秒、安全等待中、地形流送中、等相机朝向、执行器状态、ServerMove 监听中、普通场景加载中——一眼看出卡在哪一段。
④ 异常分支只记日志、不再退登:原来的 RecoverFromLoadingFailure、失败遮罩 TeleportSafetyFailure、IsRecoveringFromFailure 门面拦截和 BackLoginView 整段已删除。快照为空、流送失败、执行器异常、安全完成回调异常都只打一条 Log.Error,等待与锁保持原样;解除等待只剩三个出口:新请求接管(CancelCurrentRequest)、交接(HandoverCurrentRequest)、流程清理(Clear())。
⑤ 安全等待期间挡住抢先调用:IsSafetyWaitPending(IsWaitingForSafety 的 getter)在 8 处做 guard——门面 3 处、过场编排 4 处、V2 流程 1 处,只挡调用、不改状态,真正的收尾始终只由「安全条件就绪」那个回调完成。
总结
把「等一个计时器」换成「等一个物理条件」,收益是行为诚实,代价是必须自己回答「这个条件永远不成立时怎么办」。这次的答案是一个明确选择而不是遗漏:不设客户端上限、每 100 秒告警、真的恢复不了就只能重登。两个已知失效成因也记在文档里——探的是服务器下发的目标坐标而不是角色实际位置,而每根射线只向下探 200 厘米,落地修正把角色挪走以后原坐标可能永远打不到;九点采样跨 ±132 厘米,外围点本来就没有地面时等多久都不会通过。凡是「等一个可能永远不来的条件」的流程,都要把两件事写在同一处:谁能解除,以及怎么发现。
另外两条可复用的结论:定时器系统的单次上限(TimerSystemConstants.MaxTime = 90000)比想要的长周期短,长周期告警要用轮询累计,别一次开一个超长定时器;命令超时不是兜底——CommandExecutor 的 triggerCallbackOnTimeout 默认是 false,超时只打日志、连 Promise 都不会 resolve,所以无上限等待不能指望执行器超时把它推下去。
起止
提出并完成 2026/09/21(W39)
环境
changelist 830889 · 传送点主动传送 / 同图传送
4. 【关卡工具】 任务s1181145 技能集合表导出 JSON 交给服务器,复用宝箱那套 FGameDTJsonExporter,行名为数字 Id、列含技能 Id 集合与集合自有 Tag
模块:技能导出 / GameSkillDataExporter · GameDTJsonExporter · 技能集合表 DT_SkillCollectionForLevel
需求
服务器要读「技能集合表」DT_SkillCollectionForLevel(行结构 SLevelSkillCollection),需要客户端把它导出成 JSON 交给服务器,口径与已经在给服务器用的 DTSkill.json 保持一致。
两个硬要求:① 行 Id 必须是 int,服务器按整数索引,所以表行名本身就得是数字;② 列含技能 Id 集合与集合自有的 Tag(不复用 SkillTag),Tag 统一按 FNV 哈希成 int32 输出。
同一张表的行名还要给 ChildQuest(子任务)节点的「技能监听」子任务类型下拉用,两边必须是同一份行名,不能各配一套。
实现细节
① 复用宝箱的导出基类,不另起一套 —— 新增 FGameLevelSkillCollectionJsonExporter(final,继承 FGameDTJsonExporter,与 DTSkill.json 的 FGameSkillJsonExporter 同一个基类):文件名 DTSkillCollectionForLevel.json、RowStructName = SLevelSkillCollection、IdName = Id(首列字段名)、bWriteRowName = false(行名不进 JSON,Id 走列);CheckAndGenerateId 把行名 FCString::Atoi 成 int32 当 Id,返回 0 即跳过该行。
② 挂进导出注册表 —— GameSkillDataExporter 里加一个与既有阶段同构的 ExportSkillCollectionStage,并在注册表登记一行:{ SkillCollection, 加载并导出技能集合配置, 技能集合表, DT_SkillCollectionForLevel, &ExportSkillCollectionStage }。导出流程是注册表逐项 Entry.Exec(Context),登记漏了这一步就跑不到。
③ 配置落 UGameSkillExportSettings + DefaultEditor.ini —— 新增 SkillCollectionDTPath 与 SkillCollectionPropsToExport,ini 里配表路径加 SkillIds / CustomTags 两列,与其它表「一组 DTPath 配一组 PropsToExport」的写法一致。
④ 子任务侧同步 —— ChildQuest(子任务)节点新增「技能监听」类型 SkillListen、状态枚举 ESkillListenState(Cast / Hit / Finish),「技能集合」字段用 createDataTableRowScheme(数据表行字段)直接从 DT_SkillCollectionForLevel 选行,与导出读同一张表。
⑤ 跨流留档 —— 改动先在 0.7.1 的 CL 829666 留档,再迁到 0.8.0 的 CL 834775。
结果
导出产物 Source/Config/Server/DataTable/DTSkillCollectionForLevel.json,格式与 DTSkill.json 一致:TAB 缩进、根 {"Data":[...]}、数组换行展开、Tag 为哈希后的 int32。0.8.0 上 6 个文件(导出器 .cpp/.h、GameSkillDataExporter .cpp/.h、GameSkillExportSettings.h、DefaultEditor.ini)在 CL 834775;ChildQuest.ts 的改动已被 AutoMerge 合入 0.8.0 depot,不需要再动。
总结
① 给服务器新增一张表,只需三件事 —— 配置里加一组「DTPath + PropsToExport」、导出注册表登记一个 Stage、继承 FGameDTJsonExporter 写一个导出器。这印证了「不自己另写一套导出」是对的:DTSkill.json 与本次产物出自同一个基类,格式天然一致。
② 基类三条硬规则 —— 行名必须纯数字(非数字整行静默跳过,不报错)、列名等于结构体字段名、GameplayTag 一律 FNV 哈希成 int32。配表时行名不要写「组合1」这类文字,否则导出的 JSON 里那行会凭空消失。
③ 跨流迁移搁置必须指定目标流 —— 搁置内容存在 0.7.1 路径、而 workspace 只映射 0.8.0 时,p4 unshelve -s <CL> 直接报 No such file(s);要带 -S //game/branch_0.8.0,且文件参数写目标流的路径,unshelve 完必须 p4 resolve 才算落地。
④ 搁置不等于留档 —— 先 revert 再 shelve,搁置里存下的就是「没改」的内容,等于白留。留档之后要 p4 print @=<CL> 抽查一眼,确认搁置里真带着改动。
起止
提出并完成 2026/09/20 - 09/22(W39)
环境
changelist 829666(0.7.1)· 834775(0.8.0,6 文件留档)
5. 【日常】 删掉客户端 GM 命令 TeleportPlayer(传送玩家到位置{X,Y,Z}),只删定义、不动调用方
模块:GM 工具 / GmCommand · TeleportPlayer
需求
客户端 GM 命令 TeleportPlayer(GM 列表里显示为「传送玩家到位置{X,Y,Z}」)要从 GM 列表里去掉,只删定义,调用方保持不动。
这条 GM 自己没有独立逻辑:解析三个数值参数 → 把 TeleportController.ForceSimplifiedTeleportForGm 置 true(本次传送切简版:黑屏 + 系统 Loading,不做 Seq1 / Seq2 / Seq3 复杂表现)→ 再转发成 #move x y z,实际传送交给既有的 #move 命令。
实现细节
删掉 GmCommand 上的三个静态成员:TeleportPlayerDescription、TeleportPlayerCategory(EGmCategory.Teleport)、TeleportPlayer(args) 方法。
GM 是按「xxxDescription + xxxCategory + 同名静态方法」三件套登记的,三样一起删掉,GM 列表里才不会再出现这一条。
未清理 import:TeleportController、EventSystem、EEventName 仍被文件里其它 GM 使用。
结果
GmCommand.ts 里不再有 TeleportPlayer,IDE 无诊断;改动进 CL 837513。
6 处 TestModuleBridge.RunGm('TeleportPlayer#x,y,z') 调用按约定保留,运行期会变成未知 GM——涉及 AutoRunCityOnline / AutoRunMainCityOnline / AutoRunIslandOnline 与 3 个 PerfTestCaseProfile。
总结
客户端 GM 名是字符串派发的(RunGm('名字#参数')),删掉定义不会有任何编译错误,调用方只会静默失效。所以删 GM 之前先全仓搜 RunGm('<名字> 和这个名字的字面量,确认还有没有靠字符串在用的地方。
起止
删除 2026/09/23(W39)
环境
changelist 837513(pending)· Src/Test/GM/GmCommand.ts

2Bug4 条

本周修的问题。Bug 卡字段单元:模块 / 问题描述详细版本 / 原因 / 解决方案 / 总结。

1. 【主线】 b1273319 传送功能未开启时客户端仍发包,被服务端拦截后没退出传送状态,只剩镜头可操作
模块:传送 / TeleportController · SceneItemController · 功能开启 FunctionalController ·定责:客户端 已修复
问题描述详细版本
线上单 b1273319:给传送配了功能开启条件(需完成序章)后,角色发起传送,客户端协议被服务端拦截,传送失败后角色只能操作镜头,其余操作全部失效。
日志核对:客户端照常发出 WorldTeleportCSReq(23707),服务端 onTeleportCSReqPacket 判定 player cant access function:14、ret:-28000 后 exec packet handle failed,同时回了 TeleportSCRsp(23708),cnt=1 · body_length_avg=11——也就是说客户端拿得到这次失败的回包。
原因
拦截是服务端做的,但「发起前先自查」和「失败后退出状态」两件事客户端都没做:
① 发包前没有功能开启检查 —— 传送的唯一发包点只校验 teleportId 是否合法就发 23707,不看 EFunctionType.Teleport(function:14)是否开启。功能屏蔽装饰器挂在传送入口上,但它只拦 Disabled / NonInteractive 两种状态,「功能未开启」不在它的判定里,所以没被挡住。
② 失败回包只打了日志 —— TeleportSCRsp 的 retcode 非成功分支只取了一条错误码文案,没有退出传送状态。
③ 恢复链等的是另一条协议 —— 传送的收尾靠服务端广播 ServerEntityMoveSCNotify(15511)触发 TeleportDownCallBack → RestorePlayerState。服务端这次直接拒绝、不会广播坐标,这条链永远不触发;而发起前已经 EnableInputControl(false) + EnableMainUi(false) 并开了黑屏 / 遮罩,于是输入锁与主 UI 锁永久残留,只剩相机还能动。
解决方案
两端补齐,拦住入口 + 补一条失败出口:
① 客户端发包前先自查功能开启 —— TeleportController.OnStartUp 处理主动传送时先调新增的 CheckTeleportFunctionOpen(),用 FunctionalController.IsOpenByFunctionType(EFunctionType.Teleport) 判断(与服务端 function:14 同源);未开启直接拦截、不发 23707,并按既有范式弹 FunctionDefineConstants.FunctionDisableKey 提示,玩家看到的是「功能没开」而不是传送失败。服务端拦截保持不变,作为兜底。
② 失败回包退出传送状态 —— SceneItemController.RequestTeleport 的 23708 失败分支改调新增的 TeleportController.OnTeleportRequestFailed():先按已知原因走 RecoverPresentationFromTransitionFailure('服务端拒绝传送请求')(关遮罩、关黑屏、放行输入与主 UI),再 RestorePlayerState,最后按本次走的是 V2 还是老线调对应的 OnEnd() 收尾。入口先判 IsTeleporting,不在传送中直接返回;范式和既有的 OnLocalPlayerDead 对齐。
③ 兜底不再误报 —— RecoverPresentationFromTransitionFailure 加可选 reason 参数:不带(过场表现异常)仍打 Error,带(服务端正常拒绝这类已知中断)打 Warn 并记原因,不会再把一次正常拒绝写成「表现异常需修数据」。
验证:npm run compile 退出码 0;产物 JS 里搜到 CheckTeleportFunctionOpen / OnTeleportRequestFailed,且 23708 失败分支已实际接线。功能未开启时点传送,客户端不发包、直接提示;人为构造失败回包时,黑屏与输入锁均正常退出。
总结
「服务端拦得住」不等于「客户端可以不管」。判断功能是否开启要在发包前做,依据必须和服务端同一份数据(这里都是 EFunctionType.Teleport),否则两边口径一旦分叉,玩家看到的就是「点了没反应」或者「卡死在半路」。
另一个更常见的坑:凡是「发起时锁输入、等某条回包再解锁」的流程,都要问一句这条回包在失败路径上会不会来。这个 bug 的恢复链挂在成功路径才有的广播协议上,失败时就成了没人解锁的死锁——凡是失败分支,都要有一条自己的出口,而不是指望同一条成功路径的收尾。
起止
提出并修复 2026/09/18
环境
传送点主动传送 · 功能 EFunctionType.Teleport(14)未开启
顺带发现
排查时确认的一处判定盲区,未单独立条:挂在传送入口上的功能屏蔽装饰器只拦 Disabled / NonInteractive,不拦「功能未开启」,所以「靠装饰器兜住功能未开启」这条假设不成立,功能开启必须自己显式判断。
2. 【主线】 坠落重生完成后角色只能转镜头,输入掩码残留;诱因是低层黑屏无动画关闭时异常逃逸,打断了完成回调
模块:坠落重生 + 边界重生 · 低层黑屏 LowLayerBlackScreenController · 输入掩码 OnlyAllow ·定责:客户端 已修复
问题描述详细版本
偶现:爆烈洞窟之后的山洞坠落重生完成后,角色只能转动镜头,其余操作全部失效。
日志核对(关卡 40065017):全文 [坠落重生输入] 只有 3 条 —— 第一轮 IsEnableInput: false(掩码 Add lookup / turn / zoom)、第一轮恢复 true(掩码 Remove ×3)、第二轮 false(Add ×3);第二轮之后再没出现过 true,也没再出现过 Remove。
掩码残留当场可见:OnlyAllow拦截 从第二轮起持续刷到世界清理(20:53 段 7200 条、20:54 段 3840 条,全文累计 17330 条),与「只剩镜头能转」完全吻合。
原因
现行 bug 和它的诱因是两件事,叠在同一次坠落重生里:
① 第二轮恢复输入被句柄守卫吞掉 —— FallingRebornController.EndFallingReborn 把 SetEnableInputControl(true) 包在 if (this.CurrentRebornEntityHandle?.Valid) 里,句柄失效时静默跳过(Valid 依次查 Invalidated 和实体模型校验,失败只返回 false,本身不带日志)。主黑屏的关闭日志(「触发结束黑屏 async [坠落重生]」→「黑屏关闭动画结束 [Close]」)说明 await RemoveBlackScreenAsync 已经 resolve,其后却没有任何输入日志 —— 只可能是 Valid === false 把恢复吞了。两条「预防性保底」也都没兜住:HandleOnEnterLevel 两轮判定都是 IsLevelChanged: false,ReconnectCompleted 全程没发生。
② 诱因:第一轮低层黑屏关闭抛异常,打断了完成回调 —— 第一轮走的是坠落区域分支 DoFallZoneRespawn,它注册的 FallingRebornCompleted 回调里调 CloseLowLayerBlackScreenIfNeeded() → LowLayerBlackScreenController.CloseBlackScreen:HideTemp(EBlackScreenAnimType.None) 走无动画路径,SwitchState(HideWithOutAnim) 同步触发 FinishHidePromise(),把共享的静态引用 BlackScreenGlobalData.HidePromise 置成 undefined;紧跟着那句await BlackScreenGlobalData.HidePromise!.Promise 就裸读到 undefined,抛 TypeError: Cannot read properties of undefined (reading 'Promise')。异常从同步派发的 EventSystem.Emit 里逃逸,掐断了回调后续的状态复位(IsRespawning / IsInBoundary 没复位、低层黑屏面板也没走到 OnClear),34 ms 后第二轮重生就被触发。
③ 同类里唯一没守卫的那一处 —— 低层黑屏和主黑屏共用这一份静态 Promise(源码注释写明「不能和 BlackScreenController 同时使用,BlackScreenGlobalData 是共享的」),但主黑屏侧 RemoveBlackScreenAsync 有 if (BlackScreenGlobalData.HidePromise) 守卫,低层黑屏侧没有。
解决方案
客户端两处已改(CL 831132),黑屏侧另出一份建议给对方:
① 恢复输入不再依赖句柄 —— EndFallingReborn 去掉 Valid 包裹,无条件 SetEnableInputControl(true)。依据:SetEnableInputControl 自己有幂等守卫(值相同直接 return),掩码按 reason('坠落重生')精确摘除、重复摘除无副作用,恢复动作没有理由跟句柄有效性绑在一起。
② 关低层黑屏异常不外逃 —— CloseLowLayerBlackScreenIfNeeded 给 CloseBlackScreen 补 .catch() + Log.Error,黑屏模块抛错不再打断 FallingRebornCompleted 回调里后续的状态复位。
③ 黑屏侧(建议 HCS 胡宸硕处理):CloseBlackScreen 补 if (BlackScreenGlobalData.HidePromise) 守卫;用 try / finally 保证 OnClear() 一定走到;治本方向是这份静态 Promise 不该被两条黑屏通道共用。
验证:npx eslint --fix 0 error(只剩既有标识符 Respawning 的拼写 warning)、npm run tsc-native 通过、行尾仍为 CRLF。
总结
凡是「先锁输入 / 先开黑屏,等某个信号再解锁」的流程,解锁动作不要挂在可能失效的对象上——句柄(Valid)和一次性的 Promise 引用都会悄悄失效,且失效时通常什么都不打,玩家看到的就是「只剩镜头能转」这种哑掉的状态。
排查这类问题的快路径:直接数「锁」和「解锁」的日志条数对不对得上。这次全文 [坠落重生输入] 只有 3 条、第二轮之后再没出现 true,一眼就定位到恢复被吞;再看 OnlyAllow拦截 一路刷屏,就能确认掩码确实残留。
共享资源再记一条:同一份静态 Promise 被两条通道共用时,每条通道都要自己判空,不能指望「另一条通道有守卫」——这次就是主黑屏有、低层黑屏没有,同一次操作里前后脚把引用置空。
起止
报障 2026/09/16(W38)· 定位并修复 2026/09/21(W39)
环境
关卡 40065017 · 爆烈洞窟之后的山洞坠落重生(偶现)
顺带发现
① 没被 catch 的 async 调用会从同步 Emit 里逃逸,在 puerts 下只落成 v8::kPromiseRejectWithNoHandler + unhandledRejection 两行,不中断调用栈、也不阻止后续日志,排查时要主动去日志里搜 unhandledRejection。
② 黑屏模块的日志作者标记是 HCS(胡宸硕),修复建议按这个找人对口;LowLayerBlackScreenController 最近一次改动是 shisujuan(2026/08/25,主界面迁 HUD 层)。
3. 【主线】 传送 loading 之后地形流送「判据达成」的那一瞬画面闪一下:那一刻的关遮罩其实是空操作,相机硬切直接露了出来
模块:传送 / 同图流送 TeleportSameSceneFlow · Seq3 收尾 TeleportSequenceFlow · 流送判据 GameModeController ·定责:客户端 已修复
问题描述详细版本
飞书报障(关卡 / 副本):「角色传送 loading 后,地形流送的某个阶段里,角色相机表现异常」,具体描述是流送完成前的一瞬间相机会闪一下——不点钩锁也会出现、报障人本机必现。
把「闪的那一瞬」对到代码上,是 TeleportSteamingComplete 触发的同图流送回调(TeleportSameSceneFlow)里连着做的两件事:
① 先 Callbacks.ResetCamera() —— 相机朝向按落点数据硬切,FightCameraLogicComponent 里直接 SetRotation,没有 blend 过渡;
② 紧接着 Presentation.CloseSeamlessTransitionMask('streaming-complete')。
能确认闪的就是这一帧,靠的是报障日志里的 OnTeleportSelfTimeout(15 s 兜底)——这个符号只在 09/15 之前的 TeleportSameSceneFlow 里存在。
顺带把判据本身说清:这里说的「流送完成」= GameModeController.CheckTargetStreamingCompleted,用 UE.WorldPartitionStreamingQuerySource(半径 7000,约 70 m)每 100 ms 轮询一次 IsStreamingCompleted(Activated, …),粒度是cell 已 Activated,不是地形几何 / 渲染全量。所以「判据达成」到「画面真的能看」之间天然还有一段窗口。
原因
定责客户端(传送表现链路上缺遮挡),不是流送判据算错。两件事叠在一起:
① 那一刻的「关遮罩」是空操作 —— 主动传送到这一步,黑屏(ELayerType.CG 层,1<<10)从头就没开过:OpenSeamlessTransitionMask() 一进来就被 ShouldUseUiLoadingInsteadOfBlackScreen() 挡掉直接 return,遮挡改由系统 Loading(Loading 层,1<<8)承担。所以 CloseSeamlessTransitionMask('streaming-complete') 关的是一个根本不存在的东西;真正撤遮挡落在后面的 Seq3 就绪(TeleportSequenceFlow 里 CloseUiLoadingForSeq3Async('Seq3 已就绪'))。相机硬切就发生在这两次之间,中间只要没东西盖着,就会被看到。
② 报障人那份包在这段根本没有遮挡 —— 「给传送插 SceneLoadingOpenUiCmd / SceneLoadingCloseUiCmd」由 SceneLoadingTeleportCmd 里的 needLoadingUi = TeleportModel.IsTeleporting 决定,而这两条命令是 2026/09/15 的 CL 815371 才加进去的。他跑的是修复前的包,所以这一帧既没有黑屏也没有 Loading,硬切直接暴露。
解决方案
代码侧已由两笔提交覆盖(不是本次新改):
① 把遮挡提前到流送之前 —— CL 815371(2026/09/15)在 TeleportTransitionFlow 的 OnStartLoad 里先 GameModeOpenLoading() 再启动流送,系统 Loading 从「离开旧关卡」那一刻起就盖着,一直盖到 Seq3 就绪才关;同一笔还把三处「超时强制关遮罩」(无缝遮罩 8 s、换落点 20 s 等)改成只打日志、不再强关,避免遮罩在流送中途被提前撤掉。命令链末尾的 SceneLoadingCloseUiCmd 在非 Login / 非无缝分支只 SetIsLoading(false),不会抢着撤遮挡。
② 相机侧收口 —— CL 827308(2026/09/18)给 CameraManager.ReleaseCamera 加 expectedViewTarget 目标校验(同模式已被其他演出接管时不再误释放),并在 TeleportTransitionFlow.OnEnd 补 StopTeleportFade(),兜底清掉残留在玩家身上的淡变材质。
待验:还没有人拿 09/18 之后的包在原场景复验。复验只看两条日志——有「打开系统 Loading 遮罩」说明遮挡确实盖上了;传送-关系统 Loading 遮罩(async) 出现的时刻必须晚于相机重置那一帧。若第一条就没打,说明这次触发没走主动传送链(例如传送门的同步瞬移),遮挡缺口要另算。
总结
「流送完成」是判据,不是画面就绪。这里的判据是 WorldPartition 的 cell 已 Activated(半径 70 m、100 ms 轮询),达成那一刻只说明数据到位,不代表画面能看——凡是「判据达成之后再动相机 / 撤遮挡」的写法,都要问一句这中间有东西盖着吗。
这次踩的正是这个点位:回调里先硬切相机、再「关掉一个从来没开过的遮罩」。关遮罩的函数不报错,不等于遮罩存在——要判断遮挡在不在,得回到「谁开的、开的是哪一层」,而不是看关的那一句。这也解释了为什么 t1 的关遮罩日志很干净却什么都没关掉。
起止
报障 2026/09/16(W38)· 定位 2026/09/21(W39)
环境
飞书报障:关卡 / 副本「传送 loading 后地形流送某阶段相机表现异常」· 报障人所在包早于 2026/09/15 CL 815371
顺带发现
① 这段链是新写的、不是有人动了老逻辑:TeleportSameSceneFlow / TeleportPresentation / TeleportSequenceFlow / TeleportV2Flow 在 P4 上都是 2026/09/08 才 add;09/08–09/18 共六笔(796995 / 803204 / 809088 / 811295 / 815371 / 827308)作者是同一账号。09/21 那笔脱离卡死迭代由 zhongmingkang 提交,只给 TeleportTransitionFlow 补了帧末兜底恢复。
② 一个便宜的版本判别法:OnTeleportSelfTimeout 只存在于 TeleportSameSceneFlow 的 rev#1 / rev#2(即 09/15 之前),当前分支里全库搜不到该符号——报障日志里出现它,就能直接断定对方跑的是修复前的包,不必反复让他复现。
③ SceneLoadingOpenUiCmd 非 Login 分支只 SetIsLoading(true)、不创建 Loading 界面这件事,已单独记在传送遮挡链路上三处会漏遮挡的口子。
4. 【衍生】 服务端反馈集合传送后不到半秒就收到 LoadDone;核对后确认客户端没抢跑,是 reason=15 下发了 (0,0,0),玩家被送到世界原点后坠落重生
模块:传送 / ServerEntityMoveSCNotify(15511) · ClientSameSceneLoadDoneCSReq(14716) · TeleportSameSceneFlow ·定责:服务器 待对齐
问题描述详细版本
服务端反馈:集合(reason=15)触发服务器传送后不到半秒就收到 ClientSameSceneLoadDoneCSReq(14716),怀疑客户端「没加载完就上报」。把两端绝对时间戳摆出来对齐:服务端 setServerMoveLoad is_loading=true 11:33:13.381 → 收到 LoadDone 11:33:13.923,共 542ms;客户端收到 ServerEntityMoveSCNotify(15511)13.407 → 发出 14716 13.916,共 509ms。
这 0.5s 在客户端日志里能逐项拆开:场景加载串行链中 SceneWaitWpStreamingCmd 单独占 0.506 秒,其余子命令(传送 / 新场景 / 完成)全是 0.000 秒——上报是在「流送等待之外确实没别的事可做」之后才发的。
但同一条日志露出了真正的问题:15511 下发的玩家目标位置是 (0,0,0)。
原因
① 目标坐标就是世界原点 —— 15511 入口日志打出 pos: 0.0,0.0,0.0;用客户端「本次位移」可以闭式验证:distanceMeters 132.10072062540263 恰好等于 |(7539.347, 10802.601, 983.899) − (0,0,0)| / 100,即 132.10m,快照目标就是原点。随后「开始传送」日志的 TargetBornLocation 也是 (0,0,0),该值直接取自 15511 的快照。
② 客户端拿不到别的坐标 —— WorldTeleportSelfSCNotify(15512)协议里只有 blackScreenType / dirPos / leaveShowType / enterShowType,不带任何坐标字段;客户端能用的目标只有 15511 的 position。所以全 0 不是客户端凑出来的。
③ 后果链 —— 贴地射线从 (0,0,0) 向下 1000cm isHit:false(世界原点没有地形),FixActorBornLocation 最终调整失败,玩家停在原点后位置状态 Ground→Air、坐标 Z 掉到 -13246.59,约 8 秒后触发 WorldFallingRebornCSReq(坠落重生)。同一天 11:24:58 另一次集合传送(distanceMeters 123.40576472151595)现象完全一致。
解决方案
① 服务端(主责):确认集合传送(reason=15)为什么下发 position=(0,0,0)——大概率是这条路径没填目标点、走了默认值;补上真实集合点坐标后本条现象消失。
② 客户端(待评估,本次未改):TeleportGroundProbe.WaitForGroundReady 没有次数 / 时间上限,同图流程侧只有「只告警不退出」的计时器;一旦目标点永远贴不到地(比如又是全 0),LoadDone 会永远不上报、移动锁与遮罩不解除,服务端 is_loading 一直挂着。等服务端把坐标问题确认掉之后,再定是否补超时兜底,以及是否给 GATHER_AREA(15)加专属分支。
总结
「客户端上报太快」这类反馈,先把两端绝对时间戳摆出来对齐,再看客户端那段时间究竟在等什么:这次的 0.5s 全部是场景加载链里的流送等待,客户端并没有抢跑。
更值得记的一条:同图传送的坐标只能来自 15511,15512 不带坐标;一旦 15511 的 position 是全 0 默认值,客户端会忠实地把玩家送到世界原点,而这条链路上没有任何一步会因为「坐标看起来不对」而停下来。所以「服务端下发坐标 → 客户端直接应用」的链路,客户端侧至少要有一个「贴不到地就不算完成」的闸门(当前版本已经这样做了),但闸门必须留出口,否则问题会从「提前上报」变成「永不上报」。
起止
提出并定位 2026/09/21(W39)
环境
关卡 40100107 · UID 268704624612 · 集合传送 reason=15(GATHER_AREA)
顺带发现
排查时同时确认的两点,未单独立条:
① 这份日志来自重构前的构建——日志里的「传送-流送完成但朝向包未到」「传送-解锁前贴地预检超时,强制解锁兜底」在当前源码里已经不存在,只存在于 .codely-cli/stash 的旧版 TeleportController;旧版顺序是「executor 成功 → SafeMoveTeleport → 立刻上报 LoadDone → 再贴地预检」,所以「流送一完成就上报」是旧版设计,半秒上报这一现象在当前版本已不复现。
② 客户端对 reason=15 没有专属分支——落 default 的通用 SafeMoveTeleport,于是错误坐标被当成一次正常远距离传送处理(距离 132m > 100m,升格判定还走了一次 uiload)。

3经验总结0 条

可复用的模块知识 / 工具用法。这一类只在明确要求时记录。

本周无

4问题复盘0 条

排查走了弯路、造成外部代价的事。这一类只在明确要求时记录。

本周无

5. 飞书周报(一键复制)

下面这块是按飞书周报模板压缩后的对外版,只保留结果、关键方案与风险 / 后续。

飞书周报 · 2026-W39(09/21 – 09/27) file:// 下若复制被拒,会自动帮你选中,按 Ctrl+C
1.本周工作进展:

    a. 【关卡工具】任务s1179923,行为树新增「当传送完成」条件节点,带可选超时时间(默认 10 秒),传送判定与计时全在服务器
       模块:行为树条件节点 · 传送 / 条件注册表 Condition(条件)· ChildQuest(子任务)
       需求:策划需要在玩法行为树里等「玩家传送完成」再往下推进,于是新增条件节点「当传送完成」。
       节点在 Condition(条件)节点的「条件类型:」下拉和 ChildQuest(子任务)节点的「子任务类型:」下拉里都要能选到;带一个可选参数「超时时间」,默认 10 秒(10000 毫秒),超时从第一个玩家客户端加载完成上报成功起算。
       传送范围、完成状态、超时判定全部由服务器实现,客户端本期只交付编辑器侧的条件配置,不新增任何运行时判定逻辑。
       实现细节:客户端的条件节点分两套互不相干的注册表,两边都得登记,只在一边加是选不到的:Condition(条件)节点走 ECondition,ChildQuest(子任务)节点走 EChildQuest。
       每套注册表各有三张 Scheme(配置结构)表要挂:任务树、玩法编辑器(LevelPlay)、野外事件(WildEvent)。节点本体是一个 createDynamicChildScheme:只出两个字段,ServerType 固定为本次新增的类型(判定在服务器,客户端不下发自己的结果),加上可选参数「超时时间」,用 createFloatScheme 配 Optional: true 与 CreateDefault: () => 10000,OnSchemeCheck 把 ≤ 0 拦成校验错误。
       两个坑:① ECondition 的枚举新增后,必须同步补 CommonConditionAllDefault(全量映射)里的 undefined 条目,少一条 tsc 直接编不过;② 客户端节点类型表里,这个条件映射到的仍服务器节点,不要在客户端新建一个空壳节点类。
       结果:「条件类型:」和「子任务类型:」两个下拉都出现「当传送完成」;不填超时按 10000 毫秒走,服务器从第一个玩家客户端加载完成上报成功开始计时。客户端未新增判定代码,条件同时配了一份测试用例 JSON,供策划导入后直接验证。
       验收:npx tsc -p tsconfig.json --noEmit 退出码 0;npx eslint --fix 对改动文件 0 error。
       编辑器读的是 Content/Aki/JavaScript/** 下的编译产物、不是 TS 源码,产物已自动增量编译,所以要重启编辑器才能在「条件类型:」下拉里看到新节点。

    b. 【关卡工具】任务s1183537,「发射关卡子弹」的子弹类型Id 加了编辑期存在性校验,读哪张子弹表由伤害来源与子弹类型共同决定
       模块:关卡工具 / 关卡子弹 · 发射关卡子弹 LaunchBullet · 子弹表配置
       需求:「发射关卡子弹」Action 里的「子弹类型Id」原来是纯数字输入,填了不存在 / 已删除的 Id 也能存盘,要等运行时才炸。需求是在编辑期就拦住:填的 Id 必须能在对应的子弹表里找到行。
       麻烦的地方在于读哪张表不是固定的——由同级字段「伤害来源」和「子弹类型」(子弹 / 发射器)共同决定,所以不能简单挂一个静态表路径了事。
       实现细节:① 字段改成从表里选行 —— 「子弹类型Id」用 createDataTableRowScheme(数据表行字段,Int 形态)替换原数字输入,下拉展示 RowName / Describe,DataTablePath 写成函数形态,按同级字段动态解析表路径。
       ② 按伤害来源分三支解析表路径:
       · 玩家(Player):固定走玩家的 DT_CommonBulletActor / DT_CommonBulletEmitter;
       · 怪物实体(MonsterEntity):表在运行时由 actorID 决定,静态解析不出来,返回空路径、跳过成员校验,只做非空;
       · 子弹类型Id > 10000:这是通用子弹,走 CDT_CommonBulletActors / CDT_CommonBulletEmitters(阈值与运行时 BulletConfigCache.IsCommonId 同一份口径);
       · 其余(环境怪 Default):ConfigId → 怪物总表的 CharacterConfigId → 角色总表 CDT_CharacterConfig 的 BulletTableConfig.BulletActorTable / BulletEmitterTable。
       「子弹类型」字段决定读子弹表还是发射器表,两张表都要读。
       ③ 父对象层再补一次成员校验 —— 字段形态的 DataTablePath 在 check 侧拿不到同级字段,只能做非空;而且改完伤害来源后,旧 Id 在新表里非法也检测不到。所以在父对象层补了 checkBulletConfigId,挂进 Action 的 OnSchemeCheck(校验钩子),用 ue.DataTable.Load 加 KuroDataTableFunctionLibrary.DataTableRowExists 查行是否存在。
       ④ 顺带放宽了字段形态 —— DataTableRowScheme 原来要求 Int 模式必须配 ValueColumn;子弹表没有整型列、行名本身就是要存的子弹类型Id,于是改成「Int 模式下不配 ValueColumn 就把 RowName 当数字存」。
       ⑤ 分层纪律 —— 这段代码在 EditorCommon,不可顶层 import Editor 与 ue:ModelConfigManager 用 require 懒加载,ue 只在 UE 分支内 require;非 UE 环境(Jenkins 数据校验)没有 DataTable 模块,只做非空校验。
       结果:「子弹类型Id」从手填数字变成带下拉的表行选择;填了表里不存在的 Id,编辑期直接报「子弹类型Id[x]在子弹表[...]中不存在,请检查是否填了已删除的Id」。切伤害来源 / 切子弹类型时,下拉读的表会跟着变。npx tsc -p tsconfig.json --noEmit 退出码 0。
       总结:① 表路径依赖同级字段时,校验要写两处 —— DataTablePath 给下拉用(render 侧能拿到 props.Parent.Value),成员校验得在父对象的 OnSchemeCheck 里补一次,否则「改了伤害来源、旧 Id 留在新表里非法」这种组合情况永远检测不到。
       ② 子弹类型Id 只能是 5 位以内的数字 —— 子弹服务器Id 是 15 位:归一化角色Id(6) + 子弹类型Id(5) + 子子弹Id(3) + 配置位(1)。客户端拼 Id 用的是 padStart(5, '0')(只补不截),7 位数会拼出 17 位,直接命中「子弹服务器Id 必须 15 位」的校验。后续谁想在玩家分支加 Id 区间硬校验,先过这一条。
       ③ 编辑器与运行时的分流阈值必须同源 —— 通用子弹的 10000 阈值在编辑器、TS 运行时、C++ 导出三处各有一份,改任一处成不同口径,就会出现「编辑器能配、运行时读不到」。
       顺带发现:策划文档《关卡子弹配置》里玩家分支写的是 2000001-2000999 区间,与实现对不上:7 位数拼不出 15 位服务器Id,表里玩家子弹的实际行名是 1000001 起,本工程内也搜不到 2000xxx 段。判断那句是在约束「玩家的实体配置id」而不是 Action 里的子弹类型Id,故未按文档加区间硬校验,属文档口径问题。

    c. 【主线】b1275298,同图传送的完成判据从「满 15 秒放行」改成「落点地面确认可站」,卡住不设客户端上限、只每 100 秒打一条告警
       模块:传送 / 同图传送安全等待 · TeleportSameSceneFlow · TeleportGroundProbe
       需求:同图传送的「完成」原来由计时器说话:TeleportSelfFallbackTimeoutMs = 15000 到点就强制结束等待、上报 LoadDone(14716)、清理移动锁与遮罩。这套做法产生三种「伪成功」:
       ① 报早了 —— LoadDone 的语义是「本端准备好了,可以让所有人继续」,地形还没流送出来就发,等于骗服务器:别人开始动,本端脚下还是空的。
       ② 解锁早了 —— 遮罩撤掉、移动解锁,但地面确认这时候才刚开始,玩家当着镜头的面穿地下坠,再靠坠落重生兜回来。
       ③ 放行早了 —— 贴地预检 20 次(约 1 秒)用完就放行,落点地形慢一点,就把「地形没好」当成「可以落地」。
       改造把「传送完成」的判定权从计时器交还给物理系统:上报 LoadDone、解锁移动、关无缝遮罩全部排在落点地面确认之后。卡住时不设客户端退出上限(宁可等,不伪造成功),但要保证线上能发现——每 100 秒打一条红色告警。
       实现细节:① 新增「等地面就绪」:TeleportGroundProbe.WaitForGroundReady(targetPos, finalize) 先立刻查一次,通过就直接 finalize('ready-on-first-check');没通过先打一条 Log.Error「传送-找不到合适的地面,九点探地未通过,继续等待」(带 targetX / targetY / targetZ),再用 TimerSystem.Forever 每 50 毫秒(GroundReadyCheckIntervalMs = 50)复查,通过后取消计时器并回报 ready-after-N-retries。判据本身没改:以服务器下发的目标坐标为心,±132 厘米的九宫格各点向下 200 厘米做 LineTrace(射线检测),九点全部命中才算就绪。
       ② 收尾顺序固定:集中在 TeleportSameSceneFlow.StartLevelStreamingFromSnapshot 的结果回调里——执行器 Success → SafeMoveTeleport → 等地面就绪 → 清安全等待标记 + 相机复位 → 上报 LoadDone(仍是 5/5/10/20/40 秒、最多 5 次、总窗口 LoadDoneTotalWindowMs = 80000)→ 解锁移动(带 groundConfirmed 时恢复 MOVE_Walking,不再落回坠落态)→ 关无缝遮罩 + 清告警 + 结束协议缓存。每一步之间复查 FlowGeneration(流程代次),代次变了立刻退出,不再往下推进。
       ③ 100 秒告警用轮询累计实现:TimerSystemConstants.MaxTime = 90000 限制单次定时器最长 90 秒,所以 SafetyWaitWarnIntervalMs = 100000 不能直接开成一个定时器,改成 TimerSystem.Forever 每 10 秒(SafetyWaitWarnTickMs = 10000)跑一次、累计到第 10 次才输出。输出一行带 7 个诊断字段:已等待毫秒、安全等待中、地形流送中、等相机朝向、执行器状态、ServerMove 监听中、普通场景加载中——一眼看出卡在哪一段。
       ④ 异常分支只记日志、不再退登:原来的 RecoverFromLoadingFailure、失败遮罩 TeleportSafetyFailure、IsRecoveringFromFailure 门面拦截和 BackLoginView 整段已删除。快照为空、流送失败、执行器异常、安全完成回调异常都只打一条 Log.Error,等待与锁保持原样;解除等待只剩三个出口:新请求接管(CancelCurrentRequest)、交接(HandoverCurrentRequest)、流程清理(Clear())。
       ⑤ 安全等待期间挡住抢先调用:IsSafetyWaitPending(IsWaitingForSafety 的 getter)在 8 处做 guard——门面 3 处、过场编排 4 处、V2 流程 1 处,只挡调用、不改状态,真正的收尾始终只由「安全条件就绪」那个回调完成。
       总结:把「等一个计时器」换成「等一个物理条件」,收益是行为诚实,代价是必须自己回答「这个条件永远不成立时怎么办」。这次的答案是一个明确选择而不是遗漏:不设客户端上限、每 100 秒告警、真的恢复不了就只能重登。两个已知失效成因也记在文档里——探的是服务器下发的目标坐标而不是角色实际位置,而每根射线只向下探 200 厘米,落地修正把角色挪走以后原坐标可能永远打不到;九点采样跨 ±132 厘米,外围点本来就没有地面时等多久都不会通过。凡是「等一个可能永远不来的条件」的流程,都要把两件事写在同一处:谁能解除,以及怎么发现。
       另外两条可复用的结论:定时器系统的单次上限(TimerSystemConstants.MaxTime = 90000)比想要的长周期短,长周期告警要用轮询累计,别一次开一个超长定时器;命令超时不是兜底——CommandExecutor 的 triggerCallbackOnTimeout 默认是 false,超时只打日志、连 Promise 都不会 resolve,所以无上限等待不能指望执行器超时把它推下去。

    d. 【关卡工具】任务s1181145,技能集合表导出 JSON 交给服务器,复用宝箱那套 FGameDTJsonExporter,行名为数字 Id、列含技能 Id 集合与集合自有 Tag
       模块:技能导出 / GameSkillDataExporter · GameDTJsonExporter · 技能集合表 DT_SkillCollectionForLevel
       需求:服务器要读「技能集合表」DT_SkillCollectionForLevel(行结构 SLevelSkillCollection),需要客户端把它导出成 JSON 交给服务器,口径与已经在给服务器用的 DTSkill.json 保持一致。
       两个硬要求:① 行 Id 必须是 int,服务器按整数索引,所以表行名本身就得是数字;② 列含技能 Id 集合与集合自有的 Tag(不复用 SkillTag),Tag 统一按 FNV 哈希成 int32 输出。
       同一张表的行名还要给 ChildQuest(子任务)节点的「技能监听」子任务类型下拉用,两边必须是同一份行名,不能各配一套。
       实现细节:① 复用宝箱的导出基类,不另起一套 —— 新增 FGameLevelSkillCollectionJsonExporter(final,继承 FGameDTJsonExporter,与 DTSkill.json 的 FGameSkillJsonExporter 同一个基类):文件名 DTSkillCollectionForLevel.json、RowStructName = SLevelSkillCollection、IdName = Id(首列字段名)、bWriteRowName = false(行名不进 JSON,Id 走列);CheckAndGenerateId 把行名 FCString::Atoi 成 int32 当 Id,返回 0 即跳过该行。
       ② 挂进导出注册表 —— GameSkillDataExporter 里加一个与既有阶段同构的 ExportSkillCollectionStage,并在注册表登记一行:{ SkillCollection, 加载并导出技能集合配置, 技能集合表, DT_SkillCollectionForLevel, &ExportSkillCollectionStage }。导出流程是注册表逐项 Entry.Exec(Context),登记漏了这一步就跑不到。
       ③ 配置落 UGameSkillExportSettings + DefaultEditor.ini —— 新增 SkillCollectionDTPath 与 SkillCollectionPropsToExport,ini 里配表路径加 SkillIds / CustomTags 两列,与其它表「一组 DTPath 配一组 PropsToExport」的写法一致。
       ④ 子任务侧同步 —— ChildQuest(子任务)节点新增「技能监听」类型 SkillListen、状态枚举 ESkillListenState(Cast / Hit / Finish),「技能集合」字段用 createDataTableRowScheme(数据表行字段)直接从 DT_SkillCollectionForLevel 选行,与导出读同一张表。
       ⑤ 跨流留档 —— 改动先在 0.7.1 的 CL 829666 留档,再迁到 0.8.0 的 CL 834775。
       结果:导出产物 Source/Config/Server/DataTable/DTSkillCollectionForLevel.json,格式与 DTSkill.json 一致:TAB 缩进、根 {"Data":[...]}、数组换行展开、Tag 为哈希后的 int32。0.8.0 上 6 个文件(导出器 .cpp/.h、GameSkillDataExporter .cpp/.h、GameSkillExportSettings.h、DefaultEditor.ini)在 CL 834775;ChildQuest.ts 的改动已被 AutoMerge 合入 0.8.0 depot,不需要再动。
       总结:① 给服务器新增一张表,只需三件事 —— 配置里加一组「DTPath + PropsToExport」、导出注册表登记一个 Stage、继承 FGameDTJsonExporter 写一个导出器。这印证了「不自己另写一套导出」是对的:DTSkill.json 与本次产物出自同一个基类,格式天然一致。
       ② 基类三条硬规则 —— 行名必须纯数字(非数字整行静默跳过,不报错)、列名等于结构体字段名、GameplayTag 一律 FNV 哈希成 int32。配表时行名不要写「组合1」这类文字,否则导出的 JSON 里那行会凭空消失。
       ③ 跨流迁移搁置必须指定目标流 —— 搁置内容存在 0.7.1 路径、而 workspace 只映射 0.8.0 时,p4 unshelve -s <CL> 直接报 No such file(s);要带 -S //game/branch_0.8.0,且文件参数写目标流的路径,unshelve 完必须 p4 resolve 才算落地。
       ④ 搁置不等于留档 —— 先 revert 再 shelve,搁置里存下的就是「没改」的内容,等于白留。留档之后要 p4 print @=<CL> 抽查一眼,确认搁置里真带着改动。

    e. 【日常】删掉客户端 GM 命令 TeleportPlayer(传送玩家到位置{X,Y,Z}),只删定义、不动调用方
       模块:GM 工具 / GmCommand · TeleportPlayer
       需求:客户端 GM 命令 TeleportPlayer(GM 列表里显示为「传送玩家到位置{X,Y,Z}」)要从 GM 列表里去掉,只删定义,调用方保持不动。
       这条 GM 自己没有独立逻辑:解析三个数值参数 → 把 TeleportController.ForceSimplifiedTeleportForGm 置 true(本次传送切简版:黑屏 + 系统 Loading,不做 Seq1 / Seq2 / Seq3 复杂表现)→ 再转发成 #move x y z,实际传送交给既有的 #move 命令。
       实现细节:删掉 GmCommand 上的三个静态成员:TeleportPlayerDescription、TeleportPlayerCategory(EGmCategory.Teleport)、TeleportPlayer(args) 方法。
       GM 是按「xxxDescription + xxxCategory + 同名静态方法」三件套登记的,三样一起删掉,GM 列表里才不会再出现这一条。
       未清理 import:TeleportController、EventSystem、EEventName 仍被文件里其它 GM 使用。
       结果:GmCommand.ts 里不再有 TeleportPlayer,IDE 无诊断;改动进 CL 837513。
       6 处 TestModuleBridge.RunGm('TeleportPlayer#x,y,z') 调用按约定保留,运行期会变成未知 GM——涉及 AutoRunCityOnline / AutoRunMainCityOnline / AutoRunIslandOnline 与 3 个 PerfTestCaseProfile。
       总结:客户端 GM 名是字符串派发的(RunGm('名字#参数')),删掉定义不会有任何编译错误,调用方只会静默失效。所以删 GM 之前先全仓搜 RunGm('<名字> 和这个名字的字面量,确认还有没有靠字符串在用的地方。

    f. 【主线】b1273319,传送功能未开启时客户端仍发包,被服务端拦截后没退出传送状态,只剩镜头可操作
       模块:传送 / TeleportController · SceneItemController · 功能开启 FunctionalController
       问题描述详细版本:线上单 b1273319:给传送配了功能开启条件(需完成序章)后,角色发起传送,客户端协议被服务端拦截,传送失败后角色只能操作镜头,其余操作全部失效。
       日志核对:客户端照常发出 WorldTeleportCSReq(23707),服务端 onTeleportCSReqPacket 判定 player cant access function:14、ret:-28000 后 exec packet handle failed,同时回了 TeleportSCRsp(23708),cnt=1 · body_length_avg=11——也就是说客户端拿得到这次失败的回包。
       原因:拦截是服务端做的,但「发起前先自查」和「失败后退出状态」两件事客户端都没做:
       ① 发包前没有功能开启检查 —— 传送的唯一发包点只校验 teleportId 是否合法就发 23707,不看 EFunctionType.Teleport(function:14)是否开启。功能屏蔽装饰器挂在传送入口上,但它只拦 Disabled / NonInteractive 两种状态,「功能未开启」不在它的判定里,所以没被挡住。
       ② 失败回包只打了日志 —— TeleportSCRsp 的 retcode 非成功分支只取了一条错误码文案,没有退出传送状态。
       ③ 恢复链等的是另一条协议 —— 传送的收尾靠服务端广播 ServerEntityMoveSCNotify(15511)触发 TeleportDownCallBack → RestorePlayerState。服务端这次直接拒绝、不会广播坐标,这条链永远不触发;而发起前已经 EnableInputControl(false) + EnableMainUi(false) 并开了黑屏 / 遮罩,于是输入锁与主 UI 锁永久残留,只剩相机还能动。
       解决方案:两端补齐,拦住入口 + 补一条失败出口:
       ① 客户端发包前先自查功能开启 —— TeleportController.OnStartUp 处理主动传送时先调新增的 CheckTeleportFunctionOpen(),用 FunctionalController.IsOpenByFunctionType(EFunctionType.Teleport) 判断(与服务端 function:14 同源);未开启直接拦截、不发 23707,并按既有范式弹 FunctionDefineConstants.FunctionDisableKey 提示,玩家看到的是「功能没开」而不是传送失败。服务端拦截保持不变,作为兜底。
       ② 失败回包退出传送状态 —— SceneItemController.RequestTeleport 的 23708 失败分支改调新增的 TeleportController.OnTeleportRequestFailed():先按已知原因走 RecoverPresentationFromTransitionFailure('服务端拒绝传送请求')(关遮罩、关黑屏、放行输入与主 UI),再 RestorePlayerState,最后按本次走的是 V2 还是老线调对应的 OnEnd() 收尾。入口先判 IsTeleporting,不在传送中直接返回;范式和既有的 OnLocalPlayerDead 对齐。
       ③ 兜底不再误报 —— RecoverPresentationFromTransitionFailure 加可选 reason 参数:不带(过场表现异常)仍打 Error,带(服务端正常拒绝这类已知中断)打 Warn 并记原因,不会再把一次正常拒绝写成「表现异常需修数据」。
       验证:npm run compile 退出码 0;产物 JS 里搜到 CheckTeleportFunctionOpen / OnTeleportRequestFailed,且 23708 失败分支已实际接线。功能未开启时点传送,客户端不发包、直接提示;人为构造失败回包时,黑屏与输入锁均正常退出。
       总结:「服务端拦得住」不等于「客户端可以不管」。判断功能是否开启要在发包前做,依据必须和服务端同一份数据(这里都是 EFunctionType.Teleport),否则两边口径一旦分叉,玩家看到的就是「点了没反应」或者「卡死在半路」。
       另一个更常见的坑:凡是「发起时锁输入、等某条回包再解锁」的流程,都要问一句这条回包在失败路径上会不会来。这个 bug 的恢复链挂在成功路径才有的广播协议上,失败时就成了没人解锁的死锁——凡是失败分支,都要有一条自己的出口,而不是指望同一条成功路径的收尾。
       顺带发现:排查时确认的一处判定盲区,未单独立条:挂在传送入口上的功能屏蔽装饰器只拦 Disabled / NonInteractive,不拦「功能未开启」,所以「靠装饰器兜住功能未开启」这条假设不成立,功能开启必须自己显式判断。

    g. 【主线】坠落重生完成后角色只能转镜头,输入掩码残留;诱因是低层黑屏无动画关闭时异常逃逸,打断了完成回调
       模块:坠落重生 + 边界重生 · 低层黑屏 LowLayerBlackScreenController · 输入掩码 OnlyAllow
       问题描述详细版本:偶现:爆烈洞窟之后的山洞坠落重生完成后,角色只能转动镜头,其余操作全部失效。
       日志核对(关卡 40065017):全文 [坠落重生输入] 只有 3 条 —— 第一轮 IsEnableInput: false(掩码 Add lookup / turn / zoom)、第一轮恢复 true(掩码 Remove ×3)、第二轮 false(Add ×3);第二轮之后再没出现过 true,也没再出现过 Remove。
       掩码残留当场可见:OnlyAllow拦截从第二轮起持续刷到世界清理(20:53 段 7200 条、20:54 段 3840 条,全文累计 17330 条),与「只剩镜头能转」完全吻合。
       原因:现行 bug 和它的诱因是两件事,叠在同一次坠落重生里:
       ① 第二轮恢复输入被句柄守卫吞掉 —— FallingRebornController.EndFallingReborn 把 SetEnableInputControl(true) 包在 if (this.CurrentRebornEntityHandle?.Valid) 里,句柄失效时静默跳过(Valid 依次查 Invalidated 和实体模型校验,失败只返回 false,本身不带日志)。主黑屏的关闭日志(「触发结束黑屏 async [坠落重生]」→「黑屏关闭动画结束 [Close]」)说明 await RemoveBlackScreenAsync 已经 resolve,其后却没有任何输入日志 —— 只可能是 Valid === false 把恢复吞了。两条「预防性保底」也都没兜住:HandleOnEnterLevel 两轮判定都是 IsLevelChanged: false,ReconnectCompleted 全程没发生。
       ② 诱因:第一轮低层黑屏关闭抛异常,打断了完成回调 —— 第一轮走的是坠落区域分支 DoFallZoneRespawn,它注册的 FallingRebornCompleted 回调里调 CloseLowLayerBlackScreenIfNeeded() → LowLayerBlackScreenController.CloseBlackScreen:HideTemp(EBlackScreenAnimType.None) 走无动画路径,SwitchState(HideWithOutAnim) 同步触发 FinishHidePromise(),把共享的静态引用 BlackScreenGlobalData.HidePromise 置成 undefined;紧跟着那句await BlackScreenGlobalData.HidePromise!.Promise 就裸读到 undefined,抛 TypeError: Cannot read properties of undefined (reading 'Promise')。异常从同步派发的 EventSystem.Emit 里逃逸,掐断了回调后续的状态复位(IsRespawning / IsInBoundary 没复位、低层黑屏面板也没走到 OnClear),34 ms 后第二轮重生就被触发。
       ③ 同类里唯一没守卫的那一处 —— 低层黑屏和主黑屏共用这一份静态 Promise(源码注释写明「不能和 BlackScreenController 同时使用,BlackScreenGlobalData 是共享的」),但主黑屏侧 RemoveBlackScreenAsync 有 if (BlackScreenGlobalData.HidePromise) 守卫,低层黑屏侧没有。
       解决方案:客户端两处已改(CL 831132),黑屏侧另出一份建议给对方:
       ① 恢复输入不再依赖句柄 —— EndFallingReborn 去掉 Valid 包裹,无条件 SetEnableInputControl(true)。依据:SetEnableInputControl 自己有幂等守卫(值相同直接 return),掩码按 reason('坠落重生')精确摘除、重复摘除无副作用,恢复动作没有理由跟句柄有效性绑在一起。
       ② 关低层黑屏异常不外逃 —— CloseLowLayerBlackScreenIfNeeded 给 CloseBlackScreen 补 .catch() + Log.Error,黑屏模块抛错不再打断 FallingRebornCompleted 回调里后续的状态复位。
       ③ 黑屏侧(建议 HCS 胡宸硕处理):CloseBlackScreen 补 if (BlackScreenGlobalData.HidePromise) 守卫;用 try / finally 保证 OnClear() 一定走到;治本方向是这份静态 Promise 不该被两条黑屏通道共用。
       验证:npx eslint --fix 0 error(只剩既有标识符 Respawning 的拼写 warning)、npm run tsc-native 通过、行尾仍为 CRLF。
       总结:凡是「先锁输入 / 先开黑屏,等某个信号再解锁」的流程,解锁动作不要挂在可能失效的对象上——句柄(Valid)和一次性的 Promise 引用都会悄悄失效,且失效时通常什么都不打,玩家看到的就是「只剩镜头能转」这种哑掉的状态。
       排查这类问题的快路径:直接数「锁」和「解锁」的日志条数对不对得上。这次全文 [坠落重生输入] 只有 3 条、第二轮之后再没出现 true,一眼就定位到恢复被吞;再看 OnlyAllow拦截一路刷屏,就能确认掩码确实残留。
       共享资源再记一条:同一份静态 Promise 被两条通道共用时,每条通道都要自己判空,不能指望「另一条通道有守卫」——这次就是主黑屏有、低层黑屏没有,同一次操作里前后脚把引用置空。
       顺带发现:① 没被 catch 的 async 调用会从同步 Emit 里逃逸,在 puerts 下只落成 v8::kPromiseRejectWithNoHandler + unhandledRejection 两行,不中断调用栈、也不阻止后续日志,排查时要主动去日志里搜 unhandledRejection。
       ② 黑屏模块的日志作者标记是 HCS(胡宸硕),修复建议按这个找人对口;LowLayerBlackScreenController 最近一次改动是 shisujuan(2026/08/25,主界面迁 HUD 层)。

    h. 【主线】传送 loading 之后地形流送「判据达成」的那一瞬画面闪一下:那一刻的关遮罩其实是空操作,相机硬切直接露了出来
       模块:传送 / 同图流送 TeleportSameSceneFlow · Seq3 收尾 TeleportSequenceFlow · 流送判据 GameModeController
       问题描述详细版本:飞书报障(关卡 / 副本):「角色传送 loading 后,地形流送的某个阶段里,角色相机表现异常」,具体描述是流送完成前的一瞬间相机会闪一下——不点钩锁也会出现、报障人本机必现。
       把「闪的那一瞬」对到代码上,是 TeleportSteamingComplete 触发的同图流送回调(TeleportSameSceneFlow)里连着做的两件事:
       ① 先 Callbacks.ResetCamera() —— 相机朝向按落点数据硬切,FightCameraLogicComponent 里直接 SetRotation,没有 blend 过渡;
       ② 紧接着 Presentation.CloseSeamlessTransitionMask('streaming-complete')。
       能确认闪的就是这一帧,靠的是报障日志里的 OnTeleportSelfTimeout(15 s 兜底)——这个符号只在 09/15 之前的 TeleportSameSceneFlow 里存在。
       顺带把判据本身说清:这里说的「流送完成」= GameModeController.CheckTargetStreamingCompleted,用 UE.WorldPartitionStreamingQuerySource(半径 7000,约 70 m)每 100 ms 轮询一次 IsStreamingCompleted(Activated, …),粒度是cell 已 Activated,不是地形几何 / 渲染全量。所以「判据达成」到「画面真的能看」之间天然还有一段窗口。
       原因:定责客户端(传送表现链路上缺遮挡),不是流送判据算错。两件事叠在一起:
       ① 那一刻的「关遮罩」是空操作 —— 主动传送到这一步,黑屏(ELayerType.CG 层,1<<10)从头就没开过:OpenSeamlessTransitionMask() 一进来就被 ShouldUseUiLoadingInsteadOfBlackScreen() 挡掉直接 return,遮挡改由系统 Loading(Loading 层,1<<8)承担。所以 CloseSeamlessTransitionMask('streaming-complete') 关的是一个根本不存在的东西;真正撤遮挡落在后面的 Seq3 就绪(TeleportSequenceFlow 里 CloseUiLoadingForSeq3Async('Seq3 已就绪'))。相机硬切就发生在这两次之间,中间只要没东西盖着,就会被看到。
       ② 报障人那份包在这段根本没有遮挡 —— 「给传送插 SceneLoadingOpenUiCmd / SceneLoadingCloseUiCmd」由 SceneLoadingTeleportCmd 里的 needLoadingUi = TeleportModel.IsTeleporting 决定,而这两条命令是 2026/09/15 的 CL 815371 才加进去的。他跑的是修复前的包,所以这一帧既没有黑屏也没有 Loading,硬切直接暴露。
       解决方案:代码侧已由两笔提交覆盖(不是本次新改):
       ① 把遮挡提前到流送之前 —— CL 815371(2026/09/15)在 TeleportTransitionFlow 的 OnStartLoad 里先 GameModeOpenLoading() 再启动流送,系统 Loading 从「离开旧关卡」那一刻起就盖着,一直盖到 Seq3 就绪才关;同一笔还把三处「超时强制关遮罩」(无缝遮罩 8 s、换落点 20 s 等)改成只打日志、不再强关,避免遮罩在流送中途被提前撤掉。命令链末尾的 SceneLoadingCloseUiCmd 在非 Login / 非无缝分支只 SetIsLoading(false),不会抢着撤遮挡。
       ② 相机侧收口 —— CL 827308(2026/09/18)给 CameraManager.ReleaseCamera 加 expectedViewTarget 目标校验(同模式已被其他演出接管时不再误释放),并在 TeleportTransitionFlow.OnEnd 补 StopTeleportFade(),兜底清掉残留在玩家身上的淡变材质。
       待验:还没有人拿 09/18 之后的包在原场景复验。复验只看两条日志——有「打开系统 Loading 遮罩」说明遮挡确实盖上了;传送-关系统 Loading 遮罩(async) 出现的时刻必须晚于相机重置那一帧。若第一条就没打,说明这次触发没走主动传送链(例如传送门的同步瞬移),遮挡缺口要另算。
       总结:「流送完成」是判据,不是画面就绪。这里的判据是 WorldPartition 的 cell 已 Activated(半径 70 m、100 ms 轮询),达成那一刻只说明数据到位,不代表画面能看——凡是「判据达成之后再动相机 / 撤遮挡」的写法,都要问一句这中间有东西盖着吗。
       这次踩的正是这个点位:回调里先硬切相机、再「关掉一个从来没开过的遮罩」。关遮罩的函数不报错,不等于遮罩存在——要判断遮挡在不在,得回到「谁开的、开的是哪一层」,而不是看关的那一句。这也解释了为什么 t1 的关遮罩日志很干净却什么都没关掉。
       顺带发现:① 这段链是新写的、不是有人动了老逻辑:TeleportSameSceneFlow / TeleportPresentation / TeleportSequenceFlow / TeleportV2Flow 在 P4 上都是 2026/09/08 才 add;09/08–09/18 共六笔(796995 / 803204 / 809088 / 811295 / 815371 / 827308)作者是同一账号。09/21 那笔脱离卡死迭代由 zhongmingkang 提交,只给 TeleportTransitionFlow 补了帧末兜底恢复。
       ② 一个便宜的版本判别法:OnTeleportSelfTimeout 只存在于 TeleportSameSceneFlow 的 rev#1 / rev#2(即 09/15 之前),当前分支里全库搜不到该符号——报障日志里出现它,就能直接断定对方跑的是修复前的包,不必反复让他复现。
       ③ SceneLoadingOpenUiCmd 非 Login 分支只 SetIsLoading(true)、不创建 Loading 界面这件事,已单独记在传送遮挡链路上三处会漏遮挡的口子。

    i. 【衍生】服务端反馈集合传送后不到半秒就收到 LoadDone;核对后确认客户端没抢跑,是 reason=15 下发了 (0,0,0),玩家被送到世界原点后坠落重生
       模块:传送 / ServerEntityMoveSCNotify(15511) · ClientSameSceneLoadDoneCSReq(14716) · TeleportSameSceneFlow
       问题描述详细版本:服务端反馈:集合(reason=15)触发服务器传送后不到半秒就收到 ClientSameSceneLoadDoneCSReq(14716),怀疑客户端「没加载完就上报」。把两端绝对时间戳摆出来对齐:服务端 setServerMoveLoad is_loading=true 11:33:13.381 → 收到 LoadDone 11:33:13.923,共 542ms;客户端收到 ServerEntityMoveSCNotify(15511)13.407 → 发出 14716 13.916,共 509ms。
       这 0.5s 在客户端日志里能逐项拆开:场景加载串行链中 SceneWaitWpStreamingCmd 单独占 0.506 秒,其余子命令(传送 / 新场景 / 完成)全是 0.000 秒——上报是在「流送等待之外确实没别的事可做」之后才发的。
       但同一条日志露出了真正的问题:15511 下发的玩家目标位置是 (0,0,0)。
       原因:① 目标坐标就是世界原点 —— 15511 入口日志打出 pos: 0.0,0.0,0.0;用客户端「本次位移」可以闭式验证:distanceMeters 132.10072062540263 恰好等于 |(7539.347, 10802.601, 983.899) − (0,0,0)| / 100,即 132.10m,快照目标就是原点。随后「开始传送」日志的 TargetBornLocation 也是 (0,0,0),该值直接取自 15511 的快照。
       ② 客户端拿不到别的坐标 —— WorldTeleportSelfSCNotify(15512)协议里只有 blackScreenType / dirPos / leaveShowType / enterShowType,不带任何坐标字段;客户端能用的目标只有 15511 的 position。所以全 0 不是客户端凑出来的。
       ③ 后果链 —— 贴地射线从 (0,0,0) 向下 1000cm isHit:false(世界原点没有地形),FixActorBornLocation 最终调整失败,玩家停在原点后位置状态 Ground→Air、坐标 Z 掉到 -13246.59,约 8 秒后触发 WorldFallingRebornCSReq(坠落重生)。同一天 11:24:58 另一次集合传送(distanceMeters 123.40576472151595)现象完全一致。
       解决方案:① 服务端(主责):确认集合传送(reason=15)为什么下发 position=(0,0,0)——大概率是这条路径没填目标点、走了默认值;补上真实集合点坐标后本条现象消失。
       ② 客户端(待评估,本次未改):TeleportGroundProbe.WaitForGroundReady 没有次数 / 时间上限,同图流程侧只有「只告警不退出」的计时器;一旦目标点永远贴不到地(比如又是全 0),LoadDone 会永远不上报、移动锁与遮罩不解除,服务端 is_loading 一直挂着。等服务端把坐标问题确认掉之后,再定是否补超时兜底,以及是否给 GATHER_AREA(15)加专属分支。
       总结:「客户端上报太快」这类反馈,先把两端绝对时间戳摆出来对齐,再看客户端那段时间究竟在等什么:这次的 0.5s 全部是场景加载链里的流送等待,客户端并没有抢跑。
       更值得记的一条:同图传送的坐标只能来自 15511,15512 不带坐标;一旦 15511 的 position 是全 0 默认值,客户端会忠实地把玩家送到世界原点,而这条链路上没有任何一步会因为「坐标看起来不对」而停下来。所以「服务端下发坐标 → 客户端直接应用」的链路,客户端侧至少要有一个「贴不到地就不算完成」的闸门(当前版本已经这样做了),但闸门必须留出口,否则问题会从「提前上报」变成「永不上报」。
       顺带发现:排查时同时确认的两点,未单独立条:
       ① 这份日志来自重构前的构建——日志里的「传送-流送完成但朝向包未到」「传送-解锁前贴地预检超时,强制解锁兜底」在当前源码里已经不存在,只存在于 .codely-cli/stash 的旧版 TeleportController;旧版顺序是「executor 成功 → SafeMoveTeleport → 立刻上报 LoadDone → 再贴地预检」,所以「流送一完成就上报」是旧版设计,半秒上报这一现象在当前版本已不复现。
       ② 客户端对 reason=15 没有专属分支——落 default 的通用 SafeMoveTeleport,于是错误坐标被当成一次正常远距离传送处理(距离 132m > 100m,升格判定还走了一次 uiload)。


2.下周工作计划:
    无。

3.待讨论的问题/需要分享的内容:
    无。
条目格式(本站统一) 需求按模块 → 需求 → 实现细节 → 结果 → 验收记录;不展示责任方,不写逐文件流水账。英文术语补充中文说明,代码标识保持原样。

6. 下周待办

  1. 无。