feat(chat): port retail client command families

Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 14:50:15 +02:00
parent 5a45a7ac7f
commit 9ea579bdd0
47 changed files with 6659 additions and 99 deletions

View file

@ -5,6 +5,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.Core.Social;
namespace AcDream.Core.Net;
@ -70,7 +71,11 @@ public static class GameEventWiring
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null,
ItemManaState? itemMana = null)
ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -99,6 +104,42 @@ public static class GameEventWiring
var s = GameEvents.ParsePopupString(e.Payload.Span);
if (s is not null) chat.OnPopup(s);
});
dispatcher.Register(GameEventType.QueryAgeResponse, e =>
{
var p = GameEvents.ParseQueryAgeResponse(e.Payload.Span);
if (p is null) return;
string text = string.IsNullOrEmpty(p.Value.Name)
? $"You have played for {p.Value.Age}."
: $"{p.Value.Name} has played for {p.Value.Age}.";
chat.OnSystemMessage(text, chatType: 0u);
});
if (onConfirmationRequest is not null)
{
dispatcher.Register(GameEventType.CharacterConfirmationRequest, e =>
{
var request = GameEvents.ParseCharacterConfirmationRequest(e.Payload.Span);
if (request is not null)
onConfirmationRequest(request.Value);
});
}
if (friends is not null)
{
dispatcher.Register(GameEventType.FriendsListUpdate, e =>
{
FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate(e.Payload.Span);
if (update is not null) friends.Apply(update);
});
}
if (squelch is not null)
{
dispatcher.Register(GameEventType.SetSquelchDB, e =>
{
SquelchDatabase? database = SocialStateMessages.ParseSquelchDatabase(e.Payload.Span);
if (database is not null) squelch.Replace(database);
});
}
// ── TurbineChat channel list (0x0295 SetTurbineChatChannels) ─────
// Phase I.6: arrives once at login (and after chat-server reconnect)
@ -251,6 +292,7 @@ public static class GameEventWiring
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return;
uint wielderGuid = p.Value.WielderGuid != 0u
? p.Value.WielderGuid
: (playerGuid?.Invoke() ?? 0u);
@ -264,6 +306,7 @@ public static class GameEventWiring
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is null) return;
items.MoveItem(
p.Value.ItemGuid,
p.Value.ContainerGuid,
@ -385,6 +428,8 @@ public static class GameEventWiring
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
onDesiredComponents?.Invoke(p.Value.DesiredComps);
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
@ -412,6 +457,15 @@ public static class GameEventWiring
if (localPlayer is not null)
{
localPlayer.OnProperties(p.Value.Properties);
localPlayer.OnPositions(p.Value.Positions.ToDictionary(
static pair => pair.Key,
static pair => new AcDream.Core.Physics.Position(
pair.Value.LandblockId,
new System.Numerics.Vector3(
pair.Value.X, pair.Value.Y, pair.Value.Z),
new System.Numerics.Quaternion(
pair.Value.Qx, pair.Value.Qy,
pair.Value.Qz, pair.Value.Qw))));
foreach (var attr in p.Value.Attributes)
{

View file

@ -0,0 +1,235 @@
using System.Buffers.Binary;
using System.Text;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Wire builders for game actions initiated by retail client-owned chat
/// commands. Keeping this family separate from generic object interaction
/// makes the command boundary explicit while reusing the normal
/// <c>0xF7B1</c> game-action envelope.
/// </summary>
public static class ClientCommandRequests
{
public const uint MarketplaceOpcode = 0x028Du;
public const uint PkArenaOpcode = 0x0027u;
public const uint PkLiteArenaOpcode = 0x0026u;
public const uint HouseRecallOpcode = 0x0262u;
public const uint MansionRecallOpcode = 0x0278u;
public const uint QueryAgeOpcode = 0x01C2u;
public const uint QueryBirthOpcode = 0x01C4u;
public const uint ConfirmationResponseOpcode = 0x0275u;
public const uint SuicideOpcode = 0x0279u;
public const uint SetAfkModeOpcode = 0x000Fu;
public const uint SetAfkMessageOpcode = 0x0010u;
public const uint EmoteOpcode = 0x01DFu;
public const uint AddFriendOpcode = 0x0018u;
public const uint RemoveFriendOpcode = 0x0017u;
public const uint ClearFriendsOpcode = 0x0025u;
public const uint ModifyCharacterSquelchOpcode = 0x0058u;
public const uint ModifyAccountSquelchOpcode = 0x0059u;
public const uint ModifyGlobalSquelchOpcode = 0x005Bu;
public const uint ClearConsentOpcode = 0x0216u;
public const uint DisplayConsentOpcode = 0x0217u;
public const uint RemoveConsentOpcode = 0x0218u;
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
// CM_Character::Event_TeleToPKArena @ 0x006A1CB0
// CM_Character::Event_TeleToPKLArena @ 0x006A1D40
// CM_House::Event_TeleToHouse_Event @ 0x006AAFA0
// CM_House::Event_TeleToMansion_Event @ 0x006AB030
public static byte[] BuildMarketplace(uint sequence) =>
BuildParameterless(sequence, MarketplaceOpcode);
public static byte[] BuildPkArena(uint sequence) =>
BuildParameterless(sequence, PkArenaOpcode);
public static byte[] BuildPkLiteArena(uint sequence) =>
BuildParameterless(sequence, PkLiteArenaOpcode);
public static byte[] BuildHouseRecall(uint sequence) =>
BuildParameterless(sequence, HouseRecallOpcode);
public static byte[] BuildMansionRecall(uint sequence) =>
BuildParameterless(sequence, MansionRecallOpcode);
// Named-retail anchors:
// CM_Character::Event_QueryAge @ 0x006A1620
// CM_Character::Event_QueryBirth @ 0x006A16F0
public static byte[] BuildQueryAge(uint sequence, uint objectId = 0u) =>
BuildObjectQuery(sequence, QueryAgeOpcode, objectId);
public static byte[] BuildQueryBirth(uint sequence, uint objectId = 0u) =>
BuildObjectQuery(sequence, QueryBirthOpcode, objectId);
// CM_Character::Event_ConfirmationResponse @ 0x006A1210.
public static byte[] BuildConfirmationResponse(
uint sequence,
uint confirmationType,
uint contextId,
bool accepted)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), ConfirmationResponseOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), confirmationType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), contextId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), accepted ? 1u : 0u);
return body;
}
// CM_Character::Event_Suicide @ 0x006A1B00.
public static byte[] BuildSuicide(uint sequence) =>
BuildParameterless(sequence, SuicideOpcode);
// CM_Communication::Event_SetAFKMode @ 0x006A3EE0.
public static byte[] BuildSetAfkMode(uint sequence, bool away) =>
BuildUInt32(sequence, SetAfkModeOpcode, away ? 1u : 0u);
// CM_Communication::Event_SetAFKMessage @ 0x006A4440.
public static byte[] BuildSetAfkMessage(uint sequence, string message) =>
BuildString(sequence, SetAfkMessageOpcode, message);
// CM_Communication::Event_Emote @ 0x006A4120.
public static byte[] BuildEmote(uint sequence, string message) =>
BuildString(sequence, EmoteOpcode, message);
// CM_Social::Event_AddFriend/RemoveFriend/ClearFriends
// @ 0x006A5C10 / 0x006A5650 / 0x006A55C0.
public static byte[] BuildAddFriend(uint sequence, string name) =>
BuildString(sequence, AddFriendOpcode, name);
public static byte[] BuildRemoveFriend(uint sequence, uint friendId) =>
BuildUInt32(sequence, RemoveFriendOpcode, friendId);
public static byte[] BuildClearFriends(uint sequence) =>
BuildParameterless(sequence, ClearFriendsOpcode);
// Proto_UI::SendFriendsCommand @ 0x00546C50 is a control message,
// not a 0xF7B1 GameAction. Command zero requests the legacy listing.
public static byte[] BuildLegacyFriendsCommand(uint command, string name)
{
byte[] packedName = PackString16L(name);
byte[] body = new byte[8 + packedName.Length];
BinaryPrimitives.WriteUInt32LittleEndian(body, LegacyFriendsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), command);
packedName.CopyTo(body, 8);
return body;
}
// CM_Communication::Event_ModifyCharacterSquelch @ 0x006A42D0.
public static byte[] BuildModifyCharacterSquelch(
uint sequence,
bool add,
uint characterId,
string name,
uint messageType)
{
byte[] packedName = PackString16L(name);
byte[] body = CreateBody(sequence, ModifyCharacterSquelchOpcode, 12 + packedName.Length);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), characterId);
packedName.CopyTo(body, 20);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20 + packedName.Length), messageType);
return body;
}
// CM_Communication::Event_ModifyAccountSquelch @ 0x006A41E0.
public static byte[] BuildModifyAccountSquelch(uint sequence, bool add, string name)
{
byte[] packedName = PackString16L(name);
byte[] body = CreateBody(sequence, ModifyAccountSquelchOpcode, 4 + packedName.Length);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
packedName.CopyTo(body, 16);
return body;
}
// CM_Communication::Event_ModifyGlobalSquelch @ 0x006A3D00.
public static byte[] BuildModifyGlobalSquelch(uint sequence, bool add, uint messageType)
{
byte[] body = CreateBody(sequence, ModifyGlobalSquelchOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), add ? 1u : 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), messageType);
return body;
}
// CM_Character consent events @ 0x006A1180 / 0x006A1360 / 0x006A2C10.
public static byte[] BuildClearConsent(uint sequence) =>
BuildParameterless(sequence, ClearConsentOpcode);
public static byte[] BuildDisplayConsent(uint sequence) =>
BuildParameterless(sequence, DisplayConsentOpcode);
public static byte[] BuildRemoveConsent(uint sequence, string name) =>
BuildString(sequence, RemoveConsentOpcode, name);
// CM_Character::Event_SetDesiredComponentLevel @ 0x006A2350.
public static byte[] BuildSetDesiredComponentLevel(
uint sequence, uint componentId, uint amount)
{
byte[] body = CreateBody(sequence, SetDesiredComponentLevelOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), componentId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), amount);
return body;
}
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
return body;
}
private static byte[] BuildUInt32(uint sequence, uint opcode, uint value)
{
byte[] body = CreateBody(sequence, opcode, 4);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), value);
return body;
}
private static byte[] BuildString(uint sequence, uint opcode, string value)
{
byte[] packed = PackString16L(value);
byte[] body = CreateBody(sequence, opcode, packed.Length);
packed.CopyTo(body, 12);
return body;
}
private static byte[] CreateBody(uint sequence, uint opcode, int payloadLength)
{
byte[] body = new byte[12 + payloadLength];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
return body;
}
private static byte[] PackString16L(string value)
{
ArgumentNullException.ThrowIfNull(value);
byte[] data = Encoding.GetEncoding(1252).GetBytes(value);
if (data.Length > ushort.MaxValue)
throw new ArgumentException("String too long for String16L.", nameof(value));
int unpadded = 2 + data.Length;
byte[] packed = new byte[(unpadded + 3) & ~3];
BinaryPrimitives.WriteUInt16LittleEndian(packed, (ushort)data.Length);
data.CopyTo(packed, 2);
return packed;
}
private static byte[] BuildObjectQuery(uint sequence, uint opcode, uint objectId)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, InteractRequests.GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), objectId);
return body;
}
}

