feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -77,13 +78,15 @@ public static class GameEventWiring
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
clientTime ??= static () => 0d;
// ── Chat ──────────────────────────────────────────────────
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
@ -283,21 +286,44 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
{
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentAdded(
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
});
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
if (entries is not null)
{
double receivedAt = clientTime();
spellbook.OnEnchantmentsAdded(entries.Select(entry =>
ToActiveEnchantment(entry, receivedAt)));
}
});
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
{
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
_ => spellbook.OnPurgeBadEnchantments());
// ── Inventory ─────────────────────────────────────────────
dispatcher.Register(GameEventType.WieldObject, e =>
@ -407,8 +433,8 @@ public static class GameEventWiring
// spellbook arrives via PlayerDescription (0x0013), which uses
// a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
// The appraised spellbook belongs to that item. The local player's
// learned spell manifest arrives only in PlayerDescription.
});
// ── Player ────────────────────────────────────────────────
@ -441,6 +467,17 @@ public static class GameEventWiring
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
onDesiredComponents?.Invoke(p.Value.DesiredComps);
double receivedAt = clientTime();
ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments
.Select(entry => ToActiveEnchantment(entry, receivedAt))
.ToArray();
spellbook.ReplaceManifest(
p.Value.Spells,
enchantments,
p.Value.HotbarSpells,
p.Value.DesiredComps,
p.Value.SpellbookFilters);
// 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
@ -508,9 +545,6 @@ public static class GameEventWiring
}
}
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// K-fix7 (2026-04-26): push Run + Jump skill values to the
// PlayerMovementController so the runRate / jump-arc formulas
// use the SERVER's authoritative skill instead of our
@ -566,21 +600,6 @@ public static class GameEventWiring
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
// source. Record existence (+ equip slot); CreateObject fills the
// actual weenie data via ObjectTableWiring. (Previously this seeded
@ -630,4 +649,46 @@ public static class GameEventWiring
onShortcuts?.Invoke(p.Value.Shortcuts);
});
}
private static ActiveEnchantmentRecord ToActiveEnchantment(
PlayerDescriptionParser.EnchantmentEntry enchantment,
double receivedAt) => new(
SpellId: enchantment.SpellId,
LayerId: enchantment.Layer,
Duration: enchantment.Duration,
CasterGuid: enchantment.CasterGuid,
StatModType: enchantment.StatModType,
StatModKey: enchantment.StatModKey,
StatModValue: enchantment.StatModValue,
Bucket: enchantment.Bucket == 0
? ClassifyLiveEnchantmentBucket(enchantment.StatModType)
: (uint)enchantment.Bucket,
// Retail Enchantment::UnPack (0x005CB040) converts both relative
// wire timestamps to the monotonic client Timer domain at receipt.
StartTime: receivedAt + enchantment.StartTime,
SpellCategory: enchantment.SpellCategory,
PowerLevel: enchantment.PowerLevel,
DegradeModifier: enchantment.DegradeModifier,
DegradeLimit: enchantment.DegradeLimit,
LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded,
SpellSetId: enchantment.SpellSetId);
/// <summary>
/// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer
/// EnchantmentMask bucket. Retail reconstructs the registry list from the
/// StatMod type flags; this is the same ordering used by ACE's
/// EnchantmentRegistry.BuildCategories.
/// </summary>
private static uint ClassifyLiveEnchantmentBucket(uint statModType)
{
const uint Multiplicative = 0x00004000u;
const uint Additive = 0x00008000u;
const uint Vitae = 0x00800000u;
const uint Cooldown = 0x01000000u;
if ((statModType & Vitae) != 0) return 4u;
if ((statModType & Cooldown) != 0) return 8u;
if ((statModType & Multiplicative) != 0) return 1u;
if ((statModType & Additive) != 0) return 2u;
return 0u;
}
}

View file

@ -33,6 +33,9 @@ public static class ClientCommandRequests
public const uint DisplayConsentOpcode = 0x0217u;
public const uint RemoveConsentOpcode = 0x0218u;
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
public const uint AddSpellFavoriteOpcode = 0x01E3u;
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
public const uint SpellbookFilterOpcode = 0x0286u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
@ -177,6 +180,31 @@ public static class ClientCommandRequests
return body;
}
// CM_Character::Event_AddSpellFavorite @ 0x006A0F70.
public static byte[] BuildAddSpellFavorite(
uint sequence, uint spellId, int position, int tabIndex)
{
byte[] body = CreateBody(sequence, AddSpellFavoriteOpcode, 12);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), position);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(20), tabIndex);
return body;
}
// CM_Character::Event_RemoveSpellFavorite @ 0x006A1890.
public static byte[] BuildRemoveSpellFavorite(
uint sequence, uint spellId, int tabIndex)
{
byte[] body = CreateBody(sequence, RemoveSpellFavoriteOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), tabIndex);
return body;
}
// CM_Character::Event_SpellbookFilterEvent @ 0x006A1A30.
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -207,6 +207,9 @@ public static class CreateObject
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
// AMMO_NONE is the explicit wire value zero; null means absent.
ushort? AmmoType = null,
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member.
uint? SpellId = null,
// Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
@ -862,6 +865,7 @@ public static class CreateObject
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -1011,6 +1015,7 @@ public static class CreateObject
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
@ -1113,6 +1118,7 @@ public static class CreateObject
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
Physics: physics);
}
catch

View file

@ -0,0 +1,115 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail <c>Enchantment::UnPack</c> reader shared by PlayerDescription and
/// the 0x02C2/0x02C4 magic update events. The record is 60 bytes, plus the
/// optional four-byte spell-set id.
/// </summary>
/// <remarks>
/// Retail reference: <c>CM_Magic::DispatchUI_UpdateEnchantment</c>
/// (0x006A32F0). Layout cross-checked against ACE
/// <c>Network/Structure/Enchantment.cs</c>.
/// </remarks>
public static class EnchantmentWireReader
{
public static PlayerDescriptionParser.EnchantmentEntry Read(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 60)
throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(source, ref position);
ushort layer = ReadU16(source, ref position);
ushort spellCategory = ReadU16(source, ref position);
ushort hasSpellSetId = ReadU16(source, ref position);
uint powerLevel = ReadU32(source, ref position);
double startTime = ReadF64(source, ref position);
double duration = ReadF64(source, ref position);
uint casterGuid = ReadU32(source, ref position);
float degradeModifier = ReadF32(source, ref position);
float degradeLimit = ReadF32(source, ref position);
double lastDegraded = ReadF64(source, ref position);
uint statModType = ReadU32(source, ref position);
uint statModKey = ReadU32(source, ref position);
float statModValue = ReadF32(source, ref position);
uint? spellSetId = hasSpellSetId != 0 ? ReadU32(source, ref position) : null;
if (!double.IsFinite(startTime)
|| !double.IsFinite(duration)
|| !float.IsFinite(degradeModifier)
|| !float.IsFinite(degradeLimit)
|| !double.IsFinite(lastDegraded)
|| !float.IsFinite(statModValue))
{
throw new FormatException("non-finite enchantment value");
}
return new PlayerDescriptionParser.EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry> ReadList(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 4)
throw new FormatException("truncated enchantment list count");
uint count = ReadU32(source, ref position);
if (count > 0x4000)
throw new FormatException("unreasonable enchantment list count");
var result = new List<PlayerDescriptionParser.EnchantmentEntry>((int)count);
for (uint i = 0; i < count; i++)
result.Add(Read(source, ref position, bucket));
return result;
}
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 2);
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(position, 2));
position += 2;
return value;
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static float ReadF32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
float value = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static double ReadF64(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 8);
double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(position, 8));
position += 8;
return value;
}
private static void Require(ReadOnlySpan<byte> source, int position, int count)
{
if (position < 0 || source.Length - position < count)
throw new FormatException("truncated enchantment record");
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Net.Messages;
@ -271,14 +272,19 @@ public static class GameEvents
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
{
public uint Packed => SpellId | ((uint)Layer << 16);
}
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
@ -294,26 +300,20 @@ public static class GameEvents
/// 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)
public static PlayerDescriptionParser.EnchantmentEntry? 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)));
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
/// <summary>
@ -323,6 +323,23 @@ public static class GameEvents
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
var result = new LayeredSpellId[count];
for (int i = 0; i < result.Length; i++)
{
int offset = 4 + i * 4;
result[i] = new LayeredSpellId(
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
}
return result;
}
// ── Appraise / identify ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>

View file

@ -328,7 +328,9 @@ public static class PlayerDescriptionParser
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
uint options1 = 0;
uint options2 = 0;
uint spellbookFilters = 0;
// Retail CPlayerModule ctor (0x005D5245) enables every school and
// level filter before any optional PlayerDescription override.
uint spellbookFilters = 0x3FFFu;
List<ShortcutEntry> shortcuts = new();
List<IReadOnlyList<uint>> hotbarSpells = new();
List<(uint, uint)> desiredComps = new();
@ -687,10 +689,7 @@ public static class PlayerDescriptionParser
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
dest.AddRange(EnchantmentWireReader.ReadList(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
@ -703,29 +702,7 @@ public static class PlayerDescriptionParser
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
return EnchantmentWireReader.Read(src, ref pos, bucket);
}
/// <summary>Strict inventory + equipped block reader. Returns true if

View file

@ -138,5 +138,6 @@ public static class ObjectTableWiring
CombatUse: s.CombatUse,
PluralName: s.PluralName,
PetOwnerId: s.PetOwnerId,
AmmoType: s.AmmoType);
AmmoType: s.AmmoType,
SpellId: s.SpellId);
}

View file

@ -131,6 +131,7 @@ public sealed class WorldSession : IDisposable
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
PhysicsSpawnData? Physics = null);
/// <summary>
@ -191,6 +192,7 @@ public sealed class WorldSession : IDisposable
PluralName: parsed.PluralName,
PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType,
SpellId: parsed.SpellId,
Physics: parsed.Physics);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
@ -1451,6 +1453,45 @@ public sealed class WorldSession : IDisposable
seq, componentId: 0u, amount: uint.MaxValue));
}
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId, amount));
}
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
seq, spellId, position, tabIndex));
}
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
seq, spellId, tabIndex));
}
public void SendSpellbookFilter(uint filters)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
}
public void SendCastUntargetedSpell(uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
}
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{