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
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue