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 + } +}