package routers import ( "context" "sync" ) var _ = sync.Mutex{} var _ = context.Background // // 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. // // 按「离玩家最近的那家店」选店(没有位置信息时兜底西门)。 func GameRouter_Teleport_NearestShop(ctx context.Context, playerPos any) any { // Router implementation for GameRouter_Teleport_NearestShop var zero any; return zero } // 入口与出口共用 config=8,靠 beforeResName 里的 in/out 区分方向。 func GameRouter_Teleport_IsExitCutscene(ctx context.Context, option any) bool { // Router implementation for GameRouter_Teleport_IsExitCutscene return true } // // config=8 且 position 是全零时,照 donor 的做法用真实目标点填空: // 进场填 Interior(工位)、离场填 Leave(街边),并同步 afterPosition / afterRot。 // func GameRouter_Teleport_PrepareCarShopOption(ctx context.Context, conn any, option any, state any) any { // Router implementation for GameRouter_Teleport_PrepareCarShopOption var zero any; return zero } // // ★★ 景点 / 电梯传送门(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 无关。 //

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

// 在 echo 之前原地改写 option:position / afterPosition 都填推导出来的 // 落点,customAfterTrans = true。目的地仍由 // JiguanElevatorCatalog4229938 从 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。 func GameRouter_Teleport_PrepareJiguanOption(ctx context.Context, conn any, option any) bool { // Router implementation for GameRouter_Teleport_PrepareJiguanOption return true } // // 从 PreTeleportOption.extParams 里取 mapEntranceId。 // 客户端塞的是一个 JSON 字符串(实测 {"mapEntranceId":337};不是电梯事务时是 {})。 // 解析失败一律返回 0,由调用方退回坐标匹配。 // func GameRouter_Teleport_TryReadMapEntranceIdFromExtParams(ctx context.Context, extParams any) uint32 { // Router implementation for GameRouter_Teleport_TryReadMapEntranceIdFromExtParams return 0 } // // 按坐标 + 朝向反查景点 / 电梯门(客户端把 cfg.Coordinate 填进 beforePosition、 // 把 cfg.EntranceFacing 填进 beforeRot.Y)。 // ⛑ 必须先按「位置 + 朝向」筛:绿屿大厅 337 与 340 的 Coordinate 完全相同 // (同一个门位的两个按钮),只有 EntranceFacing 差 180°(90 / -90), // 而它俩的目的地一个是高楼层、一个是车库。只看坐标会挑到列表里靠后的那个。 // func GameRouter_Teleport_FindJiguanDoorByPosition(ctx context.Context, p any, facing float32) any { // Router implementation for GameRouter_Teleport_FindJiguanDoorByPosition var zero any; return zero } // // ★★ 车库出库事务(config == 7 且 beforeResName 含 out)的落点改写。 // 2026-09-30 —— 与景点电梯三修、改装店 PrepareCarShopOption 是同一个坑: // 过场结束时客户端应用的是 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 都填 // ResolveGarageExitLanding 推出来的落点、customAfterTrans = true。 // 过场起点(beforePosition)客户端同样拿不到 Spoon 航点(也是全零), // 用玩家最近上报的位置兜底 —— 出库时玩家正坐在车位上的车里,那就是车位格。 // ⚠️ PreTeleportOption 没有 MapEntranceId 字段(那是 // SyncTeleport 的 TeleportOption 才有),落点门 id 只在 // OnAskTeleportFromHouseGarage 推 SyncTeleport 那一步用。 // 回滚 = gameplay.houseGarage.enabled=false(本方法直接返回,option 逐字节不变)。 // // true = 这是车库出库事务且已改写,调用方去 echo。 func GameRouter_Teleport_PrepareGarageExitOption(ctx context.Context, conn any, option any) bool { // Router implementation for GameRouter_Teleport_PrepareGarageExitOption return true } // 把人 + 车一起挪到 dst/yaw(改装店进场/离场共用)。 func GameRouter_Teleport_PushPlayerAndVehicleAsync(ctx context.Context, conn any, state any, dst any, yaw float32, reason string) any { // Router implementation for GameRouter_Teleport_PushPlayerAndVehicleAsync var zero any; return zero } // // 「这个 AskTeleport 事务要播 timeline」的判据:任一 timeline 资源名非空。 // 决定是否把客户端发来的 option 原样回显(见 MapEntranceSettings.EchoPreTeleport)。 // 实测数据点:地铁 config=5 → after=loading_metro_end_1;改装店 // config=8 → loading_garage_in02(后者走它自己的分支,不经过这里)。 // func GameRouter_Teleport_HasTimeline(ctx context.Context, option any) bool { // Router implementation for GameRouter_Teleport_HasTimeline return true } // 玩家最近开过的那辆车(服务端 Proxy 生成/F1 召唤才有)。 func GameRouter_Teleport_LastDrivenVehicleFix(ctx context.Context, session *GameSession) any { // Router implementation for GameRouter_Teleport_LastDrivenVehicleFix var zero any; return zero } func GameRouter_Teleport_ResolveShopVehicleId(ctx context.Context, session *GameSession) uint64 { // Router implementation for GameRouter_Teleport_ResolveShopVehicleId return 0 } func GameRouter_Teleport_GetStateIfExists(ctx context.Context, session *GameSession) any { // Router implementation for GameRouter_Teleport_GetStateIfExists var zero any; return zero } // 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. func GameRouter_Teleport_AskTeleport(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_Teleport_AskTeleport var zero any; return zero } func GameRouter_Teleport_AskTeleportToParkingWaypoint(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_Teleport_AskTeleportToParkingWaypoint var zero any; return zero } func GameRouter_Teleport_AskTeleportToLeavingWaypoint(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_Teleport_AskTeleportToLeavingWaypoint var zero any; return zero } func GameRouter_Teleport_ReportPreTeleportFinish(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_Teleport_ReportPreTeleportFinish var zero any; return zero } func GameRouter_Teleport_ReportPostTeleportFinish(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_Teleport_ReportPostTeleportFinish var zero any; return zero } // // 延迟补发一帧落点(jiguanHardSet 的第二发)。失败只记日志,不影响主流程。 // 走 TcpSession.NotifyAsync(uint, byte[], CancellationToken) + // CancellationToken.None:本方法的调用者(handler)已经返回,请求上下文可能已失效。 // func GameRouter_Teleport_ReassertJiguanLandingAsync(ctx context.Context, session *GameSession, unitId uint64, landing any) any { // Router implementation for GameRouter_Teleport_ReassertJiguanLandingAsync var zero any; return zero } // // 延迟补发一帧车库出库落点(ReportPostTeleportFinish 硬设之后 500ms 的第二发)。 // 与景点电梯四修同一套路:loading 面板还在淡出时第一帧会被吞掉。 // 同坐标重复下发无副作用;失败只记日志,不影响主流程。 // 走 TcpSession.NotifyAsync(uint, byte[], CancellationToken) + // CancellationToken.None:调用它的 handler 已经返回,请求上下文可能已失效。 // func GameRouter_Teleport_ReassertGarageLandingAsync(ctx context.Context, session *GameSession, unitId uint64, landing any) any { // Router implementation for GameRouter_Teleport_ReassertGarageLandingAsync var zero any; return zero } // // 当前该用哪个单位 id 发 SyncUnitPositionAndFacing。 // 与 TeleportPlayerInstantAsync 取同一个来源,保证补发那一帧打在同一实体上。 // func GameRouter_Teleport_ResolveActiveUnitId(ctx context.Context, session *GameSession) uint64 { // Router implementation for GameRouter_Teleport_ResolveActiveUnitId return 0 } // 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). func GameRouter_Teleport_GmTeleportXZ(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_Teleport_GmTeleportXZ var zero any; return zero } // // 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)不可强设位置!!"). // func GameRouter_Teleport_TeleportPlayerInstantAsync(ctx context.Context, rpcCtx *RpcContext, x float32, y float32, z float32, facing any, reason string) any { // Router implementation for GameRouter_Teleport_TeleportPlayerInstantAsync var zero any; return zero } // // 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). // func GameRouter_Teleport_TeleportPlayerWithLoadingAsync(ctx context.Context, rpcCtx *RpcContext, x float32, y float32, z float32, facing float32, reason string) uint64 { // Router implementation for GameRouter_Teleport_TeleportPlayerWithLoadingAsync return 0 } func GameRouter_Teleport_GetWorldStateStatic(ctx context.Context, rpcCtx *RpcContext) any { // Router implementation for GameRouter_Teleport_GetWorldStateStatic var zero any; return zero } // 一趟电梯事务的落点(起终点门 id 只用于日志/排查)。 type PendingJiguanLanding struct { X float32 `json:"x"` Y float32 `json:"y"` Z float32 `json:"z"` Facing float32 `json:"facing"` SourceEntranceId uint32 `json:"sourceEntranceId"` DestEntranceId uint32 `json:"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 的四个落成点(坐标见 ShopInteriors)。 type ShopAnchor struct { Entrance TeleportVec3 `json:"entrance"` Interior TeleportVec3 `json:"interior"` Facing float32 `json:"facing"` EnterPos TeleportVec3 `json:"enterPos"` EnterYaw float32 `json:"enterYaw"` Leave TeleportVec3 `json:"leave"` LeaveYaw float32 `json:"leaveYaw"` }