using System.Text.Json; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Teleport: inbound client/GM requests plus server-push primitives used by the admin panel. /// Lua (RPCSerializeAuto WriteTeleportOption/WritePreTeleportOption) is the wire truth; /// dump.cs TeleportOption/PreTeleportOption field order is mirrored 1:1 in TeleportMethods. /// internal sealed partial class GameRouter { /// Session key holding the client's own PreTeleportOption (echo source). private const string PendingTeleportOptionKey = "pending-teleport-option"; /// LoadingConfig.ConfigType 里「进出改装店」的取值(客户端 EnterExitCarShop)。 private const uint CarShopTeleportConfig = 8u; /// /// LoadingConfig.EnterExitRoomId = 景点 / 电梯传送门(MapentranceConfig.Type == 1) /// 的 AskTeleport config 值。客户端 RpcUtils.lua:88 就是拿它发起电梯事务的。 /// private const uint JiguanTeleportConfig = 2u; /// /// LoadingConfig 里「进出车库」的 AskTeleport config 值 /// (客户端 GarageManager.lua PlayLoadingCutscene 传的是 /// LTConfig.LoadingConfig.EnterExitGarage,实测线值 7; /// 对应 LoadingConfig.json 的 Id=7 / ConfigType=6 / PanelType=2)。 /// /// ⛔ 进库与出库共用这一个 config 值,只能靠 beforeResName 区分方向: /// 进库 = EnterTimeline[1](loading_garage_in04 / in05), /// 出库 = ExitTimeline[1](loading_garage_out01 / out03)。 /// private const uint GarageExitTeleportConfig = 7u; /// /// 会话键:一趟电梯事务「待生效的落点」。 /// /// 写入方 = AskEnterRaidByMapEntrance(推 SyncTeleport 的同一步); /// 消费方 = ReportPostTeleportFinish(过场全部结束)—— 见 /// 的说明: /// 客户端不落地,所以最后一帧用硬设位置通道补上。 /// private const string PendingJiguanLandingKey = "jiguan-pending-landing"; /// 一趟电梯事务的落点(起终点门 id 只用于日志/排查)。 private sealed record PendingJiguanLanding( float X, float Y, float Z, float Facing, uint SourceEntranceId, uint DestEntranceId); // ---------------------------------------------------------------------------------- // 改装店(ApplyCar / RepairShop)—— 2026-09-25 从俄语上游 donor 的 repair-shop.zip 移植。 // // 入场链路(客户端 ApplyCarManager.lua:152 EnterRepairShop): // 触发区(AskVehicleEnterArea) → CheckCanEnterCarShop → AskTeleport(config=8) // → 服务端 echo SyncPreTeleportOption → 客户端播 beforeResName 时间轴 // → ReportPreTeleportFinish → **AskTeleportToParkingWaypoint** // → 服务端推 SyncTeleport(人) + SyncTeleportVehicle(车) → ReportPostTeleportFinish。 // // ⛑⛑ 两处 donor 用实机日志证明、不看代码绝对猜不出来的坑: // ① config=8 的 option.position / afterPosition 客户端填的是 (0,0,0), // 过场结束时客户端会「应用 afterPosition」⇒ 不改写就把人和车甩到世界原点 // (donor 2026-09-22 18:28 实测, SyncChangeIndoor(4) 进店成功 2 秒后掉回家)。 // ② 关键一步是分布 AskTeleportToParkingWaypoint / LeavingWaypoint 里推 *人的* // SyncTeleport:只 ack 不推 ⇒ 客户端永远停在黑屏(17:11 / 17:31 实测), // post-finish 永远不来。而且 pre-finish 里不能推(会把驾驶中的玩家拽到半空)。 // ---------------------------------------------------------------------------------- /// 一间 Smokey Drift 的四个落成点(坐标见 )。 private sealed record ShopAnchor( GameMethods.TeleportVec3 Entrance, GameMethods.TeleportVec3 Interior, float Facing, GameMethods.TeleportVec3 EnterPos, float EnterYaw, GameMethods.TeleportVec3 Leave, float LeaveYaw); /// /// 两间 Smokey Drift 的落成坐标(donor 从客户端 waypoint 表导出,不是手调): /// 西门 CarShopConfig.Id=1(indoorID 4)、海边 Id=2(indoorID 47)。 /// Interior = ParkingWaypoint(地下工位,y<0),EnterPos = StopCarWaypoint(过场起点), /// Leave = LeaveWaypoint(离场落地点),Entrance 只用于「离玩家最近的是哪家店」。 /// private static readonly ShopAnchor[] ShopInteriors = [ new ShopAnchor(new(1439.10803f, 1.5f, 2379.6001f), new(1443.23f, -19.992f, 2351.428f), 180f, new(1434.62f, -0.01f, 2378.78f), 90f, new(1414.36f, 0f, 2379.78f), 176.5f), // CarShopConfig 1 — 西门 new ShopAnchor(new(2791.15601f, 1.37899995f, 1286.39001f), new(2783.912f, -11.92f, 1262.419f), 0f, new(2790.46f, -0.01f, 1290.93f), 180f, new(2817.31f, 0f, 1296.75f), 270f), // CarShopConfig 2 — 海边 ]; /// 按「离玩家最近的那家店」选店(没有位置信息时兜底西门)。 private static ShopAnchor NearestShop(Vec3? playerPos) { if (playerPos is not { } p) return ShopInteriors[0]; var best = ShopInteriors[0]; var bestD = float.MaxValue; foreach (var s in ShopInteriors) { var dx = s.Entrance.X - p.X; var dz = s.Entrance.Z - p.Z; var d = dx * dx + dz * dz; if (d < bestD) { bestD = d; best = s; } } return best; } /// 入口与出口共用 config=8,靠 beforeResName 里的 in/out 区分方向。 private static bool IsExitCutscene(GameMethods.PreTeleportOption option) => option.beforeResName is not null && option.beforeResName.Contains("out", StringComparison.OrdinalIgnoreCase); /// /// config=8 且 position 是全零时,照 donor 的做法用真实目标点填空: /// 进场填 Interior(工位)、离场填 Leave(街边),并同步 afterPosition / afterRot。 /// private ShopAnchor? PrepareCarShopOption(Connection conn, GameMethods.PreTeleportOption option, WorldEntryState state) { if (option.configId != CarShopTeleportConfig) return null; // 4s 防抖:客户端自己的触发区在离场落地后 0.3s 会补一发 garage_in* 的 AskTeleport, // 老实 echo 就把人/车又拽回店里,无限循环(donor 13:35 实测)。真正的「再进一次」 // 需要真的开车压一次触发区,耗时远大于 4s。 var isExit = IsExitCutscene(option); if (!isExit && state.LastShopExitTicks != 0 && Environment.TickCount64 - state.LastShopExitTicks < 4000) { conn.Log.Info("[TELEPORT] car-shop enter suppressed (within 4s of exit — client trigger re-fire)"); return null; } // 选店用「车的实时位置」,不用玩家坐标:AskVehicleMove 不更新玩家坐标,开车时它是过期的。 var shop = NearestShop(LastDrivenVehicleFix(conn.Session) is { } f ? new Vec3(f.X, f.Y, f.Z) : state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition : null); if (option.position.X == 0f && option.position.Y == 0f && option.position.Z == 0f) { var dest = isExit ? shop.Leave : shop.Interior; var destYaw = isExit ? shop.LeaveYaw : shop.Facing; option.position = dest; option.facing = destYaw; option.afterPosition = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z); option.afterRot = new GameMethods.TeleportVec3(0f, destYaw, 0f); } // 没有 spoon 图(我们 SyncEnterScene 的 SceneSpoonNames 是空的)时 // SpoonRaidMgr.GetWayPointPosition 返回 Vector3.zero 而不是 nil ⇒ 客户端「自以为成功」 // 并带上 customBeforeTrans=true + beforePosition=(0,0,0)。用落成坐标覆盖。 if (option.customBeforeTrans && option.beforePosition.X == 0f && option.beforePosition.Y == 0f && option.beforePosition.Z == 0f) { var anchorPos = isExit ? shop.Interior : shop.EnterPos; var anchorYaw = isExit ? shop.Facing : shop.EnterYaw; option.beforePosition = new GameMethods.TeleportVec3(anchorPos.X, anchorPos.Y, anchorPos.Z); option.beforeRot = new GameMethods.TeleportVec3(0f, anchorYaw, 0f); conn.Log.Info($"[TELEPORT] rewrote zero beforePosition ({(isExit ? "exit" : "enter")}) → ({anchorPos.X:F1},{anchorPos.Y:F1},{anchorPos.Z:F1}) yaw={anchorYaw:F0}"); } // 非 null = 「这是改装店事务,调用方去 echo」;坐标改写是原地作用在 option 上的。 return shop; } /// /// ★★ 景点 / 电梯传送门(config == 2,MapentranceConfig.Type == 1)—— /// 2026-09-30 三修:「进电梯不黑屏、但落地还在原地」的真正元凶在这里。 /// ///

