465 lines
20 KiB
C#
465 lines
20 KiB
C#
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);
|
|
}
|
|
|
|
// ── Combat notifications ────────────────────────────────────────────────
|
|
|
|
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
|
|
public readonly record struct VictimNotification(string DeathMessage);
|
|
|
|
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
|
|
{
|
|
int pos = 0;
|
|
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
|
|
public readonly record struct KillerNotification(string DeathMessage);
|
|
|
|
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
|
|
{
|
|
int pos = 0;
|
|
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
|
|
public readonly record struct AttackerNotification(
|
|
string DefenderName,
|
|
uint DamageType,
|
|
double HealthPercent,
|
|
uint Damage,
|
|
uint Critical,
|
|
ulong AttackConditions);
|
|
|
|
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
|
|
{
|
|
int pos = 0;
|
|
try
|
|
{
|
|
string name = ReadString16L(payload, ref pos);
|
|
if (payload.Length - pos < 28) return null;
|
|
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
|
|
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
|
|
return new AttackerNotification(name, damageType, pct, damage, crit, cond);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
|
|
public readonly record struct DefenderNotification(
|
|
string AttackerName,
|
|
uint DamageType,
|
|
double HealthPercent,
|
|
uint Damage,
|
|
uint HitQuadrant,
|
|
uint Critical,
|
|
ulong AttackConditions);
|
|
|
|
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
|
|
{
|
|
int pos = 0;
|
|
try
|
|
{
|
|
string name = ReadString16L(payload, ref pos);
|
|
if (payload.Length - pos < 32) return null;
|
|
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
|
|
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
|
|
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
|
|
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
|
|
{
|
|
int pos = 0;
|
|
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
|
}
|
|
|
|
/// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
|
|
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
|
|
{
|
|
int pos = 0;
|
|
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
|
}
|
|
|
|
/// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
|
|
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
|
|
|
|
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
|
|
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
|
|
|
|
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
|
|
}
|
|
|
|
// ── Spell enchantments ──────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
|
|
/// </summary>
|
|
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
|
|
|
|
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 8) return null;
|
|
return new MagicRemoveEnchantment(
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
|
}
|
|
|
|
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
|
|
public static uint? ParseMagicRemoveSpell(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 0x02C2 MagicUpdateEnchantment — the Enchantment blob. Full layout
|
|
/// (ACE <c>Enchantment.Pack</c>) is ~80+ bytes of spell metadata +
|
|
/// stat mods. We expose the first few fields that drive the enchant
|
|
/// bar UI; the rest is available via the raw payload view.
|
|
/// </summary>
|
|
public readonly record struct EnchantmentSummary(
|
|
uint SpellId,
|
|
uint LayerId,
|
|
float Duration,
|
|
uint CasterGuid);
|
|
|
|
public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan<byte> payload)
|
|
{
|
|
// Layout (ACE Enchantment.Pack):
|
|
// u32 spellId
|
|
// u32 layerId
|
|
// f32 duration
|
|
// u32 casterGuid
|
|
// ... (stat mods, category, power, etc.)
|
|
if (payload.Length < 16) return null;
|
|
return new EnchantmentSummary(
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
|
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 0x02C7 MagicDispelEnchantment — (layerId, spellId).
|
|
/// Structure matches MagicRemoveEnchantment.
|
|
/// </summary>
|
|
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
|
|
=> ParseMagicRemoveEnchantment(payload);
|
|
|
|
// ── Appraise / identify ─────────────────────────────────────────────────
|
|
|
|
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>
|
|
public readonly record struct IdentifyResponseHeader(
|
|
uint Guid,
|
|
uint AppraiseFlags,
|
|
bool Success);
|
|
|
|
/// <summary>
|
|
/// Parse the header of an <c>IdentifyObjectResponse (0x00C9)</c>.
|
|
/// Full property-bundle deserialization (int / bool / float / string
|
|
/// tables per the AppraiseFlags bitfield) is a future pass; this
|
|
/// header alone is enough for the UI to display "Appraise complete
|
|
/// on target X" and to route into the repository.
|
|
/// </summary>
|
|
public static IdentifyResponseHeader? ParseIdentifyResponseHeader(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 12) return null;
|
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
|
|
uint success = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8));
|
|
return new IdentifyResponseHeader(guid, flags, success != 0);
|
|
}
|
|
|
|
/// <summary>0x0023 WieldObject: server-driven equip.</summary>
|
|
public readonly record struct WieldObject(
|
|
uint ItemGuid,
|
|
uint EquipLoc,
|
|
uint WielderGuid);
|
|
|
|
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 12) return null;
|
|
return new WieldObject(
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
|
}
|
|
|
|
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.</summary>
|
|
public readonly record struct InventoryPutObjInContainer(
|
|
uint ItemGuid,
|
|
uint ContainerGuid,
|
|
uint Placement);
|
|
|
|
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 12) return null;
|
|
return new InventoryPutObjInContainer(
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
|
}
|
|
|
|
// ── Other small-payload events ──────────────────────────────────────────
|
|
|
|
/// <summary>0x01C7 UseDone: the Use/UseWithTarget completion signal (WeenieError code).</summary>
|
|
public static uint? ParseUseDone(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>0x019A InventoryPutObjectIn3D: server dropped item to ground.</summary>
|
|
public static uint? ParsePutObjectIn3D(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.</summary>
|
|
public static uint? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>0x0052 CloseGroundContainer: server closed a ground container view.</summary>
|
|
public static uint? ParseCloseGroundContainer(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>0x0207 TradeFailure: server trade error code.</summary>
|
|
public static uint? ParseTradeFailure(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>0x0200 AddToTrade: (itemGuid, slotIndex).</summary>
|
|
public readonly record struct AddToTrade(uint ItemGuid, uint SlotIndex);
|
|
|
|
public static AddToTrade? ParseAddToTrade(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 8) return null;
|
|
return new AddToTrade(
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
|
}
|
|
|
|
/// <summary>0x0202 AcceptTrade: initiator guid.</summary>
|
|
public static uint? ParseAcceptTrade(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 4) return null;
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
|
}
|
|
|
|
/// <summary>0x0264 QueryItemManaResponse: (itemGuid, manaPercent).</summary>
|
|
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent);
|
|
|
|
public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 8) return null;
|
|
return new QueryItemManaResponse(
|
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
|
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)));
|
|
}
|
|
|
|
/// <summary>0x0274 CharacterConfirmationRequest — server-driven modal confirm.</summary>
|
|
public readonly record struct CharacterConfirmationRequest(
|
|
uint Type,
|
|
uint ContextId,
|
|
uint OtherGuid,
|
|
string Message);
|
|
|
|
public static CharacterConfirmationRequest? ParseCharacterConfirmationRequest(ReadOnlySpan<byte> payload)
|
|
{
|
|
if (payload.Length < 12) return null;
|
|
int pos = 0;
|
|
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload); pos += 4;
|
|
uint contextId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
uint otherGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
|
try
|
|
{
|
|
string msg = ReadString16L(payload, ref pos);
|
|
return new CharacterConfirmationRequest(type, contextId, otherGuid, msg);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
// ── 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");
|
|
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
|
|
string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length));
|
|
pos += length;
|
|
int recordSize = 2 + length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
pos += padding;
|
|
return result;
|
|
}
|
|
}
|