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:
parent
d3165f99d7
commit
d86fd08011
6 changed files with 656 additions and 0 deletions
90
src/AcDream.Core.Net/Messages/GameEventDispatcher.cs
Normal file
90
src/AcDream.Core.Net/Messages/GameEventDispatcher.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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;
|
||||
}
|
||||
57
src/AcDream.Core.Net/Messages/GameEventEnvelope.cs
Normal file
57
src/AcDream.Core.Net/Messages/GameEventEnvelope.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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)
|
||||
/// <payload> // 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);
|
||||
}
|
||||
}
|
||||
119
src/AcDream.Core.Net/Messages/GameEventType.cs
Normal file
119
src/AcDream.Core.Net/Messages/GameEventType.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// All 94 <c>GameEventType</c> sub-opcodes delivered inside the
|
||||
/// <c>0xF7B0</c> envelope (S→C). Source of truth:
|
||||
/// <c>references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs</c>
|
||||
/// + r08 protocol atlas §4.
|
||||
///
|
||||
/// <para>
|
||||
/// Only values used by the retail client are enumerated; values the
|
||||
/// server can technically emit but retail ignores are omitted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum GameEventType : uint
|
||||
{
|
||||
AllegianceUpdateAborted = 0x0003,
|
||||
PopupString = 0x0004,
|
||||
PlayerDescription = 0x0013,
|
||||
AllegianceUpdate = 0x0020,
|
||||
FriendsListUpdate = 0x0021,
|
||||
InventoryPutObjInContainer = 0x0022,
|
||||
WieldObject = 0x0023,
|
||||
CharacterTitle = 0x0029,
|
||||
UpdateTitle = 0x002B,
|
||||
CloseGroundContainer = 0x0052,
|
||||
ApproachVendor = 0x0062,
|
||||
StartBarber = 0x0075,
|
||||
InventoryServerSaveFailed = 0x00A0,
|
||||
FellowshipQuit = 0x00A3,
|
||||
FellowshipDismiss = 0x00A4,
|
||||
BookDataResponse = 0x00B4,
|
||||
BookModifyPageResponse = 0x00B5,
|
||||
BookAddPageResponse = 0x00B6,
|
||||
BookDeletePageResponse = 0x00B7,
|
||||
BookPageDataResponse = 0x00B8,
|
||||
GetInscriptionResponse = 0x00C3,
|
||||
IdentifyObjectResponse = 0x00C9,
|
||||
ChannelBroadcast = 0x0147,
|
||||
ChannelList = 0x0148,
|
||||
ChannelIndex = 0x0149,
|
||||
ViewContents = 0x0196,
|
||||
InventoryPutObjectIn3D = 0x019A,
|
||||
AttackDone = 0x01A7,
|
||||
MagicRemoveSpell = 0x01A8,
|
||||
VictimNotification = 0x01AC,
|
||||
KillerNotification = 0x01AD,
|
||||
AttackerNotification = 0x01B1,
|
||||
DefenderNotification = 0x01B2,
|
||||
EvasionAttackerNotification = 0x01B3,
|
||||
EvasionDefenderNotification = 0x01B4,
|
||||
CombatCommenceAttack = 0x01B8,
|
||||
UpdateHealth = 0x01C0,
|
||||
QueryAgeResponse = 0x01C3,
|
||||
UseDone = 0x01C7,
|
||||
AllegianceUpdateDone = 0x01C8,
|
||||
FellowshipFellowUpdateDone = 0x01C9,
|
||||
FellowshipFellowStatsDone = 0x01CA,
|
||||
ItemAppraiseDone = 0x01CB,
|
||||
Emote = 0x01E2,
|
||||
PingResponse = 0x01EA,
|
||||
SetSquelchDB = 0x01F4,
|
||||
RegisterTrade = 0x01FD,
|
||||
OpenTrade = 0x01FE,
|
||||
CloseTrade = 0x01FF,
|
||||
AddToTrade = 0x0200,
|
||||
RemoveFromTrade = 0x0201,
|
||||
AcceptTrade = 0x0202,
|
||||
DeclineTrade = 0x0203,
|
||||
ResetTrade = 0x0205,
|
||||
TradeFailure = 0x0207,
|
||||
ClearTradeAcceptance = 0x0208,
|
||||
HouseProfile = 0x021D,
|
||||
HouseData = 0x0225,
|
||||
HouseStatus = 0x0226,
|
||||
UpdateRentTime = 0x0227,
|
||||
UpdateRentPayment = 0x0228,
|
||||
HouseUpdateRestrictions = 0x0248,
|
||||
UpdateHAR = 0x0257,
|
||||
HouseTransaction = 0x0259,
|
||||
QueryItemManaResponse = 0x0264,
|
||||
AvailableHouses = 0x0271,
|
||||
CharacterConfirmationRequest = 0x0274,
|
||||
CharacterConfirmationDone = 0x0276,
|
||||
AllegianceLoginNotification = 0x027A,
|
||||
AllegianceInfoResponse = 0x027C,
|
||||
JoinGameResponse = 0x0281,
|
||||
StartGame = 0x0282,
|
||||
MoveResponse = 0x0283,
|
||||
OpponentTurn = 0x0284,
|
||||
OpponentStalemate = 0x0285,
|
||||
WeenieError = 0x028A,
|
||||
WeenieErrorWithString = 0x028B,
|
||||
GameOver = 0x028C,
|
||||
SetTurbineChatChannels = 0x0295,
|
||||
AdminQueryPluginList = 0x02AE,
|
||||
AdminQueryPlugin = 0x02B1,
|
||||
AdminQueryPluginResponse = 0x02B3,
|
||||
SalvageOperationsResult = 0x02B4,
|
||||
Tell = 0x02BD,
|
||||
FellowshipFullUpdate = 0x02BE,
|
||||
FellowshipDisband = 0x02BF,
|
||||
FellowshipUpdateFellow = 0x02C0,
|
||||
MagicUpdateSpell = 0x02C1,
|
||||
MagicUpdateEnchantment = 0x02C2,
|
||||
MagicRemoveEnchantment = 0x02C3,
|
||||
MagicUpdateMultipleEnchantments = 0x02C4,
|
||||
MagicRemoveMultipleEnchantments = 0x02C5,
|
||||
MagicPurgeEnchantments = 0x02C6,
|
||||
MagicDispelEnchantment = 0x02C7,
|
||||
MagicDispelMultipleEnchantments = 0x02C8,
|
||||
PortalStormBrewing = 0x02C9,
|
||||
PortalStormImminent = 0x02CA,
|
||||
PortalStorm = 0x02CB,
|
||||
PortalStormSubsided = 0x02CC,
|
||||
CommunicationTransientString = 0x02EB,
|
||||
MagicPurgeBadEnchantments = 0x0312,
|
||||
SendClientContractTrackerTable = 0x0314,
|
||||
SendClientContractTracker = 0x0315,
|
||||
}
|
||||
163
src/AcDream.Core.Net/Messages/GameEvents.cs
Normal file
163
src/AcDream.Core.Net/Messages/GameEvents.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +115,14 @@ public sealed class WorldSession : IDisposable
|
|||
/// <summary>Raised every time the state machine transitions.</summary>
|
||||
public event Action<State>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
|
||||
/// handler is registered here (by GameWindow / UI layer / chat
|
||||
/// system) and routed on each incoming GameEvent. Unhandled
|
||||
/// sub-opcodes are counted for diagnostic overlays.
|
||||
/// </summary>
|
||||
public GameEventDispatcher GameEvents { get; } = new();
|
||||
|
||||
public State CurrentState { get; private set; } = State.Disconnected;
|
||||
|
||||
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
|
||||
|
|
@ -479,6 +487,15 @@ public sealed class WorldSession : IDisposable
|
|||
posUpdate.Value.Velocity));
|
||||
}
|
||||
}
|
||||
else if (op == GameEventEnvelope.Opcode)
|
||||
{
|
||||
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
|
||||
// header (guid + sequence + eventType) and dispatch to the
|
||||
// registered handler for that sub-opcode. Unregistered
|
||||
// types get counted for diagnostic overlays.
|
||||
var env = GameEventEnvelope.TryParse(body);
|
||||
if (env is not null) GameEvents.Dispatch(env.Value);
|
||||
}
|
||||
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
|
||||
{
|
||||
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class GameEventDispatcherTests
|
||||
{
|
||||
// Helper: build a 0xF7B0 envelope with given sub-opcode + payload bytes.
|
||||
private static byte[] MakeEnvelope(GameEventType type, ReadOnlySpan<byte> payload,
|
||||
uint playerGuid = 0x12345678u, uint sequence = 0)
|
||||
{
|
||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||
var span = body.AsSpan();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), (uint)type);
|
||||
payload.CopyTo(span.Slice(GameEventEnvelope.HeaderSize));
|
||||
return body;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ValidEnvelope_ReturnsFields()
|
||||
{
|
||||
byte[] body = MakeEnvelope(GameEventType.PlayerDescription,
|
||||
payload: new byte[8],
|
||||
playerGuid: 0xAABBCCDD, sequence: 42);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(body);
|
||||
Assert.NotNull(env);
|
||||
Assert.Equal(0xAABBCCDDu, env!.Value.PlayerGuid);
|
||||
Assert.Equal(42u, env.Value.Sequence);
|
||||
Assert.Equal(GameEventType.PlayerDescription, env.Value.EventType);
|
||||
Assert.Equal(8, env.Value.Payload.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_WrongOuterOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
||||
Assert.Null(GameEventEnvelope.TryParse(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TooShort_ReturnsNull()
|
||||
{
|
||||
Assert.Null(GameEventEnvelope.TryParse(new byte[10]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatch_CallsHandler_ForRegisteredType()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
GameEventEnvelope received = default;
|
||||
int calls = 0;
|
||||
dispatcher.Register(GameEventType.Tell, env => { received = env; calls++; });
|
||||
|
||||
var body = MakeEnvelope(GameEventType.Tell, new byte[4]);
|
||||
var env = GameEventEnvelope.TryParse(body);
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.Equal(GameEventType.Tell, received.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatch_Unhandled_CountedButNoThrow()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var body = MakeEnvelope(GameEventType.HouseProfile, Array.Empty<byte>());
|
||||
var env = GameEventEnvelope.TryParse(body);
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
dispatcher.Dispatch(env.Value);
|
||||
|
||||
Assert.Equal(2, dispatcher.GetUnhandledCount(GameEventType.HouseProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatch_HandlerThrows_DoesNotPropagate()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
dispatcher.Register(GameEventType.Tell, _ => throw new InvalidOperationException("bad handler"));
|
||||
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
|
||||
|
||||
// Should not throw; error is logged instead.
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unregister_RemovesHandler_AndMovesToUnhandled()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
int calls = 0;
|
||||
dispatcher.Register(GameEventType.Tell, _ => calls++);
|
||||
dispatcher.Unregister(GameEventType.Tell);
|
||||
|
||||
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
|
||||
|
||||
Assert.Equal(0, calls);
|
||||
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
|
||||
}
|
||||
|
||||
// ── Per-event parser tests ───────────────────────────────────────────────
|
||||
|
||||
private static byte[] MakeString16L(string s)
|
||||
{
|
||||
byte[] data = Encoding.ASCII.GetBytes(s);
|
||||
int recordSize = 2 + data.Length;
|
||||
int padding = (4 - (recordSize & 3)) & 3;
|
||||
byte[] result = new byte[recordSize + padding];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
|
||||
Array.Copy(data, 0, result, 2, data.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChannelBroadcast_RoundTrip()
|
||||
{
|
||||
// u32 channelId + string16L sender + string16L message
|
||||
byte[] chan = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(chan, 42);
|
||||
byte[] sender = MakeString16L("Hero");
|
||||
byte[] msg = MakeString16L("hello allegiance");
|
||||
|
||||
byte[] payload = new byte[chan.Length + sender.Length + msg.Length];
|
||||
Buffer.BlockCopy(chan, 0, payload, 0, chan.Length);
|
||||
Buffer.BlockCopy(sender, 0, payload, chan.Length, sender.Length);
|
||||
Buffer.BlockCopy(msg, 0, payload, chan.Length + sender.Length, msg.Length);
|
||||
|
||||
var parsed = GameEvents.ParseChannelBroadcast(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(42u, parsed!.Value.ChannelId);
|
||||
Assert.Equal("Hero", parsed.Value.SenderName);
|
||||
Assert.Equal("hello allegiance", parsed.Value.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTell_RoundTrip()
|
||||
{
|
||||
byte[] msg = MakeString16L("hi");
|
||||
byte[] sender = MakeString16L("Alice");
|
||||
byte[] tail = new byte[12]; // u32 senderGuid + u32 targetGuid + u32 chatType
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 0xBBu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 1u);
|
||||
|
||||
byte[] payload = new byte[msg.Length + sender.Length + tail.Length];
|
||||
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
|
||||
Buffer.BlockCopy(sender, 0, payload, msg.Length, sender.Length);
|
||||
Buffer.BlockCopy(tail, 0, payload, msg.Length + sender.Length, tail.Length);
|
||||
|
||||
var parsed = GameEvents.ParseTell(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal("hi", parsed!.Value.Message);
|
||||
Assert.Equal("Alice", parsed.Value.SenderName);
|
||||
Assert.Equal(0xAAu, parsed.Value.SenderGuid);
|
||||
Assert.Equal(0xBBu, parsed.Value.TargetGuid);
|
||||
Assert.Equal(1u, parsed.Value.ChatType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseUpdateHealth_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xC0DEu);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
|
||||
|
||||
var parsed = GameEvents.ParseUpdateHealth(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0xC0DEu, parsed!.Value.TargetGuid);
|
||||
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWeenieError_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
|
||||
Assert.Equal(42u, GameEvents.ParseWeenieError(payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTransient_RoundTrip()
|
||||
{
|
||||
byte[] msg = MakeString16L("Your spell fizzled!");
|
||||
byte[] chatType = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(chatType, 5u);
|
||||
byte[] payload = new byte[msg.Length + 4];
|
||||
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
|
||||
Buffer.BlockCopy(chatType, 0, payload, msg.Length, 4);
|
||||
|
||||
var parsed = GameEvents.ParseTransient(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal("Your spell fizzled!", parsed!.Value.Message);
|
||||
Assert.Equal(5u, parsed.Value.ChatType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMagicUpdateSpell_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
|
||||
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue