using System.Buffers.Binary; using System.Collections.Concurrent; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; namespace Ananta.Server.Handlers.Game; /// /// Doors as gadgets — T2-11, ported from the donor's proven rounds 16C-16I. /// /// Doors are PLAIN GADGETS carrying a DoorModuleSyncInfo module ("USER MODEL ADOPTED: /// DOORS ARE GADGETS", HANDOFF-2026-09-18 round 16I-7). The client reports crossings with /// AskPlayerEnterOrLeaveDoor(63525373) = (ulong gadgetId, bool isEnter), dump.cs:1893406. /// /// ⛔ WIRE KIND: the client sends this as a NOTIFY, not an invoke — packets log shows /// "C2S NOTIFY AskPlayerEnterOrLeaveDoor [0x03C951FD] body=9b". Registered Invoke-only it never /// matched and every frame fell to DefaultUnknownNotify4229938, so nothing was ever logged. /// Registration is Both and only invokes get a reply (a notify has no callback). /// /// There is NO dedicated door RPC: a census of all 2754 UXRPCMethodArgs classes in the client /// found zero door-proximity args classes (round 16C). The state travels inside /// GadgetEntityInfo.DoorModuleInfo and arrives through SyncGadgetRefresh(68765074), which the /// client applies in OnSyncDoorInfo. The chain is BaseDoorProxy.TriggerEnter -> /// DoorMgr.SyncDoorProximityState -> DoorModuleSyncInfo{LockState, ProximityState, /// ProximityStartTime, ClientInfos}. /// /// ⚠ Two door classes, do not confuse them (donor round 16I-10): /// · venue / high-layer doors whose approach makes the client's own bundled /// sector_high_indoor_<id>_0 binary fail to deserialize — no server fix exists; /// · ordinary gadget doors, which this file drives. The gym (Hypercore) and Sonic Boom doors /// were in the SECOND class and opened live in round 16H. /// internal sealed partial class GameRouter { private const string DoorTimersKey = "client4229938-door-timers"; private const string DoorPlacementsKey = "client4229938-door-placements"; /// LockState values (DoorLockState enum, one wire byte). private const byte DoorLockStateUnlocked = 0; /// ProximityState values (DoorProximityState enum, one wire byte). private const byte DoorProximityNoPlayer = 0; private const byte DoorProximityHasPlayer = 1; /// Leaf slot we drive. The client's per-panel list is indexed; 0 is the first leaf. private const uint DoorLeafIndex = 0; /// Leaf angle in degrees for a shut door (DoorClientInfo.OpenAngle, the only field). private const float DoorClosedAngle = 0f; /// AskPlayerEnterOrLeaveDoor(63525373) — u64 gadgetId + u8 isEnter. /// /// ⛔ NOTIFY on the wire, never invoke — see the class note for the evidence. [Handler(MethodId.AskPlayerEnterOrLeaveDoor, HandlerPacketKind.Both)] private async Task OnAskPlayerEnterOrLeaveDoor(Connection conn, UxRpcMessage msg) { var cfg = PrivateServerConfigStore.Current.World.Content.Doors; var body = msg.Body; if (body.Length < 9) { conn.Log.Warn($"[DOOR] enter/leave short body kind={(msg.IsNotify ? "ntf" : "rpc")} " + $"bytes={body.Length} hex={Convert.ToHexString(body)}"); if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); return; } var gadgetId = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0)); var isEnter = body[8] != 0; conn.Log.Info($"[DOOR] {(isEnter ? "ENTER" : "LEAVE")} gadget={gadgetId} " + $"kind={(msg.IsNotify ? "ntf" : "rpc")} bytes={body.Length} " + $"hex={Convert.ToHexString(body)}"); // 车库门:玩家在车库室内里跨过某扇门 ⇒ 记住它是「本会话的车库门」, // 供 AskTeleportFromHouseGarage(出库)时把门推开(见 GameRouter.Garage.cs)。 // 放在 cfg.Enabled 之前:即使门的自动开关被关掉,出库开门这条兜底也照样有门可用。 if (isEnter) CacheGarageDoor(conn.Session, gadgetId); if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); if (!cfg.Enabled) return; if (isEnter) { PushDoorOpen(conn.Session, gadgetId); ArmDoorAutoClose(conn.Session, gadgetId); return; } CancelDoorAutoClose(conn.Session, gadgetId); if (cfg.PushOnLeave) PushDoorShut(conn.Session, gadgetId); } // ---- the official channel ------------------------------------------------------------- // Three dedicated door S2Cs exist and the client really implements them (GameSceneToClient // overrides at RVA 0x127D4500 / 0x127D4690 / 0x127D4840 — a stub would be 0x9148360). The // donor's round 16C census missed them because bare-scalar methods own no args class. // Semantics are the client's own enums: DoorLockState{Unlocked=0, Locked=1}, // DoorProximityState{NoPlayer=0, HasPlayer=1}. /// Announce "somebody is at this door": unlock, HasPlayer, and swing the leaf. private static void PushDoorOpen(TcpSession session, ulong gadgetId) { var cfg = PrivateServerConfigStore.Current.World.Content.Doors; var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Push(session, MethodId.IGameSceneToClient_SyncDoorLockState, new SyncDoorLockStateArgs4229938 { gadgetId = gadgetId, state = DoorLockStateUnlocked }, $"lock=unlocked"); Push(session, MethodId.IGameSceneToClient_SyncDoorProximityState, new SyncDoorProximityStateArgs4229938 { gadgetId = gadgetId, state = DoorProximityHasPlayer, startTime = now, }, $"proximity=1"); Push(session, MethodId.IGameSceneToClient_SyncDoorClientInfo, new SyncDoorClientInfoArgs4229938 { gadgetId = gadgetId, index = DoorLeafIndex, clientInfo = new DoorClientInfo { OpenAngle = cfg.OpenAngle }, }, $"angle={cfg.OpenAngle}"); if (cfg.PushGadgetRefresh) PushDoorProximity(session, gadgetId, DoorProximityHasPlayer, cfg.OpenAngle); } /// Announce "nobody is here any more" — the leaf slides back to 0 degrees. private static void PushDoorShut(TcpSession session, ulong gadgetId) { var cfg = PrivateServerConfigStore.Current.World.Content.Doors; var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Push(session, MethodId.IGameSceneToClient_SyncDoorProximityState, new SyncDoorProximityStateArgs4229938 { gadgetId = gadgetId, state = DoorProximityNoPlayer, startTime = now, }, "proximity=0"); Push(session, MethodId.IGameSceneToClient_SyncDoorClientInfo, new SyncDoorClientInfoArgs4229938 { gadgetId = gadgetId, index = DoorLeafIndex, clientInfo = new DoorClientInfo { OpenAngle = DoorClosedAngle }, }, "angle=0"); if (cfg.PushGadgetRefresh) PushDoorProximity(session, gadgetId, DoorProximityNoPlayer, DoorClosedAngle); } private static void Push(TcpSession session, uint methodId, TArgs args, string what) { var bytes = Ananta.SDK.Serialization.UxSerializer.Serialize(args); var id = args switch { SyncDoorLockStateArgs4229938 l => l.gadgetId, SyncDoorProximityStateArgs4229938 p => p.gadgetId, SyncDoorClientInfoArgs4229938 c => c.gadgetId, _ => 0UL, }; session.Log.Info($"[DOOR] push {what} gadget={id} mid={methodId} bytes={bytes.Length}"); _ = session.NotifyAsync(methodId, bytes, CancellationToken.None); } /// /// Legacy path: restate the door through SyncGadgetRefresh(68765074) with DoorModuleSyncInfo. /// /// Body size is the acceptance proof: donor round 16H measured 8 presses -> 8x 130b refreshes /// -> ZERO `deserialize failed methodId=68765074`, and the gym / Sonic Boom doors opened. /// /// ⛑️ Round 16I-4/5 then proved the 130b shape is what made the leaf VANISH on approach: /// an empty ClientInfos means "no leaf record", so the panel hides the leaf when the module /// is applied. Carrying one OpenAngle takes the body to 135b (donor's byte-exact size) and /// gives the panel something to swing. Every other collection stays EMPTY rather than null, /// which is what makes the arithmetic land on those numbers. /// /// Placement is frozen per gadget on first push (see DoorPlacementsOf): Pack carries a real /// position the client applies to the entity, so re-sending a different/player-tracked /// position drags the door around (or to zero) instead of leaving it where it belongs. /// private static void PushDoorProximity(TcpSession session, ulong gadgetId, byte proximityState, float openAngle) { var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Frozen on first sight: whatever we send in Pack is applied to the entity, so a moving // value would drag the door to the player (and to zero once no report has arrived yet). var placement = DoorPlacementsOf(session).GetOrAdd(gadgetId, static (_, s) => { var snap = GetStateStatic(s); return new DoorPlacement( snap.LastReportedPlayerPosition.X, snap.LastReportedPlayerPosition.Y, snap.LastReportedPlayerPosition.Z, snap.LastReportedPlayerRotation.Y); }, session); var entity = new GadgetEntityInfo { InstanceId = gadgetId, // The client keeps its own door geometry; position/facing only have to land on this // side of nowhere so the refresh addresses the entity it already registered. Position = new Auto.UXVector3 { X = placement.X, Y = placement.Y, Z = placement.Z }, Facing = new Auto.UXVector3 { X = 0f, Y = placement.Facing, Z = 0f }, NavId = 0, ChildNavIds = [], ForceLod0 = false, IsTask = false, Pack = new PackedGadgetInfo { posX = placement.X, posY = placement.Y, posZ = placement.Z, eulerX = 0f, eulerY = placement.Facing, eulerZ = 0f, iScale = 100, uniqueId = gadgetId, pathId = 0, spoonSpecialList = [], delayDestroy = false, startTaskId = 0, endTaskId = 0, extractionItemContainerId = 0, isStatic = true, }, ReplaceInfo = null, CommonStateInfoDic = [], CommonValueInfoDic = [], PersonalValueInfoDic = [], StateCheckIndexDic = [], ValueCheckIndexDic = [], OccupantInfos = [], LinkOccupiedId = 0, SymbiosisGadgets = [], MobilePlatformInfo = null, AdherePlatformInfo = null, DoorModuleInfo = new DoorModuleSyncInfo { LockState = DoorLockStateUnlocked, ProximityState = proximityState, ProximityStartTime = now, ClientInfos = [new DoorClientInfo { OpenAngle = openAngle }], }, DrillShelfInfo = null, HangingInfo = null, }; var args = new SyncGadgetRefreshArgs4229938 { info = entity }; var bytes = Ananta.SDK.Serialization.UxSerializer.Serialize(args); session.Log.Info($"[DOOR] refresh gadget={gadgetId} proximity={proximityState} " + $"angle={openAngle} bytes={bytes.Length} " + $"pos=({placement.X:F1},{placement.Y:F1},{placement.Z:F1})"); _ = session.NotifyAsync(MethodId.IGameSceneToClient_SyncGadgetRefresh, bytes, CancellationToken.None); } /// /// Where this session last said a door stands. GadgetEntityInfo.Pack is authoritative on the /// client side, so once we have declared a placement we must keep declaring the same one: /// a drifting value makes the leaf jump to the player instead of sitting in its frame. /// private readonly record struct DoorPlacement(float X, float Y, float Z, float Facing); private static ConcurrentDictionary DoorPlacementsOf(TcpSession session) { if (session.Items.TryGetValue(DoorPlacementsKey, out var existing) && existing is ConcurrentDictionary typed) return typed; var created = new ConcurrentDictionary(); session.Items[DoorPlacementsKey] = created; return created; } // ---- auto-close ---------------------------------------------------------------------- // Donor round 16I-4/5: the client emits ENTER far more often than LEAVE (one session logged // 3 ENTER / 0 LEAVE), and it never restates the module by itself, so a door opened by the // server stayed open forever / vanished on leave. Cancel the pending timer on any new event // for the same door so only the last one survives. private static ConcurrentDictionary DoorTimersOf(TcpSession session) { if (session.Items.TryGetValue(DoorTimersKey, out var existing) && existing is ConcurrentDictionary typed) return typed; var created = new ConcurrentDictionary(); session.Items[DoorTimersKey] = created; return created; } private static void ArmDoorAutoClose(TcpSession session, ulong gadgetId) { var seconds = PrivateServerConfigStore.Current.World.Content.Doors.AutoCloseSeconds; if (seconds <= 0) return; var timers = DoorTimersOf(session); if (timers.TryRemove(gadgetId, out var previous)) { previous.Cancel(); previous.Dispose(); } var cts = new CancellationTokenSource(); timers[gadgetId] = cts; _ = Task.Run(async () => { try { await Task.Delay(seconds * 1000, cts.Token); if (cts.IsCancellationRequested) return; timers.TryRemove(gadgetId, out _); PushDoorShut(session, gadgetId); session.Log.Info($"[DOOR] auto-close push gadget={gadgetId}"); } catch (OperationCanceledException) { // superseded by a newer event for the same door } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { // session went away } finally { cts.Dispose(); } }); } private static void CancelDoorAutoClose(TcpSession session, ulong gadgetId) { if (!DoorTimersOf(session).TryRemove(gadgetId, out var cts)) return; cts.Cancel(); cts.Dispose(); } // ---- 车库门(2026-09-30)-------------------------------------------------------------- // 车库大门是个普通 gadget 门(Gadgets_888_GarageDoor),它的实例 id 是**动态**的 —— // IndoorConfig 里没有 gadget 清单,配置查不到,只能运行时从客户端跨门上报 // (AskPlayerEnterOrLeaveDoor 的第一个 u64)里学。 // 玩家在车库室内(ClientIndoorConfigId ∈ 5 个车库 indoor)里跨门 ⇒ 记下这扇门, // 出库(AskTeleportFromHouseGarage)时用它把门推开。 private const string GarageDoorKey = "client4229938-garage-door"; /// /// 车库室内体积(IndoorConfig.Id)。⛔ 不要直接抄 HouseConfig.GarageIndoorId: /// 官方把绿屿公馆(11200003) 那一行写成了 301(= 绿屿公馆主体),真实车库是 302 /// (IndoorConfig:301 绿屿公馆 / 302 绿屿公馆车库),客户端那边已用 fastpatch /// (tools/garage_indoor_fix.lua)改回 302。这里两个都收,免得因室内划分差异漏掉。 /// 其余 4 条 = 各自车库:201 塔菲公寓车库 / 300 旧物仓公寓车库 / 299 观澜北岸车库 / 379 素光小筑车库。 /// private static readonly uint[] GarageIndoorIds = [201u, 301u, 302u, 300u, 299u, 379u]; private static void CacheGarageDoor(TcpSession session, ulong gadgetId) { if (gadgetId == 0) return; uint indoorId; try { indoorId = GetStateStatic(session).ClientIndoorConfigId; } catch { return; // 还没有世界状态(理论到不了:门只可能在世界里跨) } if (Array.IndexOf(GarageIndoorIds, indoorId) < 0) return; session.Items[GarageDoorKey] = gadgetId; session.Log.Info($"[DOOR] 记住车库门 gadget={gadgetId} indoor={indoorId}"); } /// 出库时把本会话记住的车库门推开。没有缓存(玩家没走过门)就只记一条日志。 private static void OpenCachedGarageDoor(Connection conn) { if (!conn.Session.Items.TryGetValue(GarageDoorKey, out var raw) || raw is not ulong gadgetId || gadgetId == 0) { conn.Log.Info("[GARAGE] 出库开门:本会话还没缓存到车库门 gadgetId(玩家没从门里走过?)"); return; } PushDoorOpen(conn.Session, gadgetId); conn.Log.Info($"[GARAGE] 出库开门 gadget={gadgetId}"); } }