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