feat(net): Phase F.1 GameEvent (0xF7B0) envelope dispatcher

Implements the inbound GameEvent routing layer — the single biggest
network-protocol gap per r08 (94 sub-opcodes, zero handled before).
WorldSession now detects 0xF7B0, parses the 16-byte header (guid +
gameEventSequence + eventType), and forwards to a pluggable
GameEventDispatcher.

Added:
- GameEventEnvelope record + TryParse with layout from
  ACE GameEventMessage.cs.
- GameEventType enum: all 94 S→C sub-opcodes from
  ACE.Server.Network.GameEvent.GameEventType, named per ACE conventions.
- GameEventDispatcher: handler registry + unhandled-counts bag for
  diagnostics ("which server events are firing that we don't parse?").
  Handlers invoked synchronously on the decode thread; thrown exceptions
  are swallowed + logged to stderr so one bad handler can't take down
  the packet loop.
- GameEvents parsers: ChannelBroadcast, Tell, TransientMessage,
  PopupString, WeenieError (+ WithString), UpdateHealth, PingResponse,
  MagicUpdateSpell. Each returns a typed record or null on malformed
  payload. String16L helper matches the existing CharacterList pattern
  (u16 length + ASCII bytes + 4-byte pad).
- WorldSession.GameEvents property exposing the dispatcher so
  GameWindow / UI / chat can register handlers at startup.

Wired into WorldSession.ProcessDatagram: new `else if (op ==
GameEventEnvelope.Opcode)` branch with TryParse + Dispatch.

Tests (13 new):
- Envelope: valid round-trip, wrong outer opcode, too-short body.
- Dispatcher: handler invoked, unhandled count, exception isolation,
  unregister + rollover to unhandled.
- Event parsers: ChannelBroadcast, Tell, UpdateHealth, WeenieError,
  Transient, MagicUpdateSpell.

Total: 521 tests pass (up from 508).

With this dispatcher in place, Phase F.2 (items + appraise), F.3 (combat
+ damage), F.4 (spell cast state machine), chat UI, allegiance, quest
tracker — all of which depend on GameEvent handling — are unblocked.

Ref: r08 §4 (GameEvent sub-opcode table), §2 (envelope wire shape).
Ref: ACE GameEventMessage.cs / GameEventType.cs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 16:52:46 +02:00
parent d3165f99d7
commit d86fd08011
6 changed files with 656 additions and 0 deletions

View file

@ -0,0 +1,163 @@
using System;
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parser + record types for the most-used <see cref="GameEventType"/>
/// sub-opcodes inside the <c>0xF7B0</c> envelope. Each parser takes the
/// <see cref="GameEventEnvelope.Payload"/> slice (header stripped) and
/// returns a typed record or null on malformed payload.
///
/// <para>
/// References: r08 protocol atlas §4 (wire specs) + ACE
/// <c>GameEventChat.cs</c>, <c>GameEventTell.cs</c>,
/// <c>GameEventUpdateHealth.cs</c>, <c>GameEventWeenieError.cs</c>,
/// <c>GameEventCommunicationTransientString.cs</c>.
/// </para>
/// </summary>
public static class GameEvents
{
// ── Chat / communication ─────────────────────────────────────────────────
/// <summary>0x0147 ChannelBroadcast payload.</summary>
public readonly record struct ChannelBroadcast(
uint ChannelId,
string SenderName,
string Message);
public static ChannelBroadcast? ParseChannelBroadcast(ReadOnlySpan<byte> payload)
{
int pos = 0;
if (payload.Length < 4) return null;
uint channelId = BinaryPrimitives.ReadUInt32LittleEndian(payload);
pos += 4;
try
{
string sender = ReadString16L(payload, ref pos);
string message = ReadString16L(payload, ref pos);
return new ChannelBroadcast(channelId, sender, message);
}
catch { return null; }
}
/// <summary>0x02BD Tell payload.</summary>
public readonly record struct Tell(
string Message,
string SenderName,
uint SenderGuid,
uint TargetGuid,
uint ChatType);
public static Tell? ParseTell(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
string sender = ReadString16L(payload, ref pos);
if (payload.Length - pos < 12) return null;
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new Tell(message, sender, senderGuid, targetGuid, chatType);
}
catch { return null; }
}
/// <summary>0x02EB CommunicationTransientString payload.</summary>
public readonly record struct TransientMessage(string Message, uint ChatType);
public static TransientMessage? ParseTransient(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string message = ReadString16L(payload, ref pos);
if (payload.Length - pos < 4) return null;
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
return new TransientMessage(message, chatType);
}
catch { return null; }
}
/// <summary>0x0004 PopupString — modal dialog text.</summary>
public static string? ParsePopupString(ReadOnlySpan<byte> payload)
{
int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
// ── Errors ──────────────────────────────────────────────────────────────
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
public static uint? ParseWeenieError(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x028B WeenieErrorWithString.</summary>
public readonly record struct WeenieErrorWithString(uint ErrorCode, string Interpolation);
public static WeenieErrorWithString? ParseWeenieErrorWithString(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint code = BinaryPrimitives.ReadUInt32LittleEndian(payload);
int pos = 4;
try
{
string interp = ReadString16L(payload, ref pos);
return new WeenieErrorWithString(code, interp);
}
catch { return null; }
}
// ── Vitals / combat ─────────────────────────────────────────────────────
/// <summary>0x01C0 UpdateHealth: (guid, healthPercent 0..1).</summary>
public readonly record struct UpdateHealth(uint TargetGuid, float HealthPercent);
public static UpdateHealth? ParseUpdateHealth(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4));
return new UpdateHealth(guid, pct);
}
// ── Pings / misc ────────────────────────────────────────────────────────
/// <summary>0x01EA PingResponse: echoes the client ping id.</summary>
public static uint? ParsePingResponse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Spells / magic ──────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: spell id added to spellbook.</summary>
public static uint? ParseMagicUpdateSpell(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
pos += 2;
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
string result = Encoding.ASCII.GetString(source.Slice(pos, length));
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
}