前两修都改错了地方

/// 一修:开始推 SyncTeleport ⇒ 黑屏解了(ReportPostTeleportFinish result=0)。 /// 二修:SyncTeleport.MapEntranceId 从起点门改成落点门(343)⇒ 依旧原地 /// (13:38:43 实机:下发门=343 落点=(2277.54,160.60,1412.50),玩家仍在 /// [INDOOR] indoorId=332 绿屿公馆大厅)。 /// ///

真因(捐赠端在改装店上用实机日志证明过,见本文件顶部注释 ①)

///
/// config=8 的 option.position / afterPosition 客户端填的是 (0,0,0), /// 过场结束时客户端会「应用 afterPosition」 ⇒ 不改写就把人和车甩到世界原点 /// (donor 2026-09-22 18:28 实测,SyncChangeIndoor(4) 进店成功 2 秒后掉回家)。 ///
/// 电梯这条链路一模一样:RpcUtils.lua:88 只填了 /// beforePosition = cfg.Coordinate、afterResName = JiguanEndTimeline, /// afterPosition 是全零、customAfterTrans = false。 /// 而 SyncPreTeleportOption 我们一直是原样回显客户端那份 ⇒ 过场一结束, /// 客户端就把全零(或退化后的当前点)当成落点应用 —— 跟我们推的 SyncTeleport 无关。 /// ///

修法(照改装店 的成型做法)

/// 在 echo 之前原地改写 option:position / afterPosition 都填推导出来的 /// 落点,customAfterTrans = true。目的地仍由 /// 从 MapentranceConfig 推。 /// 起点门用 beforePosition 反查(客户端就是拿 cfg.Coordinate 填的, /// 不依赖 extParams 的解析 —— 那份契约我们还没有)。 /// /// SyncTeleport 那一帧保留(它是解开 loading 的必要条件),但不再是落点来源。 /// ///

