334 lines
15 KiB
C#
334 lines
15 KiB
C#
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);
|
||
}
|
||
}
|