412 lines
18 KiB
C#
412 lines
18 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Doors as gadgets — T2-11, ported from the donor's proven rounds 16C-16I.
|
||
///
|
||
/// Doors are PLAIN GADGETS carrying a <c>DoorModuleSyncInfo</c> module ("USER MODEL ADOPTED:
|
||
/// DOORS ARE GADGETS", HANDOFF-2026-09-18 round 16I-7). The client reports crossings with
|
||
/// <c>AskPlayerEnterOrLeaveDoor(63525373)</c> = (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
|
||
/// <c>sector_high_indoor_<id>_0</c> 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.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string DoorTimersKey = "client4229938-door-timers";
|
||
private const string DoorPlacementsKey = "client4229938-door-placements";
|
||
|
||
/// <summary>LockState values (DoorLockState enum, one wire byte).</summary>
|
||
private const byte DoorLockStateUnlocked = 0;
|
||
|
||
/// <summary>ProximityState values (DoorProximityState enum, one wire byte).</summary>
|
||
private const byte DoorProximityNoPlayer = 0;
|
||
private const byte DoorProximityHasPlayer = 1;
|
||
|
||
/// <summary>Leaf slot we drive. The client's per-panel list is indexed; 0 is the first leaf.</summary>
|
||
private const uint DoorLeafIndex = 0;
|
||
|
||
/// <summary>Leaf angle in degrees for a shut door (DoorClientInfo.OpenAngle, the only field).</summary>
|
||
private const float DoorClosedAngle = 0f;
|
||
|
||
/// <summary>AskPlayerEnterOrLeaveDoor(63525373) — u64 gadgetId + u8 isEnter.</summary>
|
||
///
|
||
/// ⛔ 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}.
|
||
|
||
/// <summary>Announce "somebody is at this door": unlock, HasPlayer, and swing the leaf.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Announce "nobody is here any more" — the leaf slides back to 0 degrees.</summary>
|
||
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<TArgs>(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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private readonly record struct DoorPlacement(float X, float Y, float Z, float Facing);
|
||
|
||
private static ConcurrentDictionary<ulong, DoorPlacement> DoorPlacementsOf(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(DoorPlacementsKey, out var existing)
|
||
&& existing is ConcurrentDictionary<ulong, DoorPlacement> typed)
|
||
return typed;
|
||
|
||
var created = new ConcurrentDictionary<ulong, DoorPlacement>();
|
||
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<ulong, CancellationTokenSource> DoorTimersOf(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(DoorTimersKey, out var existing)
|
||
&& existing is ConcurrentDictionary<ulong, CancellationTokenSource> typed)
|
||
return typed;
|
||
|
||
var created = new ConcurrentDictionary<ulong, CancellationTokenSource>();
|
||
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";
|
||
|
||
/// <summary>
|
||
/// 车库室内体积(<c>IndoorConfig.Id</c>)。⛔ 不要直接抄 <c>HouseConfig.GarageIndoorId</c>:
|
||
/// 官方把绿屿公馆(11200003) 那一行写成了 <c>301</c>(= 绿屿公馆主体),真实车库是 <c>302</c>
|
||
/// (<c>IndoorConfig</c>:301 绿屿公馆 / 302 绿屿公馆车库),客户端那边已用 fastpatch
|
||
/// (<c>tools/garage_indoor_fix.lua</c>)改回 302。这里两个都收,免得因室内划分差异漏掉。
|
||
/// 其余 4 条 = 各自车库:201 塔菲公寓车库 / 300 旧物仓公寓车库 / 299 观澜北岸车库 / 379 素光小筑车库。
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
|
||
/// <summary>出库时把本会话记住的车库门推开。没有缓存(玩家没走过门)就只记一条日志。</summary>
|
||
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}");
|
||
}
|
||
}
|