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>
90 lines
3.3 KiB
C#
90 lines
3.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Central router for inbound <c>0xF7B0</c> GameEvent envelopes.
|
|
///
|
|
/// <para>
|
|
/// Each handled <see cref="GameEventType"/> gets a registered delegate.
|
|
/// Unhandled types are counted for diagnostics so it's easy to see which
|
|
/// events the server is actually emitting vs which ones we theoretically
|
|
/// support — a useful number when iterating on chat / inventory / combat.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Handlers are invoked synchronously on the thread that called
|
|
/// <see cref="Dispatch"/> — normally the render thread since
|
|
/// <see cref="WorldSession"/>'s decode path runs there in the current
|
|
/// architecture. Handlers must not block.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class GameEventDispatcher
|
|
{
|
|
public delegate void EventHandler(GameEventEnvelope envelope);
|
|
|
|
private readonly Dictionary<GameEventType, EventHandler> _handlers = new();
|
|
private readonly Dictionary<GameEventType, int> _unhandledCounts = new();
|
|
|
|
/// <summary>
|
|
/// Register a handler for a GameEvent sub-opcode. Replaces any
|
|
/// existing handler for that opcode.
|
|
/// </summary>
|
|
public void Register(GameEventType type, EventHandler handler)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(handler);
|
|
_handlers[type] = handler;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove the registered handler for a sub-opcode.
|
|
/// </summary>
|
|
public void Unregister(GameEventType type) => _handlers.Remove(type);
|
|
|
|
/// <summary>
|
|
/// Route an envelope to its handler, or log as unhandled. Exceptions
|
|
/// inside handlers are swallowed to keep the decode loop alive — a
|
|
/// malformed event from the server should not crash the client.
|
|
/// </summary>
|
|
public void Dispatch(GameEventEnvelope envelope)
|
|
{
|
|
if (_handlers.TryGetValue(envelope.EventType, out var handler))
|
|
{
|
|
try
|
|
{
|
|
handler(envelope);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The decode thread must survive handler failures. Log via
|
|
// Console so it surfaces in live-play logs without needing
|
|
// a Serilog sink here.
|
|
Console.Error.WriteLine(
|
|
$"[GameEvent] handler for 0x{(uint)envelope.EventType:X4} threw: {ex.Message}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_unhandledCounts.TryGetValue(envelope.EventType, out int n);
|
|
_unhandledCounts[envelope.EventType] = n + 1;
|
|
}
|
|
}
|
|
|
|
/// <summary>Number of events of the given type we've seen with no handler.</summary>
|
|
public int GetUnhandledCount(GameEventType type) =>
|
|
_unhandledCounts.TryGetValue(type, out var n) ? n : 0;
|
|
|
|
/// <summary>
|
|
/// Snapshot of every event type we've seen without a handler, keyed
|
|
/// by type → count. Useful for "which server events are firing that
|
|
/// we don't parse?" diagnostic overlays.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<GameEventType, int> UnhandledCounts => _unhandledCounts;
|
|
|
|
/// <summary>Reset the unhandled-counts bag (e.g. after a log-off).</summary>
|
|
public void ResetUnhandledCounts() => _unhandledCounts.Clear();
|
|
|
|
/// <summary>How many distinct sub-opcodes have a handler registered.</summary>
|
|
public int RegisteredHandlerCount => _handlers.Count;
|
|
}
|