diff --git a/src/AcDream.Core.Net/Items/ShortcutStore.cs b/src/AcDream.Core.Net/Items/ShortcutStore.cs
new file mode 100644
index 00000000..bff0b098
--- /dev/null
+++ b/src/AcDream.Core.Net/Items/ShortcutStore.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Items;
+
+///
+/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
+/// ShortCutManager::shortCuts_[18] (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.
+///
+public sealed class ShortcutStore
+{
+ public const int SlotCount = 18;
+ private readonly uint[] _objIds = new uint[SlotCount];
+
+ /// Replace all slots from the login PlayerDescription shortcut list (item entries only).
+ public void Load(IReadOnlyList entries)
+ {
+ System.Array.Clear(_objIds);
+ foreach (var e in entries)
+ if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid;
+ }
+
+ /// Bound object guid at , or 0 (empty / out of range).
+ 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; }
+}
diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs
index f46bd5b6..72fe566c 100644
--- a/src/AcDream.Core.Net/Messages/InventoryActions.cs
+++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs
@@ -95,17 +95,20 @@ public static class InventoryActions
return body;
}
- /// Pin an item / spell to a quickbar slot.
+ /// 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.
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;
}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 6508084d..3f6dbbe2 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -1140,6 +1140,22 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
+ /// Send AddShortcut (0x019C) — pin an item to toolbar slot .
+ /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.
+ public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
+ {
+ uint seq = NextGameActionSequence();
+ SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
+ }
+
+ /// Send RemoveShortcut (0x019D) — clear toolbar slot .
+ /// Retail: CM_Character::Event_RemoveShortCut.
+ public void SendRemoveShortcut(uint index)
+ {
+ uint seq = NextGameActionSequence();
+ SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
+ }
+
/// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).
///
/// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635
diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
index 5bfec696..ee71c3a0 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
@@ -74,18 +74,25 @@ public sealed class InventoryActionsTests
}
[Fact]
- public void BuildAddShortcut_ThreeFields()
+ public void BuildAddShortcut_ItemShortcut_FieldLayout()
{
- byte[] body = InventoryActions.BuildAddShortcut(
- seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1);
+ // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0.
+ byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0);
+ Assert.Equal(24, body.Length);
Assert.Equal(InventoryActions.AddShortcutOpcode,
- BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
- Assert.Equal(0u,
- BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
- Assert.Equal(1u,
- BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
- Assert.Equal(0x3E1u,
- BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
+ System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index
+ Assert.Equal(0x3E1u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid
+ Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId
+ Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer
+ }
+
+ [Fact]
+ public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s()
+ {
+ byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3);
+ Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
+ Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22)));
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
new file mode 100644
index 00000000..2804f64e
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using AcDream.Core.Net.Items;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Tests.Items;
+
+public class ShortcutStoreTests
+{
+ [Fact]
+ public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange()
+ {
+ var store = new ShortcutStore();
+ var entries = new List
+ {
+ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
+ new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0),
+ new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store)
+ new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped
+ };
+ store.Load(entries);
+ Assert.Equal(0x5001u, store.Get(0));
+ Assert.Equal(0x5002u, store.Get(3));
+ Assert.Equal(0u, store.Get(5));
+ Assert.True(store.IsEmpty(1));
+ }
+
+ [Fact]
+ public void SetRemoveGet_roundtrip_andBoundsSafe()
+ {
+ var store = new ShortcutStore();
+ store.Set(4, 0xABCDu);
+ Assert.Equal(0xABCDu, store.Get(4));
+ Assert.False(store.IsEmpty(4));
+ store.Remove(4);
+ Assert.True(store.IsEmpty(4));
+ Assert.Equal(0u, store.Get(-1));
+ Assert.Equal(0u, store.Get(18));
+ store.Set(18, 1u); // no-op, no throw
+ store.Remove(-1); // no-op, no throw
+ }
+}