acdream/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs
Erik d86fd08011 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>
2026-04-18 16:52:46 +02:00

57 lines
2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Parsed header of a retail AC <c>0xF7B0</c> GameEvent envelope (S→C).
///
/// <para>
/// Wire layout
/// (references/ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs):
/// <code>
/// u32 0xF7B0 // GameMessage opcode (GameEvent envelope)
/// u32 guid // session's player guid (0 if none yet)
/// u32 gameEventSequence // per-session, incremented by server
/// u32 eventType // GameEventType sub-opcode (94 values)
/// &lt;payload&gt; // variable; type-dispatched
/// </code>
/// </para>
///
/// <para>
/// Payload is returned as a <see cref="ReadOnlyMemory{T}"/> view into the
/// original message body — no copy. Per-event parsers slice from this.
/// </para>
/// </summary>
public readonly record struct GameEventEnvelope(
uint PlayerGuid,
uint Sequence,
GameEventType EventType,
ReadOnlyMemory<byte> Payload)
{
/// <summary>GameMessage opcode of the outer envelope.</summary>
public const uint Opcode = 0xF7B0u;
/// <summary>Header size (4×u32 = 16 bytes before payload).</summary>
public const int HeaderSize = 16;
/// <summary>
/// Parse a raw 0xF7B0 GameMessage body into the header + payload view.
/// Returns null if the body is shorter than the header or the outer
/// opcode doesn't match.
/// </summary>
public static GameEventEnvelope? TryParse(byte[] body)
{
if (body is null || body.Length < HeaderSize) return null;
uint outer = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (outer != Opcode) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
uint sequence = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
uint eventType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
var payload = body.AsMemory(HeaderSize);
return new GameEventEnvelope(guid, sequence, (GameEventType)eventType, payload);
}
}