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

@ -737,8 +737,8 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Net.Messages.PlayerDescriptionParser.ShortcutEntry> Shortcuts { get; private set; }
= System.Array.Empty<AcDream.Core.Net.Messages.PlayerDescriptionParser.ShortcutEntry>();
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts { get; private set; }
= System.Array.Empty<AcDream.Core.Items.ShortcutEntry>();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
@ -2114,7 +2114,7 @@ public sealed class GameWindow : IDisposable
Combat,
ToggleLiveCombatMode,
_itemInteractionController,
(index, guid) => _liveSession?.SendAddShortcut(index, guid),
entry => _liveSession?.SendAddShortcut(entry),
index => _liveSession?.SendRemoveShortcut(index),
_selection,
handler => Combat.HealthChanged += handler,

View file

@ -2,7 +2,6 @@ using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
namespace AcDream.App.Studio;
@ -92,7 +91,7 @@ public static class FixtureProvider
ToolbarController.Bind(
layout,
objects,
shortcuts: () => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
shortcuts: () => System.Array.Empty<ShortcutEntry>(),
iconIds: MakeIconIds(stack),
useItem: _ => { },
combatState: null,

View file

@ -1,3 +1,5 @@
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
@ -21,4 +23,5 @@ public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost
UiItemSlot SourceCell, // back-ref: reorder-restore / clear source state / ghost
ShortcutEntry? Shortcut = null); // lossless raw entry for shortcut-alias mutation

View file

@ -63,12 +63,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly UiButton? _inventoryButton;
private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Action<uint> _useItem; // guid → fire UseObject
private readonly AcDream.Core.Items.ShortcutStore _store = new();
private readonly ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<ShortcutEntry>? _sendAddShortcut;
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
@ -89,7 +89,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private ToolbarController(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState,
@ -97,7 +97,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
uint[]? warDigits,
uint[]? emptyDigits,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null)
@ -189,7 +189,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
}
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
foreach (var sc in _shortcuts())
if (sc.ObjectGuid == guid) return true;
if (sc.ObjectId == guid) return true;
return false;
}
@ -226,7 +226,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
public static ToolbarController Bind(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null,
@ -234,7 +234,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
uint[]? warDigits = null,
uint[]? emptyDigits = null,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null)
@ -288,7 +288,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
if (!_storeLoaded && _shortcuts().Count > 0)
{
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
_store.Load(_shortcuts());
_storeLoaded = true;
}
@ -296,14 +296,15 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
for (int slot = 0; slot < _slots.Length; slot++)
{
uint guid = _store.Get(slot);
ShortcutEntry? entry = _store.GetEntry(slot);
uint guid = entry?.ObjectId ?? 0u;
if (guid == 0) continue;
var list = _slots[slot];
if (list is null) continue;
var item = _repo.Get(guid);
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
list.Cell.SetItem(guid, tex);
list.Cell.SetItem(guid, tex, entry);
}
// Re-stamp slot number labels after any item change.
@ -466,8 +467,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (empty < 0)
return false;
_store.Set(empty, itemId);
_sendAddShortcut?.Invoke((uint)empty, itemId);
var entry = new ShortcutEntry(empty, itemId, 0u);
_store.Set(entry);
_sendAddShortcut?.Invoke(entry);
Populate();
return true;
}
@ -476,7 +478,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{
if (_storeLoaded)
return;
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
_store.Load(_shortcuts());
_storeLoaded = true;
}
@ -510,14 +512,18 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// evict the target's occupant, place the dragged item there, and bump the evicted item into
// the (now-vacated) source slot if it's free.
int target = targetCell.SlotIndex;
uint evicted = _store.Get(target); // RemoveShortCutInSlotNum(target)
ShortcutEntry? evictedEntry = _store.GetEntry(target); // RemoveShortCutInSlotNum(target)
uint evicted = evictedEntry?.ObjectId ?? 0u;
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
_store.Set(target, payload.ObjId); // AddShortcut(dragged, target)
_sendAddShortcut?.Invoke((uint)target, payload.ObjId);
ShortcutEntry dragged = (payload.Shortcut ?? new ShortcutEntry(payload.SourceSlot, payload.ObjId, 0u))
.WithIndex(target);
_store.Set(dragged); // AddShortcut(dragged, target)
_sendAddShortcut?.Invoke(dragged);
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
{ // displaced → vacated source slot
_store.Set(payload.SourceSlot, evicted);
_sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted);
ShortcutEntry displaced = evictedEntry!.Value.WithIndex(payload.SourceSlot);
_store.Set(displaced);
_sendAddShortcut?.Invoke(displaced);
}
Populate();
}

View file

@ -37,13 +37,13 @@ public sealed record RadarRuntimeBindings(
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> Shortcuts,
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Action<uint> UseItem,
CombatState Combat,
Action ToggleCombat,
ItemInteractionController ItemInteraction,
Action<uint, uint>? SendAddShortcut,
Action<ShortcutEntry>? SendAddShortcut,
Action<uint>? SendRemoveShortcut,
SelectionState Selection,
Action<Action<uint, float>> SubscribeHealthChanged,

View file

@ -1,5 +1,6 @@
using System;
using System.Numerics;
using AcDream.Core.Items;
namespace AcDream.App.UI;
@ -21,6 +22,12 @@ public sealed class UiItemSlot : UiElement
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
public uint IconTexture { get; private set; }
/// <summary>
/// Lossless shortcut record when this cell belongs to the toolbar. Physical item
/// lists leave it null. Snapshotted into the drag payload before remove-on-lift.
/// </summary>
public ShortcutEntry? Shortcut { get; private set; }
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 19 label, -1 on the
/// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
@ -79,17 +86,18 @@ public sealed class UiItemSlot : UiElement
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
public void SetItem(uint itemId, uint iconTexture)
public void SetItem(uint itemId, uint iconTexture, ShortcutEntry? shortcut = null)
{
ItemId = itemId;
IconTexture = iconTexture;
Shortcut = shortcut;
}
public void Clear() { ItemId = 0; IconTexture = 0; }
public void Clear() { ItemId = 0; IconTexture = 0; Shortcut = null; }
/// <inheritdoc/>
public override object? GetDragPayload()
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this, Shortcut) : null;
/// <inheritdoc/>
public override (uint tex, int w, int h)? GetDragGhost()

View file

@ -65,7 +65,7 @@ public static class GameEventWiring
// D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
// toolbar can populate itself at login without keeping a parser reference.
// Optional so all existing callers and tests compile unchanged.
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
Action<IReadOnlyList<ShortcutEntry>>? onShortcuts = null,
// B-Wire: the local player's server guid. When provided, the PD handler upserts
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null,

View file

@ -1,4 +1,5 @@
using System.Buffers.Binary;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
@ -98,20 +99,20 @@ public static class InventoryActions
return body;
}
/// <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 index, uint objectGuid, ushort spellId, ushort layer)
/// <summary>
/// Pack retail <c>ShortCutData</c> for AddShortcut: signed index, object id,
/// and raw 32-bit spell word. Retail uses the same three-u32 packer as
/// <c>InventoryPlacement::Pack @ 0x005CABB0</c> through the shortcut vtable.
/// </summary>
public static byte[] BuildAddShortcut(uint seq, ShortcutEntry entry)
{
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), index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(12), entry.Index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), entry.ObjectId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), entry.SpellId);
return body;
}

View file

@ -197,18 +197,6 @@ public static class PlayerDescriptionParser
SpellLists8 = 0x00000400,
}
/// <summary>One shortcut bar entry. 16 bytes wire size.
/// holtburger <c>shortcuts.rs:13-34</c>. Named <c>ShortcutEntry</c>
/// (not <c>Shortcut</c>) to avoid a homograph with the
/// <see cref="CharacterOptionDataFlag.Shortcut"/> flag bit, which is
/// referenced from the same scope as instances of this type in the
/// trailer walker.</summary>
public readonly record struct ShortcutEntry(
uint Index,
uint ObjectGuid,
ushort SpellId,
ushort Layer);
/// <summary>One inventory entry — a guid plus a ContainerType
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
/// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c>
@ -348,11 +336,10 @@ public static class PlayerDescriptionParser
if (count > 10_000) throw new FormatException("unreasonable shortcut count");
for (uint i = 0; i < count; i++)
{
uint idx = ReadU32(payload, ref pos);
uint guid = ReadU32(payload, ref pos);
ushort spellId = ReadU16(payload, ref pos);
ushort layer = ReadU16(payload, ref pos);
shortcuts.Add(new ShortcutEntry(idx, guid, spellId, layer));
int index = unchecked((int)ReadU32(payload, ref pos));
uint objectId = ReadU32(payload, ref pos);
uint spellId = ReadU32(payload, ref pos);
shortcuts.Add(new ShortcutEntry(index, objectId, spellId));
}
}

View file

@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Net;
using System.Threading.Channels;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
@ -1273,12 +1274,11 @@ public sealed class WorldSession : IDisposable
SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits));
}
/// <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)
/// <summary>Send lossless retail AddShortcut (0x019C).</summary>
public void SendAddShortcut(ShortcutEntry entry)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
SendGameAction(InventoryActions.BuildAddShortcut(seq, entry));
}
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.

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