using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Handlers; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Interactive props: Gadget placement and DynamicGo activation. /// /// Two different systems, both previously untouched by this server: /// /// Gadget — the game's placed interactive prop (chairs, benches, arcade cabinets, /// computers, drill shelves). The client owns the prefabs; the server only says *which prefab and /// where* via SyncGadgetAdd(68843189) and the client loads and spawns it. We have the full prefab /// list (2935 pathIds) from the world dump, so this is the one channel where the server can put a /// brand-new interactive object into the world without any scene data of its own. /// /// DynamicGo — scene-authored props that the client already has (doors, shutters, kiosks). /// Their ids live inside the client's scene resources, which is why the server has never been able /// to enumerate them. The client tells us what it activated through AskActiveDynamicGo(67339530), /// so the reachable half is: accept the report, remember it, and echo it back as /// SyncDynamicGoChange(68106491) so the state survives a relog instead of resetting. /// internal sealed partial class GameRouter { /// GadgetDisposeReason.Gm — "the server took it away", retail's own value for admin removals. private const int GadgetDisposeReasonGm = 0; /// /// Which S2C channel places the prop. SyncGadgetAdd(68843189) is the direct create call and was /// proven to reach the client; SyncGadgetAOIAddAndRemove(68169646) is the world-streaming call /// the client uses for props that come and go with the AOI. Both are offered because which one /// the client honours for a prop that no grid ever streamed is still unknown. /// internal enum GadgetSendChannel { Add = 0, Aoi = 1, Both = 2, /// /// SyncGadgetAddList(68925813) — the channel AnantaGymLoader.exe calls. Same payload as /// Add but batched in one push. Worth trying when Add builds the entity (name, position, /// interact point all correct) yet the prefab never appears: the two client handlers are /// different methods (0x12787ED0 vs 0x1276CF20) and may not share the resource path. /// AddList = 3, } /// AoiAddAndRemoveReason.AOIMove — "these props entered your streaming range". private const int AoiAddAndRemoveReasonAoiMove = 2; /// /// Client invoke: AskActiveDynamicGo(67339530) — "I activated/deactivated prop id N". /// /// The wire payload is 6 bytes and reads as int32 + bool + byte, which pins /// DynamicGoChangeReason to a single byte even though the dump types it as an int enum. The /// two sightings in unknown-methods.log are both reason 5 (Shop) on the same id with /// active 0 then 1 — a shop purchase toggling a prop, not a player pressing a switch. /// [Handler(MethodId.AskActiveDynamicGo, HandlerPacketKind.Invoke)] private async Task OnActiveDynamicGo(Connection conn, UxRpcMessage msg) { var state = GetWorldState(msg.Context); if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[DYNAMICGO] AskActiveDynamicGo undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body)}"); await conn.ReturnEmptyOkAsync(msg); return; } lock (state.SyncRoot) { state.DynamicGoActive[args.dyId] = args.active; } conn.Log.Info($"[DYNAMICGO] id={args.dyId} active={args.active} reason={args.reason}"); // The client is the one that flipped the prop locally; it only needs the echo so the // change is authoritative (and survives a relog) rather than a local guess. await conn.NotifyAsync( MethodId.SyncDynamicGoChange, new SceneMethods.SyncDynamicGoChangeArgs4229938 { dynamicGoId = args.dyId, active = args.active, reason = args.reason, }); await conn.ReturnEmptyOkAsync(msg); } /// One prop the caller wants placed. Used by the batch channel. internal readonly record struct GadgetSpawnSpec( int PathId, float X, float Y, float Z, float Yaw, ulong UniqueId, string Name); /// /// Builds the mandatory part of GadgetEntityInfo and books the instance in /// . Shared by the single and batch channels. /// internal static (SceneMethods.GadgetEntityInfo Info, ulong Id) BuildGadgetEntityInfo( TcpSession session, WorldEntryState state, int pathId, float x, float y, float z, float facing, ulong uniqueId = 0UL, int iScale = 100, IReadOnlyList<(int MarkId, string Value)>? spoonParams = null, bool houseFurniture = false) { var name = GadgetCatalog4229938.TryFind(pathId, out var entry) ? entry.Name : ""; if (string.IsNullOrEmpty(name)) session.Log.Warn($"[GADGET] pathId {pathId} is not in GadgetCatalog.json, sending anyway"); ulong id; lock (state.SyncRoot) { id = uniqueId != 0UL ? uniqueId : state.NextSpawnedGadgetId++; state.SpawnedGadgets[id] = new SpawnedGadgetRecord { InstanceId = id, PathId = pathId, Name = name, X = x, Y = y, Z = z, Yaw = facing, IsHouseFurniture = houseFurniture, }; } // Facing is a heading in degrees on our side; the wire carries it as the euler triple the // client's transform expects, with Y as the yaw axis. var info = new SceneMethods.GadgetEntityInfo { InstanceId = id, Position = new() { X = x, Y = y, Z = z }, Facing = new() { X = 0f, Y = facing, Z = 0f }, NavId = 0, ForceLod0 = true, IsTask = false, Pack = new SceneMethods.PackedGadgetInfo { posX = x, posY = y, posZ = z, eulerX = 0f, eulerY = facing, eulerZ = 0f, iScale = iScale, uniqueId = uniqueId != 0UL ? uniqueId : id, pathId = pathId, spoonSpecialList = BuildSpoonSpecialList(session, pathId, spoonParams), delayDestroy = false, isStatic = true, }, }; return (info, id); } /// /// Per-prefab spoon parameters (PackedGadgetInfo.spoonSpecialList). Most props ship it /// null — a frida dump of 161 live instances found only the SpoonSlot doors and /// MiniGameMachine_Bee carrying anything (markId 31511937 = "True", which is what arms the /// mini-game). Explicit params win; otherwise we look the prefab up in /// world.content.gadgetSpoonParams.byPathId. Null came a full stop before this existed, /// so nothing else can regress by adding it. /// internal static List? BuildSpoonSpecialList( TcpSession session, int pathId, IReadOnlyList<(int MarkId, string Value)>? explicitParams) { if (explicitParams is { Count: > 0 }) return explicitParams .Select(p => new SceneMethods.PackedGadgetSpecialParam { markId = p.MarkId, value = p.Value }) .ToList(); var cfg = PrivateServerConfigStore.Current.World.Content.GadgetSpoonParams; if (!cfg.Enabled) return null; if (!cfg.ByPathId.TryGetValue(pathId.ToString(), out var entries) || entries is null || entries.Count == 0) return null; var list = entries .Select(e => new SceneMethods.PackedGadgetSpecialParam { markId = e.MarkId, value = e.Value }) .ToList(); session.Log.Info($"[GADGET] spoon params pathId={pathId} count={list.Count} " + $"[{string.Join(",", list.Select(p => $"{p.markId}={p.value}"))}]"); return list; } /// /// Places a whole batch with SyncGadgetAddList(68925813) — one push, N entities. This is the /// channel AnantaGymLoader.exe drives by injection; it exists here so the server can try it /// without the loader (see ). /// internal static async Task<(bool Ok, string Message, int Count)> SpawnGadgetListAsync( TcpSession session, WorldEntryState state, IReadOnlyList specs, int iScale = 100, bool houseFurniture = false) { if (specs.Count == 0) return (false, "no specs", 0); var infos = new List(specs.Count); foreach (var spec in specs) { if (spec.PathId == 0) continue; var (info, _) = BuildGadgetEntityInfo( session, state, spec.PathId, spec.X, spec.Y, spec.Z, spec.Yaw, spec.UniqueId, iScale, spoonParams: null, houseFurniture: houseFurniture); infos.Add(info); } try { await session.NotifyAsync( MethodId.SyncGadgetAddList, UxSerializer.Serialize(new SceneMethods.SyncGadgetAddListArgs4229938 { infos = infos }), CancellationToken.None); } catch (Exception ex) { return (false, $"send failed: {ex.Message}", 0); } session.Log.Info($"[GADGET] addlist count={infos.Count} iScale={iScale} " + $"ids=[{string.Join(",", infos.Select(i => i.InstanceId).Take(6))}...]"); return (true, $"已批量放置 {infos.Count} 个物件", infos.Count); } /// /// Places one Gadget prefab into the world in front of the player and returns its instance id. /// /// Only the mandatory part of GadgetEntityInfo is filled: instance id, transform, and the /// packed placement (pathId + same transform + a fresh uniqueId). Every authored sub-module — /// doors, drill shelves, lifts, occupants — is left null, which the client reads as "this prop /// has none". ForceLod0 keeps a freshly spawned prop from being culled to a billboard. /// /// /// Non-zero = reuse that instance id instead of minting one. Gym equipment passes the real blob /// uid captured from the client, so the entity the server places is the same instance the client /// would have built itself (its prefab lookup and any blob-side state key off that uid). /// internal static async Task<(bool Ok, string Message, ulong Id)> SpawnGadgetAsync( TcpSession session, WorldEntryState state, int pathId, float x, float y, float z, float facing, GadgetSendChannel channel = GadgetSendChannel.Add, ulong uniqueId = 0UL, int iScale = 100) { if (pathId == 0) return (false, "pathId 0 is not a gadget", 0UL); var (info, id) = BuildGadgetEntityInfo(session, state, pathId, x, y, z, facing, uniqueId, iScale); try { if (channel is GadgetSendChannel.Add or GadgetSendChannel.Both) await session.NotifyAsync(MethodId.SyncGadgetAdd, UxSerializer.Serialize(info), CancellationToken.None); if (channel is GadgetSendChannel.Aoi or GadgetSendChannel.Both) await session.NotifyAsync( MethodId.SyncGadgetAOIAddAndRemove, UxSerializer.Serialize(new SceneMethods.SyncGadgetAOIAddAndRemoveArgs4229938 { addInfos = [info], reason = AoiAddAndRemoveReasonAoiMove, }), CancellationToken.None); } catch (Exception ex) { lock (state.SyncRoot) { state.SpawnedGadgets.Remove(id); } return (false, $"send failed: {ex.Message}", 0UL); } var label = GadgetCatalog4229938.TryFind(pathId, out var done) ? done.Name : ""; session.Log.Info($"[GADGET] add id={id} pathId={pathId} name={label} channel={channel} at ({x:F1},{y:F1},{z:F1}) facing={facing:F1}"); return (true, $"已放置 {label}(pathId {pathId},{channel})", id); } /// Removes every Gadget this panel placed, or just one when an id is given. /// /// 只撤某一种物件(按 pathId)。**这是撤离探针残留的正确姿势** —— 全清会把健身房器材 / /// PropRestock / 摊位补给一起抹掉,而它们各自管线有自己的 Sent 记录,不一定会立刻补回来。 /// (2026-10-03 随赛车移植:赛车门「过点即撤」要按 pathId 批量撤。) /// /// /// ★ 只撤这一批 id(比 更细)。 /// 赛车门一局 52 个门**同一个 pathId**,按 pathId 撤会把还没跑到的一起撤掉, /// 所以「过点即撤」必须用它。 /// internal static async Task<(bool Ok, string Message, int Count)> RemoveGadgetsAsync( TcpSession session, WorldEntryState state, ulong? onlyId = null, int pathIdFilter = 0, IReadOnlyList? onlyIds = null) { List ids; lock (state.SyncRoot) { if (onlyIds is { Count: > 0 }) ids = onlyIds.Where(id => state.SpawnedGadgets.ContainsKey(id)).ToList(); else ids = onlyId.HasValue ? (state.SpawnedGadgets.ContainsKey(onlyId.Value) ? [onlyId.Value] : []) : pathIdFilter != 0 ? state.SpawnedGadgets.Values.Where(r => r.PathId == pathIdFilter) .Select(r => r.InstanceId).ToList() : state.SpawnedGadgets.Keys.ToList(); } if (ids.Count == 0) return (true, "没有可移除的物件", 0); await session.NotifyAsync( MethodId.SyncGadgetRemoveList, UxSerializer.Serialize(new SceneMethods.SyncGadgetRemoveListArgs4229938 { ids = ids, reason = GadgetDisposeReasonGm, }), CancellationToken.None); lock (state.SyncRoot) { foreach (var id in ids) state.SpawnedGadgets.Remove(id); } session.Log.Info($"[GADGET] remove count={ids.Count} ids=[{string.Join(",", ids)}]"); return (true, $"已移除 {ids.Count} 个物件", ids.Count); } }