feat(ui): preserve exact retail shortcut records

Carry signed index, object id, and raw spell word losslessly through PlayerDescription, session storage, drag mutation, and AddShortcut wire serialization while keeping gmToolbarUI object-only. Retire AP-103 and record the live ACE relog persistence gate.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 09:17:33 +02:00
parent e65119f0c6
commit b5b230c860
21 changed files with 271 additions and 181 deletions

View file

@ -0,0 +1,14 @@
namespace AcDream.Core.Items;
/// <summary>
/// Lossless retail <c>ShortCutData</c> value: signed slot index, object id,
/// and raw 32-bit spell word. The C++ object is 16 bytes including its vtable;
/// the packed value is exactly 12 bytes. Retail anchors:
/// <c>ShortCutData::ShortCutData @ 0x005D55E0</c>,
/// <c>ShortCutManager::AddShortCut @ 0x005D5790</c>, and
/// <c>acclient.h:36484</c>.
/// </summary>
public readonly record struct ShortcutEntry(int Index, uint ObjectId, uint SpellId)
{
public ShortcutEntry WithIndex(int index) => this with { Index = index };
}

View file

@ -4,31 +4,53 @@ using System.Collections.Generic;
namespace AcDream.Core.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 shortcut list, 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 — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model
/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs.
/// Mutable client-side model of retail <c>ShortCutManager::shortCuts_[18]</c>
/// (<c>acclient.h:36492</c>). Every present entry retains all three raw
/// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only
/// <c>gmToolbarUI</c> does not render.
/// </summary>
public sealed class ShortcutStore
{
public const int SlotCount = 18;
private readonly uint[] _objIds = new uint[SlotCount];
private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount];
/// <summary>Replace all slots from a (slot, objectGuid) sequence (item entries only;
/// ObjGuid 0 and out-of-range slots are skipped).</summary>
public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries)
/// <summary>
/// Replace all slots from packed entries. Invalid signed indices are rejected exactly
/// like <c>ShortCutManager::AddShortCut @ 0x005D5790</c>; valid entries are retained
/// even when their object id is zero.
/// </summary>
public void Load(IEnumerable<ShortcutEntry> entries)
{
Array.Clear(_objIds);
foreach (var (slot, objGuid) in entries)
if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid;
Array.Clear(_entries);
foreach (var entry in entries)
Set(entry);
}
/// <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;
/// <summary>Raw entry at <paramref name="slot"/>, or null for absent/out-of-range.</summary>
public ShortcutEntry? GetEntry(int slot)
=> (uint)slot < SlotCount ? _entries[slot] : null;
/// <summary>Visible object id at <paramref name="slot"/>, or zero.</summary>
public uint Get(int slot) => GetEntry(slot)?.ObjectId ?? 0u;
/// <summary>
/// Visual availability used by <c>gmToolbarUI</c>. A retained non-object entry remains
/// invisible and therefore counts as an empty toolbar cell.
/// </summary>
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; }
public void Set(ShortcutEntry entry)
{
if ((uint)entry.Index < SlotCount)
_entries[entry.Index] = entry;
}
/// <summary>Create/replace a retail object shortcut; toolbar-created records use spell word zero.</summary>
public void SetItem(int slot, uint objectId) => Set(new ShortcutEntry(slot, objectId, 0u));
public void Remove(int slot)
{
if ((uint)slot < SlotCount)
_entries[slot] = null;
}
}