★★ 起点门从哪来:extParams 里的 JSON(2026-09-30 实机取证)

/// 客户端 RpcUtils.lua:90-105 会把 extParams.mapEntranceId 一起塞进 /// PreTeleportOption.extParams(一个 JSON 字符串)。13:38:37 实机报文: /// /// {"configId":2, ..., "customAfterTrans":false, "afterPosition":{"X":0,"Y":0,"Z":0}, /// "beforePosition":{"X":2257.781,"Y":0,"Z":1458.242}, "beforeRot":{"X":0,"Y":90,"Z":0}, /// "extParams":"{\"mapEntranceId\":337}"} /// /// ⛑ 光靠 beforePosition 是分不开的:337 与 340 的 Coordinate 完全相同 /// (大厅同一个门位上下两个按钮),只有 EntranceFacing 差 180°(90 / -90)。 /// 所以优先读 extParams;读不到才退回「位置 + beforeRot.Y 朝向」匹配。 /// /// 回滚 = gameplay.mapEntrance.jiguanElevator=false。 ///
/// true = 这是电梯事务且已改写,调用方去 echo。 private static bool PrepareJiguanOption(Connection conn, GameMethods.PreTeleportOption option) { if (option.configId != JiguanTeleportConfig) return false; var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance; if (!cfg.Enabled || !cfg.JiguanElevator) { conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=disabled enabled={cfg.Enabled} jiguanElevator={cfg.JiguanElevator}"); return false; } var entranceFromExt = TryReadMapEntranceIdFromExtParams(option.extParams); MapEntranceEntry4229938? source = null; var how = string.Empty; if (entranceFromExt != 0) { source = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceFromExt && e.IsJiguan); how = source is null ? $"extParams={entranceFromExt}(表里没有)" : $"extParams={entranceFromExt}"; } if (source is null) { source = FindJiguanDoorByPosition(option.beforePosition, option.beforeRot.Y); how = source is null ? $"位置({option.beforePosition.X:F2},{option.beforePosition.Y:F2},{option.beforePosition.Z:F2})" + $"+朝向{option.beforeRot.Y:F0}" : $"位置+朝向={source.Id}"; } if (source is null) { conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=no-source-door {how} extParams={option.extParams ?? "-"} " + "既没有可用的 mapEntranceId,也没有 Type==1 的入口坐标与之吻合"); return false; } if (!JiguanElevatorCatalog4229938.TryResolve(source.Id, cfg.JiguanGroupRadius, cfg.JiguanLandingOverrides, out var dest, out var note)) { conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=unknown-id entrance={source.Id} {note}"); return false; } var to = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z); option.position = to; option.facing = dest.Facing; option.afterPosition = to; option.afterRot = new GameMethods.TeleportVec3(0f, dest.Facing, 0f); option.customAfterTrans = true; conn.Log.Info($"[TELEPORT] jiguan 改写 afterPosition 起点门={source.Id}({how}) " + $"落点门={dest.EntranceId} 落点=({dest.X:F2},{dest.Y:F2},{dest.Z:F2}) facing={dest.Facing:F0} " + $"resolved={dest.Resolved} {note}"); return true; } /// /// 从 PreTeleportOption.extParams 里取 mapEntranceId。 /// 客户端塞的是一个 JSON 字符串(实测 {"mapEntranceId":337};不是电梯事务时是 {})。 /// 解析失败一律返回 0,由调用方退回坐标匹配。 /// private static uint TryReadMapEntranceIdFromExtParams(string? extParams) { if (string.IsNullOrWhiteSpace(extParams)) return 0u; try { using var doc = JsonDocument.Parse(extParams); if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("mapEntranceId", out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetUInt32(out var id)) return id; } catch (JsonException) { // 客户端换了格式也无所谓 —— 退回坐标匹配即可。 } return 0u; } /// /// 按坐标 + 朝向反查景点 / 电梯门(客户端把 cfg.Coordinate 填进 beforePosition、 /// 把 cfg.EntranceFacing 填进 beforeRot.Y)。 /// /// ⛑ 必须先按「位置 + 朝向」筛:绿屿大厅 337 与 340 的 Coordinate 完全相同 /// (同一个门位的两个按钮),只有 EntranceFacing 差 180°(90 / -90), /// 而它俩的目的地一个是高楼层、一个是车库。只看坐标会挑到列表里靠后的那个。 /// private static MapEntranceEntry4229938? FindJiguanDoorByPosition(GameMethods.TeleportVec3 p, float facing) { if (!float.IsFinite(p.X) || !float.IsFinite(p.Y) || !float.IsFinite(p.Z)) return null; if (p.X == 0f && p.Y == 0f && p.Z == 0f) return null; const float tol = 0.5f; // 配置坐标是浮点,0.5m 足够且不会误并(最近的另一条门 ≥ 4m) MapEntranceEntry4229938? nearest = null; var nearestD = tol; MapEntranceEntry4229938? nearestFacing = null; var nearestFacingD = tol; foreach (var e in SceneCatalog4229938.MapEntrances) { if (!e.IsJiguan) continue; var dx = e.X - p.X; var dy = e.Y - p.Y; var dz = e.Z - p.Z; var d = MathF.Sqrt(dx * dx + dy * dy + dz * dz); if (d > tol) continue; if (d <= nearestD) { nearestD = d; nearest = e; } // 朝向按「模 360 的最短夹角」:下面这个式子得到 [-180,180) 的有符号差,取绝对值即夹角。 // ⛑ 判据是 `diff <= 1f`(夹角足够小)。别写成 `180f - diff <= 1f` —— 那匹配的是「朝向相反」。 var diff = MathF.Abs(((e.Facing - facing) % 360f + 540f) % 360f - 180f); if (diff <= 1f && d <= nearestFacingD) { nearestFacingD = d; nearestFacing = e; } } return nearestFacing ?? nearest; } /// /// ★★ 车库出库事务(config == 7 且 beforeResName 含 out)的落点改写。 /// 2026-09-30 —— 与景点电梯三修、改装店 是同一个坑: /// 过场结束时客户端应用的是 afterPosition,而客户端自己那份算不出来。 /// ///

为什么客户端算不出来

/// GarageManager.lua PlayLoadingCutscene 里(反编译后比较符方向已按语义还原): /// /// afterWaypointName = houseCfg.GarageOutWaypoint -- Spoon 航点 id /// afterTLName = houseCfg.ExitTimeline[2] /// if afterWaypointName and afterTLName and afterTLName ~= "" then /// local afterPos = gSpoonMgr:GetWayPointPositionByNameOrId(afterWaypointName) /// if afterPos then ... preTeleportOption.afterPosition = afterPos ... end /// end /// /// 两条都断:① 服务端没有 Spoon 位置表,客户端在私服里也查不到航点; /// ② 素光小筑(11200006)的 ExitTimeline 只有 1 个元素 /// (["loading_garage_out03"],别的房都是 ["out01","out03"])⇒ /// afterTLName = ExitTimeline[2] = nil ⇒ 整块跳过 ⇒ /// afterPosition 留全零(实测 23:38:24 报文 /// afterPosition:(0,0,0) customAfterTrans:false)⇒ 过场一结束就被甩到世界原点 / 黑屏。 /// ///

修法

/// 原地改写 option,然后由调用方自己 echo 一份 /// SyncPreTeleportOption(不走那个受 gameplay.mapEntrance 开关约束的 /// 通用 echo 分支 —— 车库不该被景点配置牵连):position / afterPosition 都填 /// 推出来的落点、customAfterTrans = true。 /// 过场起点(beforePosition)客户端同样拿不到 Spoon 航点(也是全零), /// 用玩家最近上报的位置兜底 —— 出库时玩家正坐在车位上的车里,那就是车位格。 /// /// ⚠️ PreTeleportOption 没有 MapEntranceId 字段(那是 /// SyncTeleport 的 TeleportOption 才有),落点门 id 只在 /// OnAskTeleportFromHouseGarage 推 SyncTeleport 那一步用。 /// /// 回滚 = gameplay.houseGarage.enabled=false(本方法直接返回,option 逐字节不变)。 ///
/// true = 这是车库出库事务且已改写,调用方去 echo。 private static bool PrepareGarageExitOption(Connection conn, GameMethods.PreTeleportOption option) { if (option.configId != GarageExitTeleportConfig) return false; if (!IsExitCutscene(option)) // 进库共用 config=7,靠 beforeResName 里的 out 区分 return false; if (!PrivateServerConfigStore.Current.Gameplay.HouseGarage.Enabled) { conn.Log.Info("[GARAGE] 出库事务跳过落点改写 原因=houseGarage.enabled=false"); return false; } var houseId = LastGarageHouseId(conn.Session); var landing = ResolveGarageExitLanding(houseId, conn.Log); var to = new GameMethods.TeleportVec3(landing.X, landing.Y, landing.Z); option.position = to; option.facing = landing.Facing; option.afterPosition = to; option.afterRot = new GameMethods.TeleportVec3(0f, landing.Facing, 0f); option.customAfterTrans = true; // 过场起点:客户端填的是 (0,0,0) + customBeforeTrans=true(Spoon 反查退化, // 见 PrepareCarShopOption 里同样的说明)。出库时玩家正坐在车位的车里 ⇒ // 最近上报的位置就是车位格。拿不到位置时才退回落点。 // ⛔ 注意 LastReportedPlayerPosition 是 RpcTypes.Vec3,不是 Game.TeleportVec3, // 不能直接赋值(CS0029)—— 拆成三个 float 再构造。 var before = option.beforePosition; if (option.customBeforeTrans && before.X == 0f && before.Y == 0f && before.Z == 0f) { var state = GetStateIfExists(conn.Session); if (state is not null && state.HasLastReportedPlayerTransform) { var reported = state.LastReportedPlayerPosition; before = new GameMethods.TeleportVec3(reported.X, reported.Y, reported.Z); option.beforeRot = new GameMethods.TeleportVec3(0f, landing.Facing, 0f); } else { before = to; } option.beforePosition = before; } conn.Log.Info($"[GARAGE] 出库事务改写落点 house={houseId} " + $"after=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) facing={landing.Facing:F0} " + $"门={landing.EntranceId}({landing.Source}) " + $"before=({before.X:F1},{before.Y:F1},{before.Z:F1}) customAfterTrans=true"); return true; } /// 把人 + 车一起挪到 dst/yaw(改装店进场/离场共用)。 private async Task PushPlayerAndVehicleAsync(Connection conn, WorldEntryState state, GameMethods.TeleportVec3 dst, float yaw, string reason) { await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption { teleportId = 0, Position = dst, Facing = yaw, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = 0, }); var vehicleId = ResolveShopVehicleId(conn.Session); if (vehicleId != 0) { await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938 { EntityId = vehicleId, Position = new SceneMethods.UxVector3(dst.X, dst.Y, dst.Z), Rotation = new SceneMethods.UxVector3(0f, yaw, 0f), Velocity = 0f, Reset = true, MoveToken = 0, }); } conn.Log.Info($"[TELEPORT] {reason} → pos=({dst.X:F1},{dst.Y:F1},{dst.Z:F1}) yaw={yaw:F0} vehicle={vehicleId}"); } /// /// 「这个 AskTeleport 事务要播 timeline」的判据:任一 timeline 资源名非空。 /// 决定是否把客户端发来的 option 原样回显(见 MapEntranceSettings.EchoPreTeleport)。 /// 实测数据点:地铁 config=5 → after=loading_metro_end_1;改装店 /// config=8 → loading_garage_in02(后者走它自己的分支,不经过这里)。 /// private static bool HasTimeline(GameMethods.PreTeleportOption option) => !string.IsNullOrEmpty(option.beforeResName) || !string.IsNullOrEmpty(option.loadingResName) || !string.IsNullOrEmpty(option.afterResName); /// 玩家最近开过的那辆车(服务端 Proxy 生成/F1 召唤才有)。 private static (float X, float Y, float Z, float Yaw)? LastDrivenVehicleFix(TcpSession session) { var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) { var id = runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId; if (id == 0 || !runtime.Vehicles.TryGetValue(id, out var rec)) return null; return (rec.Position.X, rec.Position.Y, rec.Position.Z, rec.Facing); } } private static ulong ResolveShopVehicleId(TcpSession session) { var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) return runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId; } internal static WorldEntryState? GetStateIfExists(TcpSession session) => session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing ? existing : null; // Client -> server: native loading-flow request. Accept it, remember the correlation // ids, and let the client drive its own flow; admin pushes use the outbound path below. [Handler(MethodId.AskTeleport)] private async Task AskTeleport(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args?.option is not null) { var option = args.option; var state = GetWorldState(msg.Context); lock (state.SyncRoot) { state.PendingTeleportId = option.teleportId; state.PendingTeleportPreFinished = false; state.PendingTeleportSyncSent = false; } msg.Context.Session.Items[PendingTeleportOptionKey] = option; // 新事务开始 ⇒ 上一趟电梯没走完(客户端 86s 后放弃 result=2)留下的落点必须作废, // 否则下一个无关的 ReportPostTeleportFinish 会把人硬拽到过期坐标。 // 车库出库落点同理:AskTeleport(config=7) 在前、AskTeleportFromHouseGarage 在后 // (后者才写这个键),所以在这里清是安全的。 msg.Context.Session.Items.Remove(PendingJiguanLandingKey); msg.Context.Session.Items.Remove(PendingGarageLandingKey); conn.Log.Info($"[TELEPORT] AskTeleport config={option.configId} teleportId={option.teleportId} pos=({option.position.X:F1},{option.position.Y:F1},{option.position.Z:F1}) facing={option.facing:F2} before=(res={option.beforeResName} custom={option.customBeforeTrans} {option.beforePosition.X:F1},{option.beforePosition.Y:F1},{option.beforePosition.Z:F1}) after=({option.afterPosition.X:F1},{option.afterPosition.Y:F1},{option.afterPosition.Z:F1} res={option.afterResName})"); // 改装店:服务端必须 echo 同一份 option(客户端冷推是不吃的 —— donor 证明只认 // 「自己发过的 AskTeleport 的回显」),并且 config=8 的全零坐标要在这里补齐。 var echo = PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled ? PrepareCarShopOption(conn, option, state) : null; await conn.ReturnEmptyOkAsync(msg); if (echo is not null) { await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option); conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (config={option.configId} teleport={option.teleportId})"); return; } // ★★ 2026-09-30 三修:景点 / 电梯(config=2)的落点必须写在 afterPosition 里。 // 见 PrepareJiguanOption 上的长注释 —— 真正的元凶是「过场结束时客户端应用 // afterPosition」,而客户端填的是 (0,0,0);SyncTeleport 那一帧不是落点来源。 if (PrepareJiguanOption(conn, option)) { await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option); conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (景点/电梯 config={option.configId} " + $"after=({option.afterPosition.X:F2},{option.afterPosition.Y:F2},{option.afterPosition.Z:F2}) " + $"customAfterTrans={option.customAfterTrans})"); return; } // ★★ 2026-09-30:车库出库事务(config=7 + beforeResName 含 out)的落点改写。 // 与景点电梯三修同一个坑:客户端算不出 afterPosition(GarageOutWaypoint 是 // Spoon 航点 id,且素光小筑的 ExitTimeline 只有 1 个元素 ⇒ 连 Spoon 反查都不做) // ⇒ 必须由服务端回显一份带真实落点的 option。这里自己 echo、不走下面那个 // 受 gameplay.mapEntrance 开关约束的通用分支(车库不该被景点配置牵连)。 if (PrepareGarageExitOption(conn, option)) { await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option); conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (车库出库 config={option.configId} " + $"after=({option.afterPosition.X:F2},{option.afterPosition.Y:F2},{option.afterPosition.Z:F2}) " + $"customAfterTrans={option.customAfterTrans})"); return; } // ⛑⛑ 2026-09-29:非改装店的「带 timeline 的传送事务」也要回显,否则客户端 loading // 流程停在第 2 步(AskTeleport → SyncPreTeleportOption → ReportPreTeleportFinish → // domain RPC),回调不执行 ⇒ domain RPC 一个都不发。 // 实测那一帧(10:22:22):AskTeleport config=5 after=(1878.8,-1.1,2250.5 // res=loading_metro_end_1) —— 地铁乘车动画事务,只 ack 不回显 ⇒「长按 F 没反应」。 if (PrivateServerConfigStore.Current.Gameplay.MapEntrance.Enabled && PrivateServerConfigStore.Current.Gameplay.MapEntrance.EchoPreTeleport && HasTimeline(option)) { await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option); conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (timeline 事务 config={option.configId} " + $"before={option.beforeResName ?? "-"} loading={option.loadingResName ?? "-"} after={option.afterResName ?? "-"})"); } return; } else { conn.Log.Warn($"[TELEPORT] AskTeleport undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 64))}"); } await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskTeleportToParkingWaypoint, HandlerPacketKind.Invoke)] private async Task AskTeleportToParkingWaypoint(Connection conn, UxRpcMessage msg) { if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[TELEPORT] parking-waypoint bad args bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 16))}"); await conn.ReturnEmptyOkAsync(msg); return; } // ⛑⛑ 这一步的本质就是「由服务端给出坐标」:只 ack 不推 ⇒ 客户端永远停在 loading // 黑屏且 post-finish 永不来(donor 17:11 / 17:31 实测)。目标是地下工位, // 不是街上的入口 marker。到位后客户端自动把玩家请下车(retail 行为: // ApplyCarManager 的 SetSeatInteractOperation(0,6) —— 车被抬上升降机)。 await conn.ReturnEmptyOkAsync(msg); var shop = args.waypointId is 2 or 1029714 or 1029715 or 1029716 ? ShopInteriors[1] : ShopInteriors[0]; await PushPlayerAndVehicleAsync(conn, GetWorldState(msg.Context), shop.Interior, shop.Facing, $"parking waypoint={args.waypointId}"); } [Handler(MethodId.AskTeleportToLeavingWaypoint, HandlerPacketKind.Invoke)] private async Task AskTeleportToLeavingWaypoint(Connection conn, UxRpcMessage msg) { if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[TELEPORT] leaving-waypoint bad args bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 16))}"); await conn.ReturnEmptyOkAsync(msg); return; } // ok 回执让客户端重置店内状态(OnLeaveRepairShopComplete / ParkingVehicleId=0), // 真正的位移 = 下面的 SyncTeleport + SyncTeleportVehicle。 await conn.ReturnEmptyOkAsync(msg); var state = GetWorldState(msg.Context); var shop = args.carshopid == 2u ? ShopInteriors[1] : ShopInteriors[0]; var leave = new GameMethods.TeleportVec3( shop.Leave.X + PrivateServerConfigStore.Current.Gameplay.RepairShop.ExitOffsetX, shop.Leave.Y, shop.Leave.Z + PrivateServerConfigStore.Current.Gameplay.RepairShop.ExitOffsetZ); state.LastShopExitTicks = Environment.TickCount64; await PushPlayerAndVehicleAsync(conn, state, leave, shop.LeaveYaw, $"leaving waypoint carshop={args.carshopid}"); } [Handler(MethodId.ReportPreTeleportFinish)] private async Task ReportPreTeleportFinish(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { var state = GetWorldState(msg.Context); lock (state.SyncRoot) { if (args.teleportId == state.PendingTeleportId) state.PendingTeleportPreFinished = true; } conn.Log.Info($"[TELEPORT] ReportPreTeleportFinish teleportId={args.teleportId} pending={state.PendingTeleportId}"); } else { conn.Log.Warn($"[TELEPORT] ReportPreTeleportFinish undecodable bytes={msg.Body.Length}"); } await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.ReportPostTeleportFinish, HandlerPacketKind.Notify)] private async Task ReportPostTeleportFinish(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { // result = lua 的 CheckEnum(result, 39, 0)(1 字节)。0 = 成功;非 0 是**客户端自己 // 认为传送没成功**的唯一信号,所以非 0 单独用 Warn 打出来,别混在 Info 里被刷掉。 if (args.result == 0) conn.Log.Info($"[TELEPORT] ReportPostTeleportFinish teleportId={args.teleportId} result=0(ok)"); else conn.Log.Warn($"[TELEPORT] ReportPostTeleportFinish teleportId={args.teleportId} result={args.result}「非0 ⇒ 客户端认为传送未成功」"); } else { conn.Log.Warn($"[TELEPORT] ReportPostTeleportFinish undecodable bytes={msg.Body.Length} " + $"hex={Convert.ToHexString(msg.Body ?? [], 0, Math.Min(msg.Body?.Length ?? 0, 16))}"); } // ★★ 2026-09-30 四修:电梯落点兜底。 // // 前三修把服务端侧做到了逐字节正确,客户端仍然一帧都不采纳(实测 14:19:12/:17/:22 // 三次 [INDOOR] indoorId=332 = 绿屿大厅,玩家原地)。所以这里不再依赖客户端的 loading // 流程,直接用「已被实测证明有效」的硬设位置通道补最后一帧: // 同一会话 14:16:48 后台瞬移 → (2627.48,188.74,2626.95) → 14:17:15 客户端报 // [INDOOR] 进入 indoorId=298(观澜公馆主体)⇒ 高楼层可达、通道有效。 // // 落点由 AskEnterRaidByMapEntrance 在推 SyncTeleport 的同一步写进会话; // 这里读一次就删(一趟事务只补一次)。 if (conn.Session.Items.TryGetValue(PendingJiguanLandingKey, out var rawLanding) && rawLanding is PendingJiguanLanding landing) { conn.Session.Items.Remove(PendingJiguanLandingKey); if (PrivateServerConfigStore.Current.Gameplay.MapEntrance.JiguanHardSet) { // 立刻一帧:客户端若已经收尾完成就会直接生效。 await TeleportPlayerInstantAsync(msg.Context, landing.X, landing.Y, landing.Z, landing.Facing, $"jiguan landing door={landing.DestEntranceId}"); // 500ms 后再补一帧:loading 面板还在淡出时第一帧会被吞掉(照 ture 分支 // ReportPostTeleportFinish 里 seat re-assert 的 retry 做法)。同坐标重复下发无副作用。 // ⛑ 这里用「会话级」发送 + CancellationToken.None,不用 msg.Context —— // 本方法返回后 RpcContext 的 token 可能已被取消,延迟发送会直接失败。 _ = ReassertJiguanLandingAsync(conn.Session, ResolveActiveUnitId(conn.Session), landing); conn.Log.Info($"[JIGUAN] 硬设落点 起点门={landing.SourceEntranceId} 落点门={landing.DestEntranceId} " + $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) facing={landing.Facing:F0} 原因=客户端不采纳 SyncTeleport"); } else { conn.Log.Info($"[JIGUAN] 硬设落点 跳过 原因=jiguanHardSet=false " + $"落点=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})(只推 SyncTeleport)"); } } // ★★ 2026-09-30:车库出库落点兜底(与上面景点电梯四修同一个套路)。 // 出库事务(AskTeleport config=7 beforeResName=loading_garage_out*)的落点客户端自己 // 算不出来:HouseConfig.GarageOutWaypoint 是 Spoon 航点 id,服务端没有 Spoon 位置表; // 而 ExitTimeline 只有 1 个元素的房(素光小筑)客户端连 Spoon 反查都不做 ⇒ // afterPosition 全零。服务端在 AskTeleportFromHouseGarage 推了 SyncTeleport + // VisitorExit 落点,这里再过场收尾那一帧硬设一次玩家 + 车辆位置(客户端不一定采纳 // SyncTeleport 的 Position,见景点电梯三/四修)。 if (conn.Session.Items.TryGetValue(PendingGarageLandingKey, out var rawGarageLanding) && rawGarageLanding is PendingGarageLanding garageLanding) { conn.Session.Items.Remove(PendingGarageLandingKey); if (PrivateServerConfigStore.Current.Gameplay.HouseGarage.Enabled) { await TeleportPlayerInstantAsync(msg.Context, garageLanding.X, garageLanding.Y, garageLanding.Z, garageLanding.Facing, $"garage-exit landing house={garageLanding.HouseId}"); if (garageLanding.VehicleId != 0) { await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938 { EntityId = garageLanding.VehicleId, Position = new SceneMethods.UxVector3(garageLanding.X, garageLanding.Y, garageLanding.Z), Rotation = new SceneMethods.UxVector3(0f, garageLanding.Facing, 0f), Velocity = 0f, Reset = true, MoveToken = 0, }); } // 500ms 后再补一帧(loading 面板淡出时第一帧会被吞掉,同景点电梯四修)。 // ⛑ 用会话级发送 + CancellationToken.None —— 本方法返回后 msg.Context 的 // token 可能已取消,延迟发送会直接失败。 _ = ReassertGarageLandingAsync(conn.Session, ResolveActiveUnitId(conn.Session), garageLanding); conn.Log.Info($"[GARAGE] 出库硬设落点 house={garageLanding.HouseId} " + $"pos=({garageLanding.X:F2},{garageLanding.Y:F2},{garageLanding.Z:F2}) " + $"facing={garageLanding.Facing:F0} 车={garageLanding.VehicleId}"); } } } /// /// 延迟补发一帧落点(jiguanHardSet 的第二发)。失败只记日志,不影响主流程。 /// 走 + /// CancellationToken.None:本方法的调用者(handler)已经返回,请求上下文可能已失效。 /// private static async Task ReassertJiguanLandingAsync( TcpSession session, ulong unitId, PendingJiguanLanding landing) { try { await Task.Delay(500); // 手动序列化:TcpSession 只有 byte[] 重载,而且这里必须显式传 CancellationToken.None // (见方法注释)。 var payload = WorldCodec.PositionAndFacing(unitId, new Vec3(landing.X, landing.Y, landing.Z), landing.Facing); await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(payload), CancellationToken.None); session.Log.Info($"[JIGUAN] 硬设落点 补发 door={landing.DestEntranceId} unit={unitId} " + $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})"); } catch (Exception ex) { session.Log.Warn($"[JIGUAN] 硬设落点 补发失败: {ex.Message}"); } } /// /// 延迟补发一帧车库出库落点(ReportPostTeleportFinish 硬设之后 500ms 的第二发)。 /// 与景点电梯四修同一套路:loading 面板还在淡出时第一帧会被吞掉。 /// 同坐标重复下发无副作用;失败只记日志,不影响主流程。 /// 走 + /// CancellationToken.None:调用它的 handler 已经返回,请求上下文可能已失效。 /// private static async Task ReassertGarageLandingAsync( TcpSession session, ulong unitId, PendingGarageLanding landing) { try { await Task.Delay(500); var payload = WorldCodec.PositionAndFacing(unitId, new Vec3(landing.X, landing.Y, landing.Z), landing.Facing); await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(payload), CancellationToken.None); if (landing.VehicleId != 0) { await session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(new SceneMethods.SyncTeleportVehicle4229938 { EntityId = landing.VehicleId, Position = new SceneMethods.UxVector3(landing.X, landing.Y, landing.Z), Rotation = new SceneMethods.UxVector3(0f, landing.Facing, 0f), Velocity = 0f, Reset = true, MoveToken = 0, }), CancellationToken.None); } session.Log.Info($"[GARAGE] 出库硬设落点 补发 house={landing.HouseId} unit={unitId} " + $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) 车={landing.VehicleId}"); } catch (Exception ex) { session.Log.Warn($"[GARAGE] 出库硬设落点 补发失败: {ex.Message}"); } } /// /// 当前该用哪个单位 id 发 SyncUnitPositionAndFacing。 /// 与 取同一个来源,保证补发那一帧打在同一实体上。 /// private static ulong ResolveActiveUnitId(TcpSession session) { var state = GetStateIfExists(session); if (state is null) return Profile.InitialUnitId; lock (state.SyncRoot) return state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; } // GM service Teleport(x, z): UXRPCMethodArgs62733679. Log it and treat it as a // same-map XZ jump hint (Y stays at the last reported height). [Handler(MethodId.Teleport)] private async Task GmTeleportXZ(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { conn.Log.Info($"[TELEPORT] GM Teleport x={args.x:F1} z={args.z:F1}"); var state = GetWorldState(msg.Context); float y; lock (state.SyncRoot) y = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition.Y : 0f; await TeleportPlayerInstantAsync(msg.Context, args.x, y, args.z, null, "gm-Teleport-xz"); } else { conn.Log.Warn($"[TELEPORT] GM Teleport undecodable bytes={msg.Body.Length}"); } await conn.ReturnEmptyOkAsync(msg); } /// /// Instant move without a loading screen: SyncUnitPositionAndFacing (68030312) with the default /// SetPositionType.SwitchSpirit (3). Updates the tracked transform optimistically. /// /// ⚠️ Not SyncUnitPosition_P (68233969): that one is refused for the local avatar — /// NetworkGhostModule.ForceSetPositionAndFacing needs NetworkRoleType.Proxy, and the main /// character is not a proxy (Player.log: "Role(None)不可强设位置!!"). /// internal static async Task TeleportPlayerInstantAsync( RpcContext ctx, float x, float y, float z, float? facing, string reason) { var state = GetWorldStateStatic(ctx); ulong unitId; float face; lock (state.SyncRoot) { unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing); state.LastReportedPlayerPosition = new Vec3(x, y, z); state.LastReportedPlayerRotation = new Vec3(0f, face, 0f); state.HasLastReportedPlayerTransform = true; } await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing, WorldCodec.PositionAndFacing(unitId, new Vec3(x, y, z), face)); ctx.Session.Log.Info($"[TELEPORT] instant reason={reason} unit={unitId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}"); } /// /// Loading-flow move: SyncPreTeleportOption (64167855) + SyncTeleport (64030755) /// with a fresh teleport id. The client is expected to answer with /// ReportPreTeleportFinish / ReportPostTeleportFinish (logged by handlers above). /// internal static async Task TeleportPlayerWithLoadingAsync( RpcContext ctx, float x, float y, float z, float facing, string reason) { var state = GetWorldStateStatic(ctx); ulong unitId; ulong teleportId; lock (state.SyncRoot) { unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; teleportId = (ulong)Interlocked.Increment(ref s_teleportIdCounter); state.PendingTeleportId = teleportId; state.PendingTeleportPreFinished = false; state.PendingTeleportSyncSent = true; state.LastReportedPlayerPosition = new Vec3(x, y, z); state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f); state.HasLastReportedPlayerTransform = true; } var pre = new GameMethods.PreTeleportOption { configId = 0, teleportId = teleportId, ForceClear = false, TimeLineDration = 0, position = new GameMethods.TeleportVec3(x, y, z), facing = facing, beforeResName = string.Empty, customBeforeTrans = false, beforePosition = new GameMethods.TeleportVec3(x, y, z), beforeRot = new GameMethods.TeleportVec3(0f, facing, 0f), loadingResName = string.Empty, afterResName = string.Empty, customAfterTrans = false, afterPosition = new GameMethods.TeleportVec3(x, y, z), afterRot = new GameMethods.TeleportVec3(0f, facing, 0f), extParams = string.Empty, }; await ctx.NotifyAsync(MethodId.SyncPreTeleportOption, pre); await ctx.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption { teleportId = teleportId, Position = new GameMethods.TeleportVec3(x, y, z), Facing = facing, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = 0, }); ctx.Session.Log.Info($"[TELEPORT] loading-flow reason={reason} unit={unitId} teleportId={teleportId} pos=({x:F2},{y:F2},{z:F2}) facing={facing:F2}"); return teleportId; } private static long s_teleportIdCounter = 900_000; private static WorldEntryState GetWorldStateStatic(RpcContext ctx) { if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing) return existing; var created = new WorldEntryState(); ctx.Session.Items[WorldStateKey] = created; return created; } }