acdream/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md
Erik 3b66858893 docs(D.2b): inventory drag-drop (item moving) design spec
Brainstorm output for B-Drag. Drop an inventory item: empty grid slot ->
first empty; on an item -> insert before; on a side-bag cell -> into that
container. Green insert-arrow (valid) / red circle (full). Movement is
OPTIMISTIC/instant per the user — local MoveItem on drop + repaint, server
reconciles via 0x0022 echo, rolls back via 0x00A0 (the rollback the B-Wire
note reserved). InventoryController : IItemListDragHandler; pending-move
tracking in ClientObjectTable (Core, reachable from the Core.Net handlers);
SendPutItemInContainer wraps BuildPickUp 0x0019. Retail anchors: InqDropIconInfo
0x004e26f0 / ItemList_InsertItem / HandleDropRelease / ServerSaysMoveItem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:28:05 +02:00

9.4 KiB
Raw Blame History

D.2b inventory drag-drop (item moving) — design

Date: 2026-06-22 Branch: claude/hopeful-maxwell-214a12 Phase: D.2b retail-UI inventory arc — B-Drag (inventory drag SOURCE + drop placement). Brainstorm: this doc. Builds on the shipped drag-drop spine (B.1) + container-switching.


Goal

Drag an inventory item and drop it to move it, instantly:

  1. on an empty grid slot → the item goes to the first empty slot of the open pack (pack-to-front),
  2. on an occupied iteminsert before it (the rest shift down),
  3. on a side-bag cell → the item goes into that container, with a green insert-arrow overlay while a valid drop is hovered and a red circle when the target container is full.

The move feels instant: the grid updates locally the moment you drop, with the server reconciling in the background (and snapping the item back only if it bounces the move).

Scope

In:

  • Inventory cells as drag sources (already: UiItemSlot.IsDragSource when occupied, SourceKind = Inventory).
  • InventoryController as the drop handler (IItemListDragHandler) on the contents grid + the side-bag list.
  • The three drop placements (goal 13) → PutItemInContainer 0x0019.
  • Optimistic local move on drop (instant repaint) + rollback on InventoryServerSaveFailed.
  • Accept/reject overlays: green insert-arrow (valid) / red circle (target bag full).

Out (separate work, with reasons):

  • Equipping via the paperdoll (GetAndWieldItem 0x001A) — Sub-phase C / its own drop path.
  • Drop-to-ground (DropItem 0x001B) — separate interaction.
  • Dragging the side-bag cells themselves (reordering bags) — bags are drop targets here, not sources.
  • Stack split/merge on drop — deferred (the Selected-Target-Bar split is its own feature).

Retail anchors (the spec is a faithful port)

Concern Retail Note
Drop placement + accept/reject flags UIElement_ItemList::InqDropIconInfo 0x004e26f0 reads the cell's drop-info properties → placement + DropItemFlags
Insert-before UIElement_ItemList::ItemList_InsertItem the dragged item takes the target slot; rest shift
Commit the drop UIElement_ItemList::HandleDropRelease issues the move
Local move + server reconcile ItemList_InsertItem (local) + RecvNotice_ServerSaysMoveItem/ServerSaysMoveItem retail moves locally then reconciles — this is the "instant + rollback" model
Wire PutItemInContainer 0x0019 (item, container, placement) → ACE Player.HandleActionPutItemInContainer placement = the target slot index (server packs/shifts)

Components

1. WorldSession.SendPutItemInContainer (Core.Net — new wrapper)

Thin wrapper over the existing InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement) (opcode 0x0019), mirroring SendUse/SendNoLongerViewingContents:

public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)

2. InventoryController : IItemListDragHandler (App)

