Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Gadget.cs
T

334 lines
15 KiB
C#
Raw 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 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;
/// <summary>
/// Interactive props: Gadget placement and DynamicGo activation.
///
/// Two different systems, both previously untouched by this server:
///
/// <b>Gadget</b> — 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.
///
/// <b>DynamicGo</b> — 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.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>GadgetDisposeReason.Gm — "the server took it away", retail's own value for admin removals.</summary>
private const int GadgetDisposeReasonGm = 0;
/// <summary>
/// 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.
/// </summary>
internal enum GadgetSendChannel
{
Add = 0,
Aoi = 1,
Both = 2,
/// <summary>
/// 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.
/// </summary>
AddList = 3,
}
/// <summary>AoiAddAndRemoveReason.AOIMove — "these props entered your streaming range".</summary>
private const int AoiAddAndRemoveReasonAoiMove = 2;
/// <summary>
/// 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.
/// </summary>
[Handler(MethodId.AskActiveDynamicGo, HandlerPacketKind.Invoke)]
private async Task OnActiveDynamicGo(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
if (!msg.TryGetArgs<SceneMethods.AskActiveDynamicGoArgs4229938>(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);
}
/// <summary>One prop the caller wants placed. Used by the batch channel.</summary>
internal readonly record struct GadgetSpawnSpec(
int PathId, float X, float Y, float Z, float Yaw, ulong UniqueId, string Name);
/// <summary>
/// Builds the mandatory part of GadgetEntityInfo and books the instance in
/// <see cref="WorldEntryState.SpawnedGadgets"/>. Shared by the single and batch channels.
/// </summary>
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);
}
/// <summary>
/// Per-prefab spoon parameters (<c>PackedGadgetInfo.spoonSpecialList</c>). 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
/// <c>world.content.gadgetSpoonParams.byPathId</c>. Null came a full stop before this existed,
/// so nothing else can regress by adding it.
/// </summary>
internal static List<SceneMethods.PackedGadgetSpecialParam>? 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;
}
/// <summary>
/// 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 <see cref="GadgetSendChannel.AddList"/>).
/// </summary>
internal static async Task<(bool Ok, string Message, int Count)> SpawnGadgetListAsync(
TcpSession session, WorldEntryState state, IReadOnlyList<GadgetSpawnSpec> specs, int iScale = 100,
bool houseFurniture = false)
{
if (specs.Count == 0)
return (false, "no specs", 0);
var infos = new List<SceneMethods.GadgetEntityInfo>(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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="uniqueId">
/// 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).
/// </param>
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);
}
/// <summary>Removes every Gadget this panel placed, or just one when an id is given.</summary>
/// <param name="pathIdFilter">
/// 只撤某一种物件(按 pathId)。**这是撤离探针残留的正确姿势** —— 全清会把健身房器材 /
/// PropRestock / 摊位补给一起抹掉,而它们各自管线有自己的 Sent 记录,不一定会立刻补回来。
/// (2026-10-03 随赛车移植:赛车门「过点即撤」要按 pathId 批量撤。)
/// </param>
/// <param name="onlyIds">
/// ★ 只撤这一批 id(比 <paramref name="pathIdFilter"/> 更细)。
/// 赛车门一局 52 个门**同一个 pathId**,按 pathId 撤会把还没跑到的一起撤掉,
/// 所以「过点即撤」必须用它。
/// </param>
internal static async Task<(bool Ok, string Message, int Count)> RemoveGadgetsAsync(
TcpSession session, WorldEntryState state, ulong? onlyId = null, int pathIdFilter = 0,
IReadOnlyList<ulong>? onlyIds = null)
{
List<ulong> 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);
}
}