View file

@ -89,6 +89,25 @@ public static class GameEvents
try { return ReadString16L(payload, ref pos); } catch { return null; }
}
/// <summary>
/// 0x01C3 QueryAgeResponse: target name (empty for self), then the
/// server-formatted played duration. Retail source:
/// <c>CM_Character::DispatchUI_QueryAgeResponse @ 0x006A2E40</c>.
/// </summary>
public readonly record struct QueryAgeResponse(string Name, string Age);
public static QueryAgeResponse? ParseQueryAgeResponse(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
string name = ReadString16L(payload, ref pos);
string age = ReadString16L(payload, ref pos);
return new QueryAgeResponse(name, age);
}
catch { return null; }
}
// ── Errors ──────────────────────────────────────────────────────────────
/// <summary>0x028A WeenieError: generic game-logic failure code.</summary>
@ -470,20 +489,18 @@ public static class GameEvents
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;
if (payload.Length < 8) 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);
return new CharacterConfirmationRequest(type, contextId, msg);
}
catch { return null; }
}

View file

@ -0,0 +1,138 @@
using System.Buffers.Binary;
using AcDream.Core.Social;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail social-state parsers. Sources: <c>CM_Social::DispatchUI_FriendsUpdate
/// @ 0x006A5DD0</c>, <c>FriendData::UnPack @ 0x005B9D20</c>, and
/// <c>SquelchDB::UnPack @ 0x006B1900</c>.
/// </summary>
public static class SocialStateMessages
{
public static FriendsUpdate? ParseFriendsUpdate(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
uint count = ReadU32(payload, ref pos);
// PList<FriendData>::UnPack @ 0x0048CD30 stores a 32-bit count
// and bounds it only by the remaining packet. Keep a generous
// allocation guard without inventing the UI's 50-friend policy
// at the wire layer.
if (count > 65_536) return null;
var entries = new List<FriendEntry>((int)count);
for (uint i = 0; i < count; i++)
{
uint id = ReadU32(payload, ref pos);
bool online = ReadU32(payload, ref pos) != 0;
bool appearOffline = ReadU32(payload, ref pos) != 0;
string name = StringReader.ReadString16L(payload, ref pos);
IReadOnlyList<uint> friends = ReadUInt32List(payload, ref pos);
IReadOnlyList<uint> friendOf = ReadUInt32List(payload, ref pos);
entries.Add(new FriendEntry(id, name, online, appearOffline, friends, friendOf));
}
FriendsUpdateType type = (FriendsUpdateType)ReadU32(payload, ref pos);
return Enum.IsDefined(type) ? new FriendsUpdate(type, entries) : null;
}
catch (FormatException)
{
return null;
}
}
public static SquelchDatabase? ParseSquelchDatabase(ReadOnlySpan<byte> payload)
{
int pos = 0;
try
{
IReadOnlyDictionary<string, uint> accounts = ReadAccountTable(payload, ref pos);
IReadOnlyDictionary<uint, SquelchInfo> characters = ReadCharacterTable(payload, ref pos);
SquelchInfo global = ReadSquelchInfo(payload, ref pos);
return pos <= payload.Length
? new SquelchDatabase(accounts, characters, global)
: null;
}
catch (FormatException)
{
return null;
}
}
private static IReadOnlyDictionary<string, uint> ReadAccountTable(
ReadOnlySpan<byte> source, ref int pos)
{
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
// A zero-sized retail table is valid only when it is empty (see the
// early return in PackableHashTable::UnPack @ 0x006B1A86).
if (buckets == 0 && count != 0)
throw new FormatException("invalid account squelch table");
var result = new Dictionary<string, uint>(count, StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < count; i++)
{
string name = StringReader.ReadString16L(source, ref pos);
result[name] = ReadU32(source, ref pos);
}
return result;
}
private static IReadOnlyDictionary<uint, SquelchInfo> ReadCharacterTable(
ReadOnlySpan<byte> source, ref int pos)
{
(ushort count, ushort buckets) = ReadHashHeader(source, ref pos);
if (buckets == 0 && count != 0)
throw new FormatException("invalid character squelch table");
var result = new Dictionary<uint, SquelchInfo>(count);
for (int i = 0; i < count; i++)
{
uint id = ReadU32(source, ref pos);
result[id] = ReadSquelchInfo(source, ref pos);
}
return result;
}
private static SquelchInfo ReadSquelchInfo(ReadOnlySpan<byte> source, ref int pos)
{
uint wordCount = ReadU32(source, ref pos);
if (wordCount > 4) throw new FormatException("invalid vlong word count");
var messageTypes = new HashSet<uint>();
for (uint word = 0; word < wordCount; word++)
{
uint bits = ReadU32(source, ref pos);
for (uint bit = 0; bit < 32; bit++)
{
if ((bits & (1u << (int)bit)) != 0)
messageTypes.Add(word * 32 + bit);
}
}
string name = StringReader.ReadString16L(source, ref pos);
bool accountWide = ReadU32(source, ref pos) != 0;
return new SquelchInfo(name, accountWide, messageTypes);
}
private static (ushort Count, ushort Buckets) ReadHashHeader(
ReadOnlySpan<byte> source, ref int pos)
{
uint header = ReadU32(source, ref pos);
return ((ushort)(header & 0xFFFFu), (ushort)(header >> 16));
}
private static IReadOnlyList<uint> ReadUInt32List(ReadOnlySpan<byte> source, ref int pos)
{
uint count = ReadU32(source, ref pos);
if (count > 10_000) throw new FormatException("invalid packed list count");
var result = new uint[count];
for (int i = 0; i < result.Length; i++) result[i] = ReadU32(source, ref pos);
return result;
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos, 4));
pos += 4;
return value;
}
}