Registered on the contents grid + the side-bag list in Populate (list.RegisterDragHandler(this)), like ToolbarController. Cells already default SourceKind = Inventory; set each cell's SlotIndex so the drop target knows its position.

  • OnDragLift → no-op. The item stays in its slot during the drag (unlike the toolbar's remove-on-lift); it moves only on drop. (Retail dims the source; we leave it in place + the floating ghost — a minor approximation.)
  • OnDragOver(targetList, targetCell, payload) → accept/reject overlay (advisory):
    • target is a grid slot (the open pack) → accept (reorder/insert is always valid in the open container).
    • target is a side-bag cellaccept unless that bag is known-full (GetContents(bag).Count >= ItemsCapacity when we know the count) → reject. A closed bag whose count we don't know → accept (advisory; the server is authoritative).
    • Sets targetCell.DragAcceptSprite = the green insert-arrow / DragRejectSprite = the red circle (ids §Overlays).
  • HandleDropRelease(targetList, targetCell, payload) → optimistic move + wire:
    1. Resolve (targetContainer, placement):
      • grid empty slot → targetContainer = EffectiveOpen(), placement = append (first empty = end of the packed list).
      • grid occupied slot N → targetContainer = EffectiveOpen(), placement = targetCell.SlotIndex (insert-before).
      • side-bag cell → targetContainer = targetCell.ItemId (the bag guid), placement = append.
    2. If the target is known-full (the reject case) → no-op (the red overlay already showed; nothing moves).
    3. Else: record the item's pre-move (ContainerId, ContainerSlot) in a pending-move map; _objects.MoveItem(item, targetContainer, placement) locally (instant repaint via ObjectMovedConcernsPopulate); SendPutItemInContainer(item, targetContainer, placement).

3. Optimistic reconcile + rollback — ClientObjectTable (Core)

The pending-move tracking lives in ClientObjectTable (Core), so BOTH the App drop handler and the Core.Net wire handlers reach it (Core.Net can't depend on App):

  • MoveItemOptimistic(itemId, newContainer, newSlot) — snapshot the item's current (ContainerId, ContainerSlot) into a small pending map, then MoveItem (fires ObjectMoved → instant repaint). Called by HandleDropRelease.
  • ConfirmMove(itemId) — clear the pending entry (the move stuck).
  • RollbackMove(itemId)MoveItem back to the snapshotted (container, slot), then clear. Returns false if no pending entry.

Wiring (GameEventWiring):

  • Reconcile (success): the InventoryPutObjInContainer 0x0022 echo already routes to MoveItem (no-op/correction for a move we initiated) — add items.ConfirmMove(itemGuid) after it.
  • Rollback (failure): InventoryServerSaveFailed 0x00A0 (today it only logs) → items.RollbackMove(itemGuid) (snap back). B-Drag is what the B-Wire note reserved this handler for.

4. Overlays (sprites)

Green insert-arrow + red circle, dat-exported + visual-gate-confirmed before pinning (per the empty-slot-art / backpack-icon lesson). Candidates from the shipped spine: green move-arrow 0x060011F7, red ∅ 0x060011F8, green ring 0x060011F9 (inventory accept). UiItemSlot already draws DragAcceptSprite/DragRejectSprite overlays on DragEnter/Reject — the controller sets the inventory ids.

Data flow

drop item on a target cell
  → OnDragOver already showed green (valid) / red (full)
  → HandleDropRelease: resolve (container, placement)
     known-full? → no-op (red, nothing moves)
     else → record pre-move pos
          → MoveItem(item, container, placement) LOCALLY → instant repaint
          → SendPutItemInContainer(item, container, placement)
  ─────────── server ───────────
  → InventoryPutObjInContainer 0x0022 (confirm)  → MoveItem (no-op/correct) → clear pending
    OR
  → InventoryServerSaveFailed 0x00A0 (reject)    → MoveItem back to pre-move → clear pending

Divergence register

  • OnDragLift no-op / source not dimmed — retail dims the lifted item's source cell; we leave it in place + the floating ghost (minor visual).
  • Closed-bag drop is advisory-accept — the client can't know a closed bag's fullness (contents aren't indexed until opened), so it shows green + relies on the server's reject + rollback. Retail knows the count if loaded.
  • Accept/reject overlay ids set by the controller (procedural), pinned by dat-export (cf. AP-55/57).
  • Retire/relax the B-Wire note on InventoryServerSaveFailed ("B-Drag wires the rollback") — now wired.

Test plan

  • Core.Net: SendPutItemInContainer wire (opcode 0x0019, item/container/placement) — extend InteractRequestsTests (the builder is already covered; assert the wrapper path if a seam exists, else rely on the builder test).
  • App (InventoryControllerTests): the handler — extend the fake-layout harness with a SendPutItemInContainer capture.
    • drop on empty grid slot → MoveItem(item, openContainer, append) locally + wire fired with the open container.
    • drop on occupied slot N → wire placement == N (insert-before); local grid shows the item at N.
    • drop on a side-bag cell → wire container == bagGuid.
    • OnDragOver: grid → accept; full side bag → reject; closed bag → accept.
    • rollback: simulate InventoryServerSaveFailed → item returns to its pre-move container/slot.
  • Core: a pending-move rollback unit (record pre-move, restore on failure) if the mover lives in Core.

Acceptance

  • Decomp anchors cited; divergence rows added same-commit.
  • dotnet build + full dotnet test green.
  • Visual gate (instant): drag an item onto an empty slot → it lands (first empty) the instant you release; onto an item → inserts before it; onto a side bag → goes in; a full bag shows the red circle and bounces; a valid target shows the green insert-arrow. WireMCP confirms PutItemInContainer 0x0019 + the 0x0022 echo (and a 0x00A0 bounce on a full bag).
  • SSOT + roadmap updated.

Open verification (at the gate)

  • Pin the green-arrow / red-circle sprite ids by dat-export + the visual gate.
  • Confirm ACE's placement interpretation (insert-before-N vs append) against a live capture; adjust the placement mapping if ACE packs differently.