Files

412 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_&lt;id&gt;_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}");
}
}