using Ananta.SDK.Logging; 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 SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 车库 / 停车(House Garage)—— 2026-09-30。 /// /// 用户报的症状(3 条):① 靠近车位按 F「调取载具」能开选车面板(纯客户端 UI), /// 选完车后按 F「驶离车库」毫无后续;② 车的模型不出现在车位;③ 车库大门不开。 /// /// 根因(客户端 GarageManager.lua 逐行读出来的): /// /// ⛔⛔ 2026-09-30 追加:真正的第一道墙在客户端配置里。 /// HouseConfig[11200003].GarageIndoorId 官方写成了 301(= 绿屿公馆主体), /// 真实车库 indoor 是 302(IndoorConfig:301 绿屿公馆 / 302 绿屿公馆车库)。 /// ChangeIndoor 建的是 garageIndoorIdList[GarageIndoorId] = houseId, /// 而 LoadingFinish 用当前 gMapManager.IndoorId 查它 ⇒ 站在车库里 /// (实测客户端只报 332 大厅 → 302 车库,从不经过 301)永远查不到 ⇒ /// CurrentHouseId 恒为 0 ⇒ SwitchCarprotVehicle 第一行 /// parkinginfolist[0] 为空直接 FatalLog("换车失败,该房屋未购买或数据异常, houseId=0") /// + return ⇒ 连 AskHouseMoveParkingSpace 都发不出去(车不出、也没有「驶离车库」)。 /// 5 套房里另外 4 套的 GarageIndoorId 都等于各自车库 indoor(201/300/299/379),只有绿屿错。 /// 服务端改不了(CurrentHouseId / garageIndoorIdList / parkinginfolist /// 全是纯客户端状态,没有任何 RPC 能写;HouseConfig 是客户端自带表,服务端那份不下发) /// ⇒ 走客户端 fastpatch tools/garage_indoor_fix.lua(rawset(getmetatable(row),...), /// 非 BepInEx)。本文件的所有 handler 只有在那一行修好之后才可能被调用。 /// 这一族 9 条 RPC 在服务端一直是 DefaultReturnCatalog4229938 的 /// shape="void" 空 body 兜底 —— 客户端发出去的请求永远等不到有意义的回应。 /// 两个关键 S2C 服务端从来不发: /// SyncHouseParking(64277819)(车位→车的清单,触发进库演出 + 车辆生成) /// 和 SyncHouseParkingEntityBinding(64060781)(每辆车的 EntityId)。 /// LeaveGarage(行 156) 第一步就读 currentParkingInfo.ParkingVehicleUid[parkingId], /// 而这个表只能由 SyncHouseParkingEntityBinding 填 ⇒ 为空 ⇒ /// 行 164 FatalLog("离开车库","ParkingVehicleUid为空") + 行 165 return ⇒ /// 连 AskTeleportFromHouseGarage 都没发出去 —— 这就是「按 F 没后续」。 /// /// /// 本文件做的事:把 9 条 RPC 全部接起来,按客户端 GarageManager 的期望回包: /// /// AskHouseParking / AskHouseMoveParkingSpace / GmHouseParking: /// 记账(Progress.GarageParking)+ 落盘 + 回推 SyncHouseParking(addInfoList)。 /// ⛔ AskHouseMoveParkingSpace(换车 / 调取载具)还要**自己 spawn 车 + 推 /// SyncHouseParkingEntityBinding** —— 客户端在这条链路上 IsSwitchingCar=true, /// SyncHouseParking 的处理会跳过进库演出,因而永远不会自己发 /// AskCreateHouseParkingVehicles(详见 EnsureParkingVehiclesAsync)。 /// 而 AskHouseParking(开自己的车进库)不推 SyncHouseParking:客户端回调里自己会发 /// AskCreateHouseParkingVehicles(GarageManager.lua:124),再推一次会把进库演出播两遍。 /// AskCreateHouseParkingVehicles:命门 —— 为该房每个已指派车位 /// SpawnDirectAsync 出一辆车(CreateSourceType = 10 HouseParking、 /// destroyPrevious:false 以便多车位共存),再回推 /// SyncHouseParkingEntityBinding(填 ParkingVehicleUid)。 /// AskDestroyHouseParkingVehicles:出库销毁本房车辆。 /// AskTeleportToHouseGarage:只回 OK(进库演出由客户端自己播)。 /// AskTeleportFromHouseGarage:开门(复用 PushDoorOpen + 缓存的门 id)+ 回 OK。 /// AskHouseCancelParking / GmHouseCancelParking:取消指派 + 回推 SyncHouseParking(cancelInfoList)。 /// /// /// 车辆落点:HouseConfig.ParkingSpaceWaypoints 是 Spoon 航点 id /// (如 1893051),服务端没有 Spoon 位置表(E:\ConfigDump 里查不到), /// 所以无法把车精确摆到车位格。退而求其次:落在玩家当前位置(进库演出刚把玩家放到车位, /// 或玩家就站在车库里),前向偏移由 gameplay.houseGarage.spawnForwardOffset 热调。 /// 车一旦生成,客户端就能按 F 上车 / 驶离。 /// /// 回滚:gameplay.houseGarage.enabled=false(全部回空 body 兜底), /// 或把 9 条 mid 从 Enabled4229938MethodIds 删掉。两者都与改动前逐字节一致。 /// internal sealed partial class GameRouter { private const string GarageRuntimeKey = "client4229938-garage"; /// 本会话运行时的车库实体表:houseId → (车位号(0 基) → 车辆 entityId)。 private sealed class GarageRuntimeState { public object SyncRoot { get; } = new(); public Dictionary> EntitiesByHouse { get; } = new(); /// /// 最近一次车库 RPC 里出现的 houseId。给「出库传送」用:AskTeleport(config=7) /// 的 PreTeleportOption 里没有 houseId(客户端只填了 timeline 资源名), /// 而它前面必定先来过一条带 houseId 的车库 RPC(AskDestroyHouseParkingVehicles 4B / /// AskCreateHouseParkingVehicles 4B / AskHouseMoveParkingSpace 的元素里), /// 所以在这里记一份。 /// public uint LastHouseId; } /// 记下最近一次车库事务的 houseId(出库传送要用来查落点)。 private static void RememberGarageHouse(TcpSession session, uint houseId) { if (houseId == 0) return; var runtime = GetGarageRuntime(session); lock (runtime.SyncRoot) runtime.LastHouseId = houseId; } /// 本会话最近一次车库事务的 houseId;没有则 0。 private static uint LastGarageHouseId(TcpSession session) { var runtime = GetGarageRuntime(session); lock (runtime.SyncRoot) return runtime.LastHouseId; } /// /// 玩家此刻正在开的车(entityId)。出库销毁必须跳过它 —— /// 客户端一坐进车位上的车就会走「驶离车库」流程、发 AskDestroyHouseParkingVehicles, /// 无差别销毁会把玩家正坐着的车一起拆掉(2026-09-30 实测:23:38:20 上车, /// 同秒 SyncDestroyVehicle,模型当场消失、角色保持开车姿势悬空,4 秒后 /// LeaveGarage 再也找不到车辆实例 ⇒ 出库演出卡死 ⇒ 黑屏)。 /// /// 三个信号取并集,都在「当前」语义上: /// CurrentVehicleId(AskPlayerStartEnterOrExitVehicle 写)、 /// StoryEnterVehicleId(剧情通道 S011PlayerNetEnterVehicleRequest 写,实测走的是这条)、 /// Vehicles[id].ControllerPid == 本玩家 pid。 /// 不用 LastDrivenVehicleId:它下车不清,会把早先停好的车误判成「正开着」。 /// private static HashSet ResolveDrivenVehicleIds(TcpSession session) { var driven = new HashSet(); var runtime = GetVehicleRuntimeState(session); var pid = Profile.PlayerPid; lock (runtime.SyncRoot) { if (runtime.CurrentVehicleId != 0) driven.Add(runtime.CurrentVehicleId); if (runtime.StoryEnterVehicleId != 0) driven.Add(runtime.StoryEnterVehicleId); if (pid != 0) { foreach (var (id, rec) in runtime.Vehicles) { if (rec.ControllerPid == pid) driven.Add(id); } } } return driven; } private static GarageRuntimeState GetGarageRuntime(TcpSession session) { if (session.Items.TryGetValue(GarageRuntimeKey, out var raw) && raw is GarageRuntimeState existing) return existing; var created = new GarageRuntimeState(); session.Items[GarageRuntimeKey] = created; return created; } private static HouseGarageSettings GarageCfg => PrivateServerConfigStore.Current.Gameplay.HouseGarage; // ========================================================================================= // 1) AskHouseParking(63154177) —— 「开着自己的车进车库」 // ========================================================================================= [Handler(MethodId.AskHouseParking, HandlerPacketKind.Invoke)] private async Task OnAskHouseParking(Connection conn, UxRpcMessage msg) { if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!TryReadGarageArgs(conn, msg, out SceneMethods.AskHouseParkingArgs4229938 args)) { await conn.ReturnEmptyOkAsync(msg); return; } var world = GetStateIfExists(conn.Session); if (world is null) { await conn.ReturnEmptyOkAsync(msg); return; } var added = new List(); lock (world.SyncRoot) { foreach (var item in args.parkingInfoList) { if (item.HouseId == 0 || item.VehicleId == 0) continue; var index = AssignFreeSpace(world, item.HouseId, item.VehicleId); if (index < 0) { conn.Log.Warn($"[GARAGE] 入库失败:house={item.HouseId} 没有空车位(车={item.VehicleId})"); continue; } added.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = item.HouseId, VehicleId = item.VehicleId, ParkingSpaceIndex = index, }); } } if (added.Count > 0) PlayerProgressStore.Save(world, conn.Log, quiet: true); if (added.Count > 0) RememberGarageHouse(conn.Session, added[0].HouseId); // ⛔ 这里**不推** SyncHouseParking:客户端 AskHouseParking 的回调里自己会发 // AskCreateHouseParkingVehicles(见 GarageManager.lua:124),再推一次会把进库演出 // 触发两遍。落盘 + 回 OK 即可。 conn.Log.Info($"[GARAGE] AskHouseParking 入库 {added.Count} 辆 " + $"({string.Join(",", added.Select(a => $"house={a.HouseId}/space={a.ParkingSpaceIndex}/veh={a.VehicleId}"))})"); await conn.ReturnEmptyOkAsync(msg); } // ========================================================================================= // 2) AskHouseMoveParkingSpace(63780060) —— 「调取载具 / 换车」(用户场景的主链路) // ========================================================================================= [Handler(MethodId.AskHouseMoveParkingSpace, HandlerPacketKind.Invoke)] private async Task OnAskHouseMoveParkingSpace(Connection conn, UxRpcMessage msg) { if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!TryReadGarageArgs(conn, msg, out SceneMethods.AskHouseMoveParkingSpaceArgs4229938 args)) { await conn.ReturnEmptyOkAsync(msg); return; } var world = GetStateIfExists(conn.Session); if (world is null) { await conn.ReturnEmptyOkAsync(msg); return; } var cancel = new List(); var add = new List(); // 落位后要确保「车位上有活着的车」的清单:(houseId, 车位号, 车辆, 这辆车在本房的旧车位)。 // 旧车位用来把运行时的实体搬过来复用 —— 换车位不该重新生成一辆车。 var wanted = new List<(uint HouseId, int Space, uint VehicleId, int PreviousSpace)>(); lock (world.SyncRoot) { foreach (var item in args.moveParkingSpaceInfoList) { if (item.HouseId == 0 || item.ParkingSpaceIndex < 0) continue; if (!world.Progress.GarageParking.TryGetValue(item.HouseId, out var spaces)) { spaces = new Dictionary(); world.Progress.GarageParking[item.HouseId] = spaces; } if (item.VehicleId == 0) { // 清空某个车位。 if (spaces.TryGetValue(item.ParkingSpaceIndex, out var old) && old != 0) { spaces.Remove(item.ParkingSpaceIndex); cancel.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = item.HouseId, VehicleId = old, ParkingSpaceIndex = item.ParkingSpaceIndex, }); } continue; } // 同一辆车先从这个房的其它车位挪走(避免一辆车占两格)。 var previousSpace = -1; foreach (var key in spaces.Where(kv => kv.Value == item.VehicleId).Select(kv => kv.Key).ToList()) { spaces.Remove(key); previousSpace = key; cancel.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = item.HouseId, VehicleId = item.VehicleId, ParkingSpaceIndex = key, }); } // 目标格若已有别的车,先请出去。 if (spaces.TryGetValue(item.ParkingSpaceIndex, out var occupant) && occupant != 0 && occupant != item.VehicleId) { spaces.Remove(item.ParkingSpaceIndex); cancel.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = item.HouseId, VehicleId = occupant, ParkingSpaceIndex = item.ParkingSpaceIndex, }); } spaces[item.ParkingSpaceIndex] = item.VehicleId; add.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = item.HouseId, VehicleId = item.VehicleId, ParkingSpaceIndex = item.ParkingSpaceIndex, }); wanted.Add((item.HouseId, item.ParkingSpaceIndex, item.VehicleId, previousSpace)); } } PlayerProgressStore.Save(world, conn.Log, quiet: true); if (wanted.Count > 0) RememberGarageHouse(conn.Session, wanted[0].HouseId); await PushSyncHouseParkingAsync(conn.Session, cancel, add, conn.Log); // ⛔⛔ 这里**必须自己生成车**。客户端 SwitchCarprotVehicle 在发这条请求前就把 // IsSwitchingCar 置了 true,于是 SyncHouseParking 的处理里 // (GarageManager.lua:641 `if self.IsSwitchingCar then ... return`)会跳过进库演出, // 也就永远不会发 AskCreateHouseParkingVehicles ⇒ 车位上有数据、但场上没有模型。 // 所以换车/调取载具这条链路,spawn + SyncHouseParkingEntityBinding 都得服务端自己来。 foreach (var group in wanted.GroupBy(w => w.HouseId)) { var bindings = await EnsureParkingVehiclesAsync( conn, group.Key, group.Select(w => (w.Space, w.VehicleId, w.PreviousSpace)).ToList()); if (bindings.Count > 0) await PushSyncHouseParkingEntityBindingAsync(conn.Session, bindings, conn.Log); } conn.Log.Info($"[GARAGE] AskHouseMoveParkingSpace 取消={cancel.Count} 新增={add.Count}"); await conn.ReturnEmptyOkAsync(msg); } // ========================================================================================= // 3) AskHouseCancelParking(63495236) / GmHouseCancelParking(65601170) —— 按车辆 id 取消 // ========================================================================================= [Handler(MethodId.AskHouseCancelParking, HandlerPacketKind.Invoke)] private async Task OnAskHouseCancelParking(Connection conn, UxRpcMessage msg) { if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!TryReadGarageArgs(conn, msg, out SceneMethods.AskHouseCancelParkingArgs4229938 args)) { await conn.ReturnEmptyOkAsync(msg); return; } var cancel = CancelVehicles(conn, args.vehicleIdList); await conn.ReturnEmptyOkAsync(msg); conn.Log.Info($"[GARAGE] AskHouseCancelParking 取消={cancel.Count}"); } [Handler(MethodId.GmHouseCancelParking, HandlerPacketKind.Invoke)] private async Task OnGmHouseCancelParking(Connection conn, UxRpcMessage msg) { if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!TryReadGarageArgs(conn, msg, out SceneMethods.GmHouseCancelParkingArgs4229938 args)) { await conn.ReturnEmptyOkAsync(msg); return; } var cancel = CancelVehicles(conn, args.vehicleIdList); await conn.ReturnEmptyOkAsync(msg); conn.Log.Info($"[GARAGE] GmHouseCancelParking 取消={cancel.Count}"); } // ========================================================================================= // 4) GmHouseParking(65027125) —— GM:把 houseIdList[i] 的车 vehicleIdList[i] 停进去 // ========================================================================================= [Handler(MethodId.GmHouseParking, HandlerPacketKind.Invoke)] private async Task OnGmHouseParking(Connection conn, UxRpcMessage msg) { if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } if (!TryReadGarageArgs(conn, msg, out SceneMethods.GmHouseParkingArgs4229938 args)) { await conn.ReturnEmptyOkAsync(msg); return; } var world = GetStateIfExists(conn.Session); if (world is null) { await conn.ReturnEmptyOkAsync(msg); return; } var add = new List(); lock (world.SyncRoot) { var count = Math.Min(args.houseIdList.Count, args.vehicleIdList.Count); for (var i = 0; i < count; i++) { var houseId = args.houseIdList[i]; var vehicleId = args.vehicleIdList[i]; if (houseId == 0 || vehicleId == 0) continue; var index = AssignFreeSpace(world, houseId, vehicleId); if (index < 0) continue; add.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = houseId, VehicleId = vehicleId, ParkingSpaceIndex = index, }); } } if (add.Count > 0) { PlayerProgressStore.Save(world, conn.Log, quiet: true); await PushSyncHouseParkingAsync(conn.Session, [], add, conn.Log); } await conn.ReturnEmptyOkAsync(msg); conn.Log.Info($"[GARAGE] GmHouseParking 新增={add.Count}"); } // ========================================================================================= // 5) AskCreateHouseParkingVehicles(63479109) —— 命门:spawn 车 + 回填 EntityId // ========================================================================================= [Handler(MethodId.AskCreateHouseParkingVehicles, HandlerPacketKind.Invoke)] private async Task OnAskCreateHouseParkingVehicles(Connection conn, UxRpcMessage msg) { var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u; if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } var world = GetStateIfExists(conn.Session); if (world is null || houseId == 0) { await conn.ReturnEmptyOkAsync(msg); return; } RememberGarageHouse(conn.Session, houseId); Dictionary? spaces = null; lock (world.SyncRoot) { if (world.Progress.GarageParking.TryGetValue(houseId, out var stored) && stored.Count > 0) spaces = new Dictionary(stored); } if (spaces is null) { conn.Log.Info($"[GARAGE] AskCreateHouseParkingVehicles house={houseId} 无车位指派 ⇒ 不生成车"); await conn.ReturnEmptyOkAsync(msg); return; } var wanted = spaces.OrderBy(kv => kv.Key) .Select(kv => (kv.Key, kv.Value, -1)) .ToList(); var binding = await EnsureParkingVehiclesAsync(conn, houseId, wanted); if (binding.Count > 0) await PushSyncHouseParkingEntityBindingAsync(conn.Session, binding, conn.Log); conn.Log.Info($"[GARAGE] AskCreateHouseParkingVehicles house={houseId} 生成/回填 {binding.Count} 辆"); await conn.ReturnEmptyOkAsync(msg); } // ========================================================================================= // 6) AskDestroyHouseParkingVehicles(63266373) —— 出库销毁本房车辆 // ========================================================================================= [Handler(MethodId.AskDestroyHouseParkingVehicles, HandlerPacketKind.Invoke)] private async Task OnAskDestroyHouseParkingVehicles(Connection conn, UxRpcMessage msg) { var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u; if (!GarageCfg.Enabled) { await conn.ReturnEmptyOkAsync(msg); return; } var runtime = GetGarageRuntime(conn.Session); List? entities = null; lock (runtime.SyncRoot) { runtime.LastHouseId = houseId != 0 ? houseId : runtime.LastHouseId; if (runtime.EntitiesByHouse.TryGetValue(houseId, out var map)) { entities = map.Values.Where(id => id != 0).Distinct().ToList(); // ⛔ 只清「车位 → 实体」映射,**不销毁**玩家正开着的那辆车。 // 被跳过的那辆保持映射,下次进库 EnsureParkingVehiclesAsync 会复用它, // 不会重复生成。详见 ResolveDrivenVehicleIds 的注释。 var driven = ResolveDrivenVehicleIds(conn.Session); foreach (var (space, id) in map.Where(kv => driven.Contains(kv.Value)).ToList()) { map.Remove(space); conn.Log.Info($"[GARAGE] 出库保留玩家正开的车 entity={id} house={houseId} space={space}(不销毁)"); } } } // ⛑ await 不能写在 lock 体内(CS1996)—— 先出锁再销毁。 if (entities is null) { await conn.ReturnEmptyOkAsync(msg); return; } // 玩家正开着的车不销毁:客户端一坐进车位上的车就会走「驶离车库」流程并发这条 RPC, // 无差别销毁会把玩家正坐着的车一起拆掉 ⇒ 模型当场消失、角色保持开车姿势悬空 // (2026-09-30 23:38:20 实测),随后 LeaveGarage 找不到车辆实例 ⇒ 出库演出卡死黑屏。 var keep = ResolveDrivenVehicleIds(conn.Session); var destroyed = 0; var vehicleRuntime = GetVehicleRuntimeState(conn.Session); foreach (var entityId in entities) { if (keep.Contains(entityId)) { conn.Log.Info($"[GARAGE] 出库跳过销毁 entity={entityId}(玩家正开着)"); continue; } await DestroyVehicleAsync(conn.Session, vehicleRuntime, entityId, $"garage-exit house={houseId}"); destroyed++; } conn.Log.Info($"[GARAGE] AskDestroyHouseParkingVehicles house={houseId} 销毁 {destroyed}/{entities.Count} 辆" + $"(保留玩家正开的 {entities.Count - destroyed} 辆)"); await conn.ReturnEmptyOkAsync(msg); } // ========================================================================================= // 7) AskTeleportToHouseGarage(63099218) —— 进库(演出客户端自己播,回 OK 即可) // ========================================================================================= [Handler(MethodId.AskTeleportToHouseGarage, HandlerPacketKind.Invoke)] private async Task OnAskTeleportToHouseGarage(Connection conn, UxRpcMessage msg) { var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u; var spaceIndex = msg.Body.Length >= 8 ? BitConverter.ToInt32(msg.Body, 4) : -1; RememberGarageHouse(conn.Session, houseId); conn.Log.Info($"[GARAGE] AskTeleportToHouseGarage house={houseId} space={spaceIndex} ⇒ ok"); await conn.ReturnEmptyOkAsync(msg); } // ========================================================================================= // 8) AskTeleportFromHouseGarage(63203581) —— 出库:开门 + **推 SyncTeleport 落点** + 回 OK // ========================================================================================= /// /// 一趟出库事务待落的点(写于 , /// 消费于 ReportPostTeleportFinish 的硬设兜底 —— 与景点电梯四修同一个套路)。 /// internal sealed record PendingGarageLanding( uint HouseId, float X, float Y, float Z, float Facing, ulong VehicleId); internal const string PendingGarageLandingKey = "client4229938-garage-landing"; [Handler(MethodId.AskTeleportFromHouseGarage, HandlerPacketKind.Invoke)] private async Task OnAskTeleportFromHouseGarage(Connection conn, UxRpcMessage msg) { var houseId = msg.Body.Length >= 4 ? BitConverter.ToUInt32(msg.Body, 0) : 0u; var spaceIndex = msg.Body.Length >= 8 ? BitConverter.ToInt32(msg.Body, 4) : -1; var vehicleConfigId = msg.Body.Length >= 12 ? BitConverter.ToUInt32(msg.Body, 8) : 0u; RememberGarageHouse(conn.Session, houseId); if (GarageCfg.Enabled && GarageCfg.OpenDoorOnExit) OpenCachedGarageDoor(conn); // ⛔⛔ 这里**必须推一帧 SyncTeleport**,否则就是黑屏。 // 客户端 LeaveGarage 走的是原生 loading 事务: // AskTeleport(config=7, beforeResName=loading_garage_out*) ← 播离库过场 // → ReportPreTeleportFinish // → AskTeleportFromHouseGarage(本方法) // 而它的回调(GarageManager.lua PlayLoadingCutscene 内)**只在失败时**才 // FatalLog + StopLoading,成功分支什么都不做 —— 也就是说「结束加载画面」 // 这件事完全交给服务端。原实现只 ReturnEmptyOkAsync ⇒ 客户端永远停在 // loading ⇒ 过场播完就黑屏(2026-09-30 23:38 实测:23:38:25 收到本 RPC 并 ack, // 之后再没有 ReportPostTeleportFinish,indoor 于 23:38:27 归零后一直黑)。 // 与景点电梯「一修」是同一个根因,同一个修法。 if (!GarageCfg.Enabled) { conn.Log.Info($"[GARAGE] AskTeleportFromHouseGarage house={houseId} space={spaceIndex} " + "⇒ ok(enabled=false,不推 SyncTeleport,逐字节等于改动前)"); await conn.ReturnEmptyOkAsync(msg); return; } var landing = ResolveGarageExitLanding(houseId, conn.Log); var state = GetStateIfExists(conn.Session); ulong teleportId = 0; if (state is not null) { lock (state.SyncRoot) { teleportId = state.PendingTeleportId; state.PendingTeleportSyncSent = true; state.LastReportedPlayerPosition = new Vec3(landing.X, landing.Y, landing.Z); state.LastReportedPlayerRotation = new Vec3(0f, landing.Facing, 0f); state.HasLastReportedPlayerTransform = true; } } var driven = ResolveDrivenVehicleIds(conn.Session); var vehicleId = driven.Count > 0 ? driven.First() : 0UL; // ⛑ 先 ack、再推 —— 与 donor 在改装店验证过的顺序一致 // (GameRouter.Teleport.cs 的 AskTeleportToLeavingWaypoint:ReturnEmptyOkAsync 之后 // 才 PushPlayerAndVehicleAsync)。ack 让客户端加载流程走到「等落点」那一步, // 随后推的 SyncTeleport 才会被采纳;反序会把 SyncTeleport 吃在流程还没就位的时候。 await conn.ReturnEmptyOkAsync(msg); await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption { teleportId = teleportId, Position = new GameMethods.TeleportVec3(landing.X, landing.Y, landing.Z), Facing = landing.Facing, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = landing.EntranceId, }); if (vehicleId != 0) { await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938 { EntityId = 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, }); } // 交给 ReportPostTeleportFinish 的硬设兜底(客户端可能不采纳 SyncTeleport 的落点, // 见景点电梯四修)。新事务开始前会被覆盖。 conn.Session.Items[PendingGarageLandingKey] = new PendingGarageLanding(houseId, landing.X, landing.Y, landing.Z, landing.Facing, vehicleId); conn.Log.Info($"[GARAGE] AskTeleportFromHouseGarage house={houseId} space={spaceIndex} " + $"veh={vehicleConfigId} ⇒ SyncTeleport 落点=({landing.X:F1},{landing.Y:F1},{landing.Z:F1}) " + $"facing={landing.Facing:F0} 门={landing.EntranceId}({landing.Source}) 车={vehicleId} teleportId={teleportId}"); } /// 出库落点 + 该填进 SyncTeleport.MapEntranceId 的入口 id。 private readonly record struct GarageExitLanding( float X, float Y, float Z, float Facing, uint EntranceId, string Source); /// /// 出库落点推导,优先级:配置 exitLandingOverrides > /// HouseConfig.VisitorExit(绝对坐标)> 该房 MapentranceConfig 那一行 > /// 玩家当前上报位置(原地,宁可不动也不能黑屏)。 /// /// 为什么不能用客户端自己那份:出库落点客户端取的是 /// HouseConfig.GarageOutWaypoint(Spoon 航点 id,服务端没有 Spoon 位置表), /// 而且 ExitTimeline 只有 1 个元素的房(素光小筑)连 Spoon 反查都不做 ⇒ /// afterPosition 全零。 /// private static GarageExitLanding ResolveGarageExitLanding(uint houseId, ServerLogger log) { uint entranceId = 0; if (HouseCatalog4229938.TryGet(houseId, out var house) && house is not null) { entranceId = house.MapEntrance; foreach (var o in GarageCfg.ExitLandingOverrides) { if (o.HouseId == houseId) { log.Info($"[GARAGE] 出库落点=配置覆盖 house={houseId} ({o.X:F1},{o.Y:F1},{o.Z:F1}) facing={o.Facing:F0}"); return new GarageExitLanding(o.X, o.Y, o.Z, o.Facing, entranceId, "override"); } } if (house.VisitorExit.IsUsable) { log.Info($"[GARAGE] 出库落点=HouseConfig.VisitorExit house={houseId} " + $"({house.VisitorExit.X:F1},{house.VisitorExit.Y:F1},{house.VisitorExit.Z:F1}) " + $"facing={house.VisitorExit.Facing:F0} 门={entranceId}"); return new GarageExitLanding(house.VisitorExit.X, house.VisitorExit.Y, house.VisitorExit.Z, house.VisitorExit.Facing, entranceId, "VisitorExit"); } } if (entranceId != 0) { foreach (var e in SceneCatalog4229938.MapEntrances) { if (e.Id != entranceId) continue; log.Info($"[GARAGE] 出库落点=MapentranceConfig house={houseId} 门={entranceId} " + $"({e.X:F1},{e.Y:F1},{e.Z:F1}) facing={e.Facing:F0}"); return new GarageExitLanding(e.X, e.Y, e.Z, e.Facing, entranceId, "MapentranceConfig"); } } log.Warn($"[GARAGE] 出库落点解析失败 house={houseId}(VisitorExit 全零且门 {entranceId} 不在表里)⇒ 退化成原地"); return new GarageExitLanding(0f, 0f, 0f, 0f, entranceId, "none"); } // ========================================================================================= // helpers // ========================================================================================= /// /// 确保「本房这些车位」上各有一辆活着的车,并返回可直接推给客户端的 /// SyncHouseParkingEntityBinding 列表。 /// /// 复用顺序:① 同一辆车换车位(PreviousSpace >= 0)时把运行时里旧车位的实体搬过来, /// 不重新生成;② 目标车位已有活着的实体直接复用;③ 都没有才 SpawnDirectAsync。 /// /// 两条链路都用它:AskCreateHouseParkingVehicles(进库演出结束后客户端主动要) /// 和 AskHouseMoveParkingSpace(换车 / 调取载具 —— 那条链路上客户端因为 /// IsSwitchingCar 不会自己发前者,所以服务端必须自己生成)。 /// private async Task> EnsureParkingVehiclesAsync( Connection conn, uint houseId, IReadOnlyList<(int Space, uint VehicleId, int PreviousSpace)> wanted) { var bindings = new List(); if (wanted.Count == 0) return bindings; var state = GetStateStatic(conn.Session); var anchor = GarageSpawnPoint(state, GarageCfg.SpawnForwardOffset); var facing = state.LastReportedPlayerRotation.Y; var runtime = GetGarageRuntime(conn.Session); foreach (var (space, vehicleId, previousSpace) in wanted) { if (vehicleId == 0) continue; ulong entity = 0; lock (runtime.SyncRoot) { if (!runtime.EntitiesByHouse.TryGetValue(houseId, out var map)) { map = new Dictionary(); runtime.EntitiesByHouse[houseId] = map; } // ① 同一辆车换车位:旧车位的实体搬过来。 if (previousSpace >= 0 && previousSpace != space && map.TryGetValue(previousSpace, out var moved) && moved != 0) { map.Remove(previousSpace); entity = moved; } // ② 目标车位已有活着的车(客户端会多次调这条,必须幂等)。 else if (map.TryGetValue(space, out var existing) && existing != 0 && IsVehicleAlive(conn.Session, existing)) { entity = existing; } else { // ②b 这辆车玩家**正开着**(开出车库后又开回来入库)。 // 出库时刻意没销毁它(见 OnAskDestroyHouseParkingVehicles),实体还在 // 世界里活着 ⇒ 直接复用,否则同一个 vehicleConfigId 会有两份实体。 var drivenEntity = FindDrivenVehicleEntity(conn.Session, vehicleId); if (drivenEntity != 0) { entity = drivenEntity; conn.Log.Info($"[GARAGE] 复用玩家正开的车 entity={drivenEntity} veh={vehicleId} " + $"house={houseId} space={space}(不重复生成)"); } } if (entity != 0) map[space] = entity; } if (entity == 0) { // ⛑ await 不能在 lock 体内(CS1996)—— 上面已经出锁了。 var (ok, message, spawned, _) = await SpawnDirectAsync( conn.Session, vehicleId, anchor, facing, $"garage house={houseId} space={space}", anchor, createSourceType: 10, destroyPrevious: false); if (!ok) { conn.Log.Warn($"[GARAGE] 生成车失败 house={houseId} space={space} veh={vehicleId}: {message}"); continue; } entity = spawned; lock (runtime.SyncRoot) runtime.EntitiesByHouse[houseId][space] = entity; } bindings.Add(new SceneMethods.HouseParkingEntityBinding { VehicleId = vehicleId, EntityId = entity, ParkingSpaceIndex = space, }); } return bindings; } private static bool TryReadGarageArgs(Connection conn, UxRpcMessage msg, out T args) { try { args = UxSerializer.Deserialize(msg.Body); return args is not null; } catch (Exception ex) { conn.Log.Warn($"[GARAGE] {typeof(T).Name} 解析失败 ({ex.GetType().Name}: {ex.Message}) " + $"bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); args = default!; return false; } } /// 把 放进该房第一个空车位,返回车位号(0 基);无空位返回 -1。 /// 该车已在本房某个车位时返回原位置(幂等)。调用方必须持有 world.SyncRoot。 private static int AssignFreeSpace(WorldEntryState world, uint houseId, uint vehicleId) { var count = 2u; if (HouseCatalog4229938.TryGet(houseId, out var house) && house.ParkingSpaceCount > 0) count = house.ParkingSpaceCount; if (!world.Progress.GarageParking.TryGetValue(houseId, out var spaces)) { spaces = new Dictionary(); world.Progress.GarageParking[houseId] = spaces; } foreach (var (index, existing) in spaces) { if (existing == vehicleId) return index; } for (var i = 0; i < count; i++) { if (!spaces.ContainsKey(i)) { spaces[i] = vehicleId; return i; } } return -1; } /// 按车辆 id 取消车位指派(跨所有房),落盘并回推 cancelInfoList。 private static List CancelVehicles(Connection conn, List vehicleIds) { var cancel = new List(); var world = GetStateIfExists(conn.Session); if (world is null || vehicleIds.Count == 0) return cancel; var wanted = vehicleIds.Where(id => id != 0).ToHashSet(); lock (world.SyncRoot) { foreach (var (houseId, spaces) in world.Progress.GarageParking) { foreach (var key in spaces.Where(kv => wanted.Contains(kv.Value)).Select(kv => kv.Key).ToList()) { var vid = spaces[key]; spaces.Remove(key); cancel.Add(new SceneMethods.HouseVehicleParkingInfo { HouseId = houseId, VehicleId = vid, ParkingSpaceIndex = key, }); } } } if (cancel.Count > 0) { PlayerProgressStore.Save(world, conn.Log, quiet: true); _ = PushSyncHouseParkingAsync(conn.Session, cancel, [], conn.Log); } return cancel; } private static async Task PushSyncHouseParkingAsync( TcpSession session, List cancel, List add, ServerLogger log) { var args = new SceneMethods.SyncHouseParkingArgs4229938 { cancelInfoList = cancel, addInfoList = add, }; var body = UxSerializer.Serialize(args); await session.NotifyAsync(MethodId.SyncHouseParking, body, CancellationToken.None); log.Info($"[GARAGE] -> SyncHouseParking(64277819) cancel={cancel.Count} add={add.Count} body={body.Length}B"); } private static async Task PushSyncHouseParkingEntityBindingAsync( TcpSession session, List binding, ServerLogger log) { var args = new SceneMethods.SyncHouseParkingEntityBindingArgs4229938 { bindingList = binding }; var body = UxSerializer.Serialize(args); await session.NotifyAsync(MethodId.IGameToClient_SyncHouseParkingEntityBinding, body, CancellationToken.None); log.Info($"[GARAGE] -> SyncHouseParkingEntityBinding(64060781) n={binding.Count} body={body.Length}B"); } /// 车辆落点:玩家当前位置(+ 配置的前向偏移)。 private static Vec3 GarageSpawnPoint(WorldEntryState state, float forwardOffset) { var p = state.LastReportedPlayerPosition; if (forwardOffset == 0f) return p; var rad = state.LastReportedPlayerRotation.Y * (MathF.PI / 180f); return new Vec3(p.X + MathF.Sin(rad) * forwardOffset, p.Y, p.Z + MathF.Cos(rad) * forwardOffset); } private static bool IsVehicleAlive(TcpSession session, ulong entityId) { var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) return runtime.Vehicles.ContainsKey(entityId); } /// /// 本玩家正开着的、VehicleRecord.ConfigId == vehicleConfigId 的那个实体;没有则 0。 /// /// 用途:玩家把车库里的车开出去、又开回来入库时复用同一个实体 /// (EnsureParkingVehiclesAsync 的步骤 ②b)。出库时刻意不销毁玩家正开的车, /// 所以它仍在世界里活着;不复用就会给同一辆车生成第二份实体。 /// /// 锁序:调用方持有 GarageRuntimeState.SyncRoot 时本方法会再取 /// VehicleRuntimeState.SyncRoot ⇒ 顺序恒为 Garage → Vehicle(与 /// OnAskDestroyHouseParkingVehicles 一致),不会反向嵌套。 /// private static ulong FindDrivenVehicleEntity(TcpSession session, uint vehicleConfigId) { if (vehicleConfigId == 0) return 0; var driven = ResolveDrivenVehicleIds(session); if (driven.Count == 0) return 0; var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) { foreach (var id in driven) { if (runtime.Vehicles.TryGetValue(id, out var rec) && rec.ConfigId == vehicleConfigId) return id; } } return 0; } }