210 lines
9.6 KiB
C#
210 lines
9.6 KiB
C#
using System.Buffers.Binary;
|
||
using System.Threading;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 移动命令的回执观测 —— <c>AskReportLogicAgentCommandSuccess2</c>(67057559)、
|
||
/// <c>AskReportLogicAgentCommandFail</c>(67193144)、<c>AskReportLogicAgentCommandData2</c>(67780469)、
|
||
/// <c>AskAgentPoiActionDone</c>(67016793)。
|
||
///
|
||
/// 移植自捐赠端 DremkaPS(2026-09-24)。他们这几条端点的价值写得很直白:
|
||
/// <c>SyncLogicAgentCommand</c> 对服务端是 fire-and-forget —— 客户端装了这条命令、还是拿到就丢了,
|
||
/// 在出站日志里长得<b>一模一样</b>。这几条是客户端的回答,有了它们,「行人还是不走」才能细分成
|
||
/// 「客户端收了 12 条一条没跑」还是「每一条进来就被拒了」—— 两种完全不同的故障,
|
||
/// 修法也完全相反。这正是服务端侧 stall 看门狗(规则 45)缺的那只眼睛。
|
||
///
|
||
/// ⛑⛑ 三条本服的取舍:
|
||
/// 1. <b>只观测,不改判定</b>。回执计数先落进 <c>AmbientPed</c>,但不参与
|
||
/// <c>StepFailCount</c> 的回收判据 —— 那套判据是实测调过的,先把数据看清楚再动。
|
||
/// 日志里同时打出 <c>stepFail=</c>,两者的相关性肉眼就能对上。
|
||
/// 2. <b>只认我们自己的人</b>。捐赠端提醒过:ambient crowd 和客户端自己的场景行人也走这几条
|
||
/// notify,所以这里按 agentId 在 <c>AmbientState.Peds</c> 里查,查不到就直接返回。
|
||
/// 3. <b>POI 不重放</b>。捐赠端的 <c>AskAgentPoiActionDone</c> 会把同一个 POI 再放回去,
|
||
/// 但本服根本不下发任何 POI action(Handlers / RpcTypes 里没有 POI 载荷),重放无从谈起;
|
||
/// 而他们自己也写了客户端「350 ms 内上报 31 条」,没有闸门的重放就是一场包风暴。
|
||
/// 这里只记观测日志。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// 规则 110:周期性/重复性事件不要「只打前 N 条」—— 那会让节奏类排查看到的全是假象。
|
||
// 一律「前 N 条 + 之后每 M 条」,并且日志带 count,让「连续性」本身肉眼可验。
|
||
private const int AckLogFirst = 10;
|
||
private const int AckLogEvery = 50;
|
||
|
||
/// <summary>
|
||
/// ⛑⛑⛑ agentId 在 body 里的起点 —— <b>两条不一样</b>,而且<b>跟捐赠端给的契约都不一样</b>。
|
||
///
|
||
/// 实测取自 <c>logs/unknown-methods.log</c>(2026-09-23 18:56,3.8 万条 Fail 全是 16 字节):
|
||
/// <code>
|
||
/// Success2 (67057559) bytes=14 FF 02 | 1A00BE5306862F73 | A8000000
|
||
/// ↑标记 ↑u64 agentId ↑int commandId
|
||
/// Fail (67193144) bytes=16 0600BE5306862F73 | C6000000 | 00000000
|
||
/// ↑u64 agentId ↑int commandId ↑尾部还有 4 字节
|
||
/// </code>
|
||
/// 校验:agentId 按 LE 读出来是 0x732F860653BE00xx ≈ 8.29e18,正好落在规则 23 的
|
||
/// 行人号段 8_300e15 上 —— 说明这两个偏移是对的,也能确认这些回执确实是我们自己的人发的。
|
||
///
|
||
/// 捐赠端把这两条当成同一个 12 字节布局(u64 + int,无标记),在 4229938 下<b>是错的</b>:
|
||
/// Success2 少算了 2 字节前缀,Fail 少算了 4 字节尾巴。这正是规则 22 说的
|
||
/// 「手写契约以实际 wire 为准,别信抄来的表」—— 所以这里干脆不碰契约,直接按偏移读。
|
||
/// </summary>
|
||
private const int SuccessAckAgentOffset = 2;
|
||
private const int FailAckAgentOffset = 0;
|
||
|
||
private static int s_ackReports;
|
||
private static int s_ackUndecodable;
|
||
private static int s_poiDone;
|
||
|
||
[Handler(MethodId.AskReportLogicAgentCommandSuccess2, HandlerPacketKind.Notify)]
|
||
private Task AgentCommandSuccess(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// Success2 的 agentId 从第 2 字节起(前面 FF 02 是 nullable 标记 + dispatch tag)。
|
||
if (!TryReadCommandAck(msg.Body, SuccessAckAgentOffset, out var agentId, out var commandId))
|
||
{
|
||
NoteUndecodable(conn, "Success2", msg.Body.Length);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
var ped = FindAmbientPed(conn.Session, agentId);
|
||
if (ped is null)
|
||
return Task.CompletedTask;
|
||
|
||
int acks, fails, count;
|
||
lock (AmbientState(conn.Session).SyncRoot)
|
||
{
|
||
ped.CommandAcks++;
|
||
ped.LastAckCommandId = commandId;
|
||
acks = ped.CommandAcks;
|
||
fails = ped.CommandFails;
|
||
}
|
||
count = acks + fails;
|
||
|
||
LogAck(conn, "ack", agentId, commandId, acks, fails, count, ped);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
[Handler(MethodId.AskReportLogicAgentCommandFail, HandlerPacketKind.Notify)]
|
||
private Task AgentCommandFail(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// Fail 的 body 没有前置标记,agentId 从第 0 字节起。
|
||
if (!TryReadCommandAck(msg.Body, FailAckAgentOffset, out var agentId, out var commandId))
|
||
{
|
||
NoteUndecodable(conn, "Fail", msg.Body.Length);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
var ped = FindAmbientPed(conn.Session, agentId);
|
||
if (ped is null)
|
||
return Task.CompletedTask;
|
||
|
||
int acks, fails, count;
|
||
lock (AmbientState(conn.Session).SyncRoot)
|
||
{
|
||
ped.CommandFails++;
|
||
ped.LastAckCommandId = commandId;
|
||
acks = ped.CommandAcks;
|
||
fails = ped.CommandFails;
|
||
}
|
||
count = acks + fails;
|
||
|
||
LogAck(conn, "fail", agentId, commandId, acks, fails, count, ped);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>AskReportLogicAgentCommandData2</c> 是 per-agent 的状态 / 层报告。只记条数与关键字段:
|
||
/// 它回答的是「客户端到底有没有在跑这台状态机」,不需要逐条解析 <c>Data</c>。
|
||
/// </summary>
|
||
[Handler(MethodId.AskReportLogicAgentCommandData2, HandlerPacketKind.Notify)]
|
||
private Task AgentCommandReport(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var n = Interlocked.Increment(ref s_ackReports);
|
||
if (n > AckLogFirst && n % AckLogEvery != 0)
|
||
return Task.CompletedTask;
|
||
|
||
var args = msg.TryGetArgs<SceneMethods.LogicAgentCommandReportData>(out var decoded) ? decoded : null;
|
||
conn.Log.Info(args is null
|
||
? $"[CMD-ACK] report count={n} undecodable bytes={msg.Body.Length}"
|
||
: $"[CMD-ACK] report count={n} agent={args.AgentId} type={args.Type} "
|
||
+ $"layer={args.Layer}/{args.StateLayer} data={(args.Data?.Count ?? 0)}B");
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>AskAgentPoiActionDone</c>:观测 only(理由见文件头第 3 条)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskAgentPoiActionDone, HandlerPacketKind.Notify)]
|
||
private Task AgentPoiActionDone(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var n = Interlocked.Increment(ref s_poiDone);
|
||
if (n > AckLogFirst && n % AckLogEvery != 0)
|
||
return Task.CompletedTask;
|
||
|
||
var args = msg.TryGetArgs<SceneMethods.AskAgentPoiActionDone>(out var decoded) ? decoded : null;
|
||
conn.Log.Info(args is null
|
||
? $"[CMD-ACK] poi-done count={n} undecodable bytes={msg.Body.Length}"
|
||
: $"[CMD-ACK] poi-done count={n} agent={args.agentId} poi={args.poiActionId} (no replay: server sends no POI)");
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ helpers
|
||
|
||
/// <summary>
|
||
/// 按实测偏移读回执里的 (agentId, commandId)。agentId 固定 8 字节 LE,commandId 紧跟其后 4 字节 LE。
|
||
/// 长度不够就返回 false —— 宁可记一条 undecodable,也不要读半个字段当真值。
|
||
/// </summary>
|
||
private static bool TryReadCommandAck(byte[] body, int agentOffset, out ulong agentId, out int commandId)
|
||
{
|
||
agentId = 0;
|
||
commandId = 0;
|
||
if (body is null || body.Length < agentOffset + 12)
|
||
return false;
|
||
|
||
agentId = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(agentOffset));
|
||
commandId = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(agentOffset + 8));
|
||
return true;
|
||
}
|
||
|
||
private static AmbientPed? FindAmbientPed(TcpSession session, ulong agentId)
|
||
{
|
||
if (agentId == 0)
|
||
return null;
|
||
|
||
var state = AmbientState(session);
|
||
lock (state.SyncRoot)
|
||
{
|
||
foreach (var ped in state.Peds)
|
||
{
|
||
if (ped.Id == agentId)
|
||
return ped;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static void LogAck(Connection conn, string kind, ulong agentId, int commandId,
|
||
int acks, int fails, int count, AmbientPed ped)
|
||
{
|
||
if (count > AckLogFirst && count % AckLogEvery != 0)
|
||
return;
|
||
|
||
// stepFail 一并打出:服务端自己算的卡死计数与客户端回执的相关性,一眼就能对上。
|
||
conn.Log.Info($"[CMD-ACK] {kind} agent={agentId} cmd={commandId} acks={acks} fails={fails} "
|
||
+ $"count={count} stepFail={ped.StepFailCount} walking={ped.Walking}");
|
||
}
|
||
|
||
private static void NoteUndecodable(Connection conn, string kind, int bytes)
|
||
{
|
||
var n = Interlocked.Increment(ref s_ackUndecodable);
|
||
if (n > AckLogFirst && n % AckLogEvery != 0)
|
||
return;
|
||
|
||
conn.Log.Warn($"[CMD-ACK] {kind} body did not decode ({bytes}B) count={n}");
|
||
}
|
||
}
|