fix(battle-node): hand events are unencrypted JSON arrays, not encrypted dicts
The prior 'hand'-ack fix worked in test but failed in prod because both
the handler and the test used the wrong wire shape. Re-tracing the
client emit path:
RealTimeNetworkAgent.cs:783-786 (msg path):
return MessagePackSerializer.Serialize(
CryptAES.encryptForNode(JsonMapper.ToJson(info))); // ← encrypted
RealTimeNetworkAgent.cs:815-817 (hand path):
return MessagePackSerializer.Serialize(
JsonMapper.ToJson(info)); // ← NOT encrypted
And EmitFrontStockData:717-723 picks "hand" as the SIO event name only
when frontData["StockHandData"] exists; in that branch it passes the
StockHandData list (NOT the dict) to CreatePackEmitHandData. So the
wire body is:
msgpack_string(JsonMapper.ToJson(List<object>))
i.e. a JSON array, unencrypted. EmitMsgUriPack:1456-1458 puts pubSeq at
index 3 of that array (after uri_int / viewerId / udid). The dict's
top-level pubSeq stays client-local for stockEmitMessageMgr.GetSelectData.
Handler now:
- Skips NodeCrypto.DecryptForNode (was throwing FormatException on the
unencrypted bytes — caught and swallowed silently by the existing
outer try/catch, so the bug presented as 'no warning, no ack')
- Parses RootElement.ValueKind:
- Array → arr[3] is the pubSeq
- Object → top-level "pubSeq" (defensive; not used by prod today)
- Falls back to ack arg=0 if neither extraction works (the client's
GetSelectData lookup misses but its OnAck path still fires — same as a
normal cache-miss — so the queue still drains)
Diagnostic [hand-rx] log added (gated by DiagnosticLogging) so we can
see the actual body content per-frame during verification.
Test was also wrong (encrypted dict shape); rewritten to use the real
wire shape (unencrypted JSON array). +1 net new test covering the
dict-shape defensive path.
176 battle-node tests passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -274,14 +274,22 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
|
||||
/// <summary>
|
||||
/// Ack <c>hand</c> events from the client so the client's <c>stockEmitMessageMgr</c>
|
||||
/// drains and subsequent emits transmit. Wire shape differs from <c>msg</c>: the body
|
||||
/// is <c>{"StockHandData":[uri_int, viewerId, udid, ...params, pubSeq], "try":0, "pubSeq":N}</c>
|
||||
/// (no top-level <c>uri</c>), so we can't reuse <see cref="HandleMsgEventAsync"/> — its
|
||||
/// <see cref="MsgEnvelope.FromJson"/> path requires a top-level <c>uri</c>.
|
||||
/// drains and subsequent emits transmit.
|
||||
/// <para>
|
||||
/// Wire shape: hand events are <b>not encrypted</b> on the wire — the client's
|
||||
/// <c>RealTimeNetworkAgent.CreatePackEmitHandData:815-817</c> calls only
|
||||
/// <c>MessagePackSerializer.Serialize(JsonMapper.ToJson(list))</c>, skipping the
|
||||
/// <c>CryptAES.encryptForNode</c> wrap that <c>CreatePackEmitData</c> applies to <c>msg</c>
|
||||
/// events. The msgpack-wrapped string is a JSON array of the form
|
||||
/// <c>[uri_int, viewerId, udid, pubSeq, ...emit_params]</c> — see
|
||||
/// <c>EmitMsgUriPack:1456-1458</c> which inserts <c>pubSeq</c> at index 3 of the list
|
||||
/// for <c>isHandData</c> emits. The dict's top-level <c>pubSeq</c> stays client-local
|
||||
/// (used by its stockEmitMessageMgr.GetSelectData lookup); it's NOT on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In scripted/Bot mode the server has no opponent to forward touches to; ack-only is
|
||||
/// correct. PvP-side forwarding (so the other player's client can render opponent
|
||||
/// touch/cursor UI) is unverified — see <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
||||
/// correct. PvP-side forwarding semantics are unverified — see
|
||||
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fire-and-forget hand frames (TOUCH_URI / SELECT_OBJECT_URI / TURN_END_READY_URI) arrive
|
||||
@@ -299,20 +307,49 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
}
|
||||
try
|
||||
{
|
||||
var encryptedString = MessagePack.MessagePackSerializer.Deserialize<string>(frame.BinaryAttachments[0]);
|
||||
var json = NodeCrypto.DecryptForNode(encryptedString);
|
||||
// No NodeCrypto.DecryptForNode here — hand events are unencrypted on the wire.
|
||||
var json = MessagePack.MessagePackSerializer.Deserialize<string>(frame.BinaryAttachments[0]);
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"[hand-rx] viewer={Vid} ackId={AckId} bodyLen={Len} body={Body}",
|
||||
ViewerId, frame.AckId, json.Length,
|
||||
json.Length > 200 ? json.Substring(0, 200) + "..." : json);
|
||||
}
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("pubSeq", out var psEl)
|
||||
|| psEl.ValueKind != System.Text.Json.JsonValueKind.Number)
|
||||
long? pubSeq = null;
|
||||
var rootKind = doc.RootElement.ValueKind;
|
||||
if (rootKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
// Prod shape: [uri_int, viewerId, udid, pubSeq, ...emit_params].
|
||||
var arr = doc.RootElement;
|
||||
if (arr.GetArrayLength() > 3
|
||||
&& arr[3].ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
{
|
||||
pubSeq = arr[3].GetInt64();
|
||||
}
|
||||
}
|
||||
else if (rootKind == System.Text.Json.JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("pubSeq", out var psEl)
|
||||
&& psEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
{
|
||||
// Defensive: dict root with top-level pubSeq isn't what the client sends today,
|
||||
// but the StockHandData dict shape exists on the client side and a future
|
||||
// wire-format change could expose it. Cheap to handle.
|
||||
pubSeq = psEl.GetInt64();
|
||||
}
|
||||
|
||||
if (pubSeq is null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"RealParticipant viewer={Vid}: 'hand' event ackId={AckId} body missing numeric pubSeq; " +
|
||||
"acking with 0 as a fallback (client's stockEmitMessageMgr lookup will see null selectData).",
|
||||
ViewerId, frame.AckId);
|
||||
"RealParticipant viewer={Vid}: 'hand' event ackId={AckId} body has no extractable pubSeq " +
|
||||
"(rootKind={Kind}, bodyLen={Len}); acking with 0 as fallback.",
|
||||
ViewerId, frame.AckId, rootKind, json.Length);
|
||||
await SendSioAckAsync(frame.AckId.Value, 0);
|
||||
return;
|
||||
}
|
||||
await SendSioAckAsync(frame.AckId.Value, psEl.GetInt64());
|
||||
await SendSioAckAsync(frame.AckId.Value, pubSeq.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user