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>
9.4 KiB
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:
- on an empty grid slot → the item goes to the first empty slot of the open pack (pack-to-front),
- on an occupied item → insert before it (the rest shift down),
- 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.IsDragSourcewhen occupied,SourceKind = Inventory). InventoryControlleras the drop handler (IItemListDragHandler) on the contents grid + the side-bag list.- The three drop placements (goal 1–3) →
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 cell → accept unless that bag is known-full (
GetContents(bag).Count >= ItemsCapacitywhen 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:- 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.
- grid empty slot →
- If the target is known-full (the reject case) → no-op (the red overlay already showed; nothing moves).
- Else: record the item's pre-move
(ContainerId, ContainerSlot)in a pending-move map;_objects.MoveItem(item, targetContainer, placement)locally (instant repaint viaObjectMoved→Concerns→Populate);SendPutItemInContainer(item, targetContainer, placement).
- Resolve
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, thenMoveItem(firesObjectMoved→ instant repaint). Called byHandleDropRelease.ConfirmMove(itemId)— clear the pending entry (the move stuck).RollbackMove(itemId)—MoveItemback to the snapshotted(container, slot), then clear. Returns false if no pending entry.
Wiring (GameEventWiring):
- Reconcile (success): the
InventoryPutObjInContainer 0x0022echo already routes toMoveItem(no-op/correction for a move we initiated) — additems.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
OnDragLiftno-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:
SendPutItemInContainerwire (opcode0x0019, item/container/placement) — extendInteractRequestsTests(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 aSendPutItemInContainercapture.- 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.
- drop on empty grid 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+ fulldotnet testgreen.- 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+ the0x0022echo (and a0x00A0bounce 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
placementinterpretation (insert-before-N vs append) against a live capture; adjust the placement mapping if ACE packs differently.