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}");
}
}