diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 34c14a31..195f7046 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 4. Temporary stopgap (TS) — 31 rows
+## 4. Temporary stopgap (TS) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -186,6 +186,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
+| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D |
---
diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs
index 1279328a..9cb43471 100644
--- a/src/AcDream.App/UI/Layout/ToolbarController.cs
+++ b/src/AcDream.App/UI/Layout/ToolbarController.cs
@@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
/// CreateObject resolves a formerly-unknown guid.
///
///
-public sealed class ToolbarController
+public sealed class ToolbarController : IItemListDragHandler
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
@@ -96,7 +96,15 @@ public sealed class ToolbarController
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
+ {
WireClick(list);
+ // B.1 drag-drop spine: this controller is the drop handler for every
+ // toolbar slot list; each cell knows its slot index + that it's a
+ // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
+ list.RegisterDragHandler(this);
+ list.Cell.SlotIndex = i;
+ list.Cell.SourceKind = ItemDragSource.ShortcutBar;
+ }
}
// Cache the four mutually-exclusive combat-mode indicator elements.
@@ -287,4 +295,22 @@ public sealed class ToolbarController
_useItem(list.Cell.ItemId);
};
}
+
+ // ── IItemListDragHandler (B.1 spine) ─────────────────────────────────────
+ // 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).
+
+ ///
+ public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
+ => payload.ObjId != 0;
+
+ ///
+ 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}");
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
index b4204f4d..6a599ecd 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
@@ -421,4 +421,61 @@ public class ToolbarControllerTests
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
}
+
+ // ── B.1: drag-drop spine wiring ──────────────────────────────────────────
+
+ /// Bind registers the controller as each slot list's drag handler and
+ /// stamps every cell's SlotIndex + SourceKind=ShortcutBar.
+ [Fact]
+ public void Bind_registersDragHandler_andStampsSlots()
+ {
+ var (layout, slots, _) = FakeToolbar();
+ var repo = new ClientObjectTable();
+
+ var ctrl = ToolbarController.Bind(layout, repo,
+ () => Array.Empty(),
+ iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
+
+ for (int i = 0; i < Row1.Length; i++)
+ {
+ Assert.Same(ctrl, slots[Row1[i]].DragHandler);
+ Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
+ Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
+ }
+ // Bottom row slots are indices 9..17.
+ for (int j = 0; j < Row2.Length; j++)
+ Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
+ }
+
+ /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
+ /// is Stream B.2; the stub accepts any non-empty payload.
+ [Fact]
+ public void OnDragOver_acceptsRealItem()
+ {
+ var (layout, slots, _) = FakeToolbar();
+ var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
+ () => Array.Empty(),
+ iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
+
+ var list = slots[Row1[0]];
+ var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
+ Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
+ }
+
+ /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not
+ /// mutate the target slot (no wire / no store yet).
+ [Fact]
+ public void HandleDropRelease_isInertStub()
+ {
+ var (layout, slots, _) = FakeToolbar();
+ var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
+ () => Array.Empty(),
+ iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
+
+ var list = slots[Row1[2]];
+ var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
+ var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload));
+ Assert.Null(ex);
+ Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub
+ }
}