diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs
new file mode 100644
index 00000000..817effa0
--- /dev/null
+++ b/src/AcDream.App/UI/IItemListDragHandler.cs
@@ -0,0 +1,19 @@
+namespace AcDream.App.UI;
+
+///
+/// A panel controller implements this and registers itself on each of its
+/// s. Port of retail's m_dragHandler vtable
+/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient
+/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
+/// decides the accept/reject OVERLAY only (advisory).
+/// is authoritative — it performs the action, or
+/// no-ops to reject.
+///
+public interface IItemListDragHandler
+{
+ /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).
+ bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
+
+ /// Perform the drop (issue the per-panel wire action), or no-op to reject.
+ void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
+}
diff --git a/src/AcDream.App/UI/ItemDragPayload.cs b/src/AcDream.App/UI/ItemDragPayload.cs
new file mode 100644
index 00000000..b06728d4
--- /dev/null
+++ b/src/AcDream.App/UI/ItemDragPayload.cs
@@ -0,0 +1,24 @@
+namespace AcDream.App.UI;
+
+///
+/// Where a dragged item came from — the retail InqDropIconInfo flag
+/// distinction (flags & 0xE == 0 fresh-from-inventory vs
+/// flags & 4 within-list reorder) expressed as a typed enum. The drop
+/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
+/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
+///
+public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
+
+///
+/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
+/// mid-drag can't mutate it under us). Port of retail's m_dragElement +
+/// InqDropIconInfo out-params (objId/container/flags, decomp 230533).
+/// SourceContainer is intentionally NOT stored: the handler resolves the
+/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the
+/// same container id retail reads off the dragged element, single source of truth.
+///
+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
diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs
index 761d4db3..6c4ce9bf 100644
--- a/src/AcDream.App/UI/UiItemList.cs
+++ b/src/AcDream.App/UI/UiItemList.cs
@@ -24,6 +24,14 @@ public sealed class UiItemList : UiElement
public Func? SpriteResolve { get; set; }
+ /// The drag handler this list routes drops to (a panel controller).
+ /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.
+ public IItemListDragHandler? DragHandler { get; private set; }
+
+ /// Register the panel's drag handler on this list. Retail:
+ /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).
+ public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
+
/// Convenience for single-cell slots (the toolbar): the first cell.
/// Valid only while the list has at least one cell; after
/// (the inventory-phase rebuild path) the list is empty until
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
new file mode 100644
index 00000000..b6f67bfc
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -0,0 +1,42 @@
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+public class DragDropSpineTests
+{
+ // A spy handler used across the spine tests.
+ private sealed class SpyHandler : IItemListDragHandler
+ {
+ public bool AcceptResult = true;
+ public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
+ public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
+
+ public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
+ { LastOver = (list, cell, p); return AcceptResult; }
+
+ public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
+ { LastDrop = (list, cell, p); }
+ }
+
+ [Fact]
+ public void Payload_holdsAllFields()
+ {
+ var src = new UiItemSlot();
+ var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
+ Assert.Equal(0x5001u, p.ObjId);
+ Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
+ Assert.Equal(3, p.SourceSlot);
+ Assert.Same(src, p.SourceCell);
+ }
+
+ [Fact]
+ public void UiItemList_registerDragHandler_roundtrips()
+ {
+ var list = new UiItemList(_ => (0u, 0, 0));
+ Assert.Null(list.DragHandler);
+ var h = new SpyHandler();
+ list.RegisterDragHandler(h);
+ Assert.Same(h, list.DragHandler);
+ }
+}