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; /// /// 移动命令的回执观测 —— AskReportLogicAgentCommandSuccess2(67057559)、 /// AskReportLogicAgentCommandFail(67193144)、AskReportLogicAgentCommandData2(67780469)、 /// AskAgentPoiActionDone(67016793)。 /// /// 移植自捐赠端 DremkaPS(2026-09-24)。他们这几条端点的价值写得很直白: /// SyncLogicAgentCommand 对服务端是 fire-and-forget —— 客户端装了这条命令、还是拿到就丢了, /// 在出站日志里长得一模一样。这几条是客户端的回答,有了它们,「行人还是不走」才能细分成 /// 「客户端收了 12 条一条没跑」还是「每一条进来就被拒了」—— 两种完全不同的故障, /// 修法也完全相反。这正是服务端侧 stall 看门狗(规则 45)缺的那只眼睛。 /// /// ⛑⛑ 三条本服的取舍: /// 1. 只观测,不改判定。回执计数先落进 AmbientPed,但不参与 /// StepFailCount 的回收判据 —— 那套判据是实测调过的,先把数据看清楚再动。 /// 日志里同时打出 stepFail=,两者的相关性肉眼就能对上。 /// 2. 只认我们自己的人。捐赠端提醒过:ambient crowd 和客户端自己的场景行人也走这几条 /// notify,所以这里按 agentId 在 AmbientState.Peds 里查,查不到就直接返回。 /// 3. POI 不重放。捐赠端的 AskAgentPoiActionDone 会把同一个 POI 再放回去, /// 但本服根本不下发任何 POI action(Handlers / RpcTypes 里没有 POI 载荷),重放无从谈起; /// 而他们自己也写了客户端「350 ms 内上报 31 条」,没有闸门的重放就是一场包风暴。 /// 这里只记观测日志。 /// internal sealed partial class GameRouter { // 规则 110:周期性/重复性事件不要「只打前 N 条」—— 那会让节奏类排查看到的全是假象。 // 一律「前 N 条 + 之后每 M 条」,并且日志带 count,让「连续性」本身肉眼可验。 private const int AckLogFirst = 10; private const int AckLogEvery = 50; /// /// ⛑⛑⛑ agentId 在 body 里的起点 —— 两条不一样,而且跟捐赠端给的契约都不一样。 /// /// 实测取自 logs/unknown-methods.log(2026-09-23 18:56,3.8 万条 Fail 全是 16 字节): /// /// Success2 (67057559) bytes=14 FF 02 | 1A00BE5306862F73 | A8000000 /// ↑标记 ↑u64 agentId ↑int commandId /// Fail (67193144) bytes=16 0600BE5306862F73 | C6000000 | 00000000 /// ↑u64 agentId ↑int commandId ↑尾部还有 4 字节 /// /// 校验:agentId 按 LE 读出来是 0x732F860653BE00xx ≈ 8.29e18,正好落在规则 23 的 /// 行人号段 8_300e15 上 —— 说明这两个偏移是对的,也能确认这些回执确实是我们自己的人发的。 /// /// 捐赠端把这两条当成同一个 12 字节布局(u64 + int,无标记),在 4229938 下是错的: /// Success2 少算了 2 字节前缀,Fail 少算了 4 字节尾巴。这正是规则 22 说的 /// 「手写契约以实际 wire 为准,别信抄来的表」—— 所以这里干脆不碰契约,直接按偏移读。 /// 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; } /// /// AskReportLogicAgentCommandData2 是 per-agent 的状态 / 层报告。只记条数与关键字段: /// 它回答的是「客户端到底有没有在跑这台状态机」,不需要逐条解析 Data。 /// [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(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; } /// /// AskAgentPoiActionDone:观测 only(理由见文件头第 3 条)。 /// [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(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 /// /// 按实测偏移读回执里的 (agentId, commandId)。agentId 固定 8 字节 LE,commandId 紧跟其后 4 字节 LE。 /// 长度不够就返回 false —— 宁可记一条 undecodable,也不要读半个字段当真值。 /// 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}"); } }