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:
parent
7193bed309
commit
745a92bbae
5 changed files with 115 additions and 15 deletions
32
src/AcDream.Core.Net/Items/ShortcutStore.cs
Normal file
32
src/AcDream.Core.Net/Items/ShortcutStore.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
42
tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
Normal file
42
tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
Normal file
|
|
@ -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<PlayerDescriptionParser.ShortcutEntry>
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue