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>
|
/// <summary>Raised every time the state machine transitions.</summary>
|
||||||
public event Action<State>? StateChanged;
|
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;
|
public State CurrentState { get; private set; } = State.Disconnected;
|
||||||
|
|
||||||
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
|
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
|
||||||
|
|
@ -479,6 +487,15 @@ public sealed class WorldSession : IDisposable
|
||||||
posUpdate.Value.Velocity));
|
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
|
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
|
||||||
{
|
{
|
||||||
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
|
// 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