feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay

ToolbarController is now the LIVE drag handler:
- OnDragLift: removes the source slot from ShortcutStore, sends
  RemoveShortcut (0x019D) wire immediately (retail remove-on-lift model).
- HandleDropRelease: evicts occupant from target (RemoveShortcut),
  places dragged item (AddShortcut 0x019C), bumps evicted item into
  the vacated source slot if empty (swap path); off-bar release leaves
  the lift's removal standing.
- Populate() now lazy-loads ShortcutStore from the PD shortcut list on
  first call; store is authoritative thereafter.
- IsShortcutGuid() uses the store (O(18)) when loaded, falls back to
  scanning _shortcuts() in the pre-PD window.
- All 18 slot cells now get DragAcceptSprite=0x060011FA (green cross,
  distinct from the inventory ring 0x060011F9).
- GameWindow.Bind wired: sendAddShortcut + sendRemoveShortcut lambdas
  forwarded to _liveSession?.Send*().
- Divergence register: AP-47 Divergence+Risk cells updated (opacity
  corrected, underlay-backing framing improved). TS-33 row deleted
  (stopgap retired). Section header 32→31 rows.
- Tests: HandleDropRelease_isInertStub removed; 5 new B.2 tests added
  (lift removes + sends, swap sequence, empty-target place, self-drop
  re-adds, green-cross sprite). 24/24 ToolbarController tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 15:33:23 +02:00
parent 41bb70c11a
commit 250f7827be
4 changed files with 179 additions and 38 deletions

View file

@ -2018,7 +2018,9 @@ public sealed class GameWindow : IDisposable
combatState: Combat,
peaceDigits: toolbarPeaceDigits,
warDigits: toolbarWarDigits,
emptyDigits: toolbarEmptyDigits);
emptyDigits: toolbarEmptyDigits,
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
// Analogue of retail gmToolbarUI::HandleSelectionChanged

View file

@ -60,6 +60,10 @@ public sealed class ToolbarController : IItemListDragHandler
private readonly Func<IReadOnlyList<PlayerDescriptionParser.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.Net.Items.ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -82,7 +86,9 @@ public sealed class ToolbarController : IItemListDragHandler
CombatState? combatState,
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits)
uint[]? emptyDigits,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
_repo = repo;
_shortcuts = shortcuts;
@ -91,6 +97,8 @@ public sealed class ToolbarController : IItemListDragHandler
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
for (int i = 0; i < SlotIds.Length; i++)
{
@ -104,6 +112,7 @@ public sealed class ToolbarController : IItemListDragHandler
list.RegisterDragHandler(this);
list.Cell.SlotIndex = i;
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
}
}
@ -133,11 +142,20 @@ public sealed class ToolbarController : IItemListDragHandler
}
/// <summary>
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
/// (i.e., present in the live <see cref="AcDream.Core.Net.Items.ShortcutStore"/>).
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
/// </summary>
private bool IsShortcutGuid(uint guid)
{
if (_storeLoaded)
{
for (int s = 0; s < AcDream.Core.Net.Items.ShortcutStore.SlotCount; s++)
if (_store.Get(s) == guid) return true;
return false;
}
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
foreach (var sc in _shortcuts())
if (sc.ObjectGuid == guid) return true;
return false;
@ -182,10 +200,13 @@ public sealed class ToolbarController : IItemListDragHandler
CombatState? combatState = null,
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null)
uint[]? emptyDigits = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits);
peaceDigits, warDigits, emptyDigits,
sendAddShortcut, sendRemoveShortcut);
c.Populate();
return c;
}
@ -196,24 +217,27 @@ public sealed class ToolbarController : IItemListDragHandler
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// As of B.2: the <see cref="AcDream.Core.Net.Items.ShortcutStore"/> is the
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
/// </summary>
public void Populate()
{
// Clear all slot cells first (flush).
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; }
foreach (var list in _slots) list?.Cell.Clear();
foreach (var sc in _shortcuts())
for (int slot = 0; slot < _slots.Length; slot++)
{
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
if (sc.Index >= (uint)_slots.Length) continue;
var list = _slots[(int)sc.Index];
uint guid = _store.Get(slot);
if (guid == 0) continue;
var list = _slots[slot];
if (list is null) continue;
var item = _repo.Get(sc.ObjectGuid);
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
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(sc.ObjectGuid, tex);
list.Cell.SetItem(guid, tex);
}
// Re-stamp slot number labels after any item change.
@ -296,13 +320,24 @@ public sealed class ToolbarController : IItemListDragHandler
};
}
// ── IItemListDragHandler (B.1 spine) ─────────────────────────────────────
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut
// wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33).
// Retail model (remove-on-lift / place-on-drop / no-restore):
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
// off-bar release → the lift's removal stands (no restore)
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
/// <inheritdoc/>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { /* Task 3: remove + wire */ }
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
// drop onto a slot. Off-bar release leaves it removed.
_store.Remove(payload.SourceSlot);
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
Populate();
}
/// <inheritdoc/>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
@ -311,9 +346,19 @@ public sealed class ToolbarController : IItemListDragHandler
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation +
// AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971).
Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " +
$"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");
// Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5):
// 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)
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
_store.Set(target, payload.ObjId); // AddShortcut(dragged, target)
_sendAddShortcut?.Invoke((uint)target, payload.ObjId);
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);
}
Populate();
}
}