View file

@ -1272,6 +1272,153 @@ public sealed class WorldSession : IDisposable
SendGameAction(InteractRequests.BuildTeleToLifestone(seq));
}
/// <summary>Send retail marketplace recall (0x028D).</summary>
public void SendTeleportToMarketplace()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildMarketplace(seq));
}
/// <summary>Send retail full-PK arena recall (0x0027).</summary>
public void SendTeleportToPkArena()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildPkArena(seq));
}
/// <summary>Send retail PKLite arena recall (0x0026).</summary>
public void SendTeleportToPkLiteArena()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildPkLiteArena(seq));
}
/// <summary>Send retail personal-house recall (0x0262).</summary>
public void SendTeleportToHouse()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildHouseRecall(seq));
}
/// <summary>Send retail allegiance-mansion recall (0x0278).</summary>
public void SendTeleportToMansion()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildMansionRecall(seq));
}
/// <summary>Query the local character's played time (0x01C2).</summary>
public void SendQueryAge()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryAge(seq));
}
/// <summary>Query the local character's creation date (0x01C4).</summary>
public void SendQueryBirth()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildQueryBirth(seq));
}
/// <summary>Reply to a server confirmation request (0x0275).</summary>
public void SendConfirmationResponse(uint confirmationType, uint contextId, bool accepted)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildConfirmationResponse(
seq, confirmationType, contextId, accepted));
}
/// <summary>Send the confirmed retail suicide action (0x0279).</summary>
public void SendSuicide()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSuicide(seq));
}
public void SendSetAfkMode(bool away)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAfkMode(seq, away));
}
public void SendSetAfkMessage(string message)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetAfkMessage(seq, message));
}
public void SendEmote(string message)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildEmote(seq, message));
}
public void SendAddFriend(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddFriend(seq, name));
}
public void SendRemoveFriend(uint friendId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveFriend(seq, friendId));
}
public void SendClearFriends()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearFriends(seq));
}
public void SendLegacyFriendsListRequest() =>
SendControlMessage(ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty));
public void SendModifyCharacterSquelch(bool add, uint characterId, string name, uint messageType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyCharacterSquelch(
seq, add, characterId, name, messageType));
}
public void SendModifyAccountSquelch(bool add, string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyAccountSquelch(seq, add, name));
}
public void SendModifyGlobalSquelch(bool add, uint messageType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
}
public void SendClearConsent()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildClearConsent(seq));
}
public void SendDisplayConsent()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildDisplayConsent(seq));
}
public void SendRemoveConsent(string name)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveConsent(seq, name));
}
public void SendClearDesiredComponents()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId: 0u, amount: uint.MaxValue));
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{
@ -1517,10 +1664,20 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
private void SendGameMessage(byte[] gameMessageBody)
private void SendGameMessage(byte[] gameMessageBody) =>
SendGameMessage(gameMessageBody, GameMessageGroup.UIQueue);
/// <summary>
/// Retail <c>Proto_UI::SendToControl</c> path used by standalone control
/// messages such as the legacy <c>0xF7CD</c> friends request.
/// </summary>
private void SendControlMessage(byte[] gameMessageBody) =>
SendGameMessage(gameMessageBody, GameMessageGroup.ControlQueue);
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
{
var fragment = GameMessageFragment.BuildSingleFragment(
_fragmentSequence++, GameMessageGroup.UIQueue, gameMessageBody);
_fragmentSequence++, queue, gameMessageBody);
byte[] packetBody = GameMessageFragment.Serialize(fragment);
var header = new PacketHeader
{