feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders

ShortcutStore lands in AcDream.Core.Net.Items (not AcDream.Core.Items) because
it depends on PlayerDescriptionParser.ShortcutEntry; placing it in AcDream.Core
would create a circular dependency (Core.Net already references Core). The test
lives in AcDream.Core.Tests which gets Core.Net transitively via App.

BuildAddShortcut signature corrected from the old (seq, slotIndex, objectType,
targetId) 4×u32 layout to the retail ShortCutData wire format confirmed in the
action-bar deep-dive: Index(u32), ObjectId(u32), SpellId(u16), Layer(u16).
The old BuildAddShortcut_ThreeFields test is replaced by two new tests that
verify both item and spell shortcut packing.

WorldSession gains SendAddShortcut / SendRemoveShortcut following the
SendChangeCombatMode sender pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 15:09:58 +02:00
parent 7193bed309
commit 745a92bbae
5 changed files with 115 additions and 15 deletions

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Items;
/// <summary>
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
/// <c>ShortCutManager::shortCuts_[18]</c> (acclient.h:36492). Holds the bound object guid per
/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop
/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the
/// store stays the client's source of truth within a session (retail: client owns the array).
/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load.
/// </summary>
public sealed class ShortcutStore
{
public const int SlotCount = 18;
private readonly uint[] _objIds = new uint[SlotCount];
/// <summary>Replace all slots from the login PlayerDescription shortcut list (item entries only).</summary>
public void Load(IReadOnlyList<PlayerDescriptionParser.ShortcutEntry> entries)
{
System.Array.Clear(_objIds);
foreach (var e in entries)
if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid;
}
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
public bool IsEmpty(int slot) => Get(slot) == 0u;
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
}

View file

@ -95,17 +95,20 @@ public static class InventoryActions
return body;
}
/// <summary>Pin an item / spell to a quickbar slot.</summary>
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32),
/// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive
/// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0.</summary>
public static byte[] BuildAddShortcut(
uint seq, uint slotIndex, uint objectType, uint targetId)
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
return body;
}

View file

@ -1140,6 +1140,22 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
public void SendRemoveShortcut(uint index)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>