diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 2b7a4dc9..390d7ef5 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -155,7 +155,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
-| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
| AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 |
| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation |
| AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 17e0e753..fb0e1baa 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -496,6 +496,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display.
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
- **Wave 4.5 implemented — retail primary-weapon switching (corrective live gate pending).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, followed when necessary by an incompatible shield or mismatched ammo; each blocker waits for authoritative `InventoryPutObjInContainer` before the next step, then `GetAndWieldItem` equips the requested bow/sword/caster/two-hander. The transaction remains inventory-busy through the exact authoritative `WieldObject` acknowledgement, independent of the object table's rollback counter, matching retail's waiting state and preventing rapid switches from overlapping or remaining stuck. `CreateObject` retains exact `AmmoType` for compatibility. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`.
+- **Wave 4 inventory drag visuals implemented — live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path; shortcut aliases remain unghosted. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
diff --git a/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md b/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md
new file mode 100644
index 00000000..478849e4
--- /dev/null
+++ b/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md
@@ -0,0 +1,90 @@
+# Retail item drag visuals — pseudocode
+
+Date: 2026-07-13
+
+Scope: the cursor graphic and source-cell visual while dragging an item from an
+item list. This is a focused continuation of
+`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
+
+## Retail anchors
+
+- `IconData::RenderIcons` @ `0x0058d180`
+ (`named-retail/acclient_2013_pseudo_c.txt:407524`)
+- `UIElement_ItemList::PrepareDragIcon` @ `0x004e2a50`
+ (`named-retail/acclient_2013_pseudo_c.txt:230765`)
+- `UIElement_ItemList::ItemList_BeginDrag` @ `0x004e32d0`
+ (`named-retail/acclient_2013_pseudo_c.txt:231350`)
+- `UIElement_UIItem::SetWaitingState` @ `0x004e11b0`
+ (`named-retail/acclient_2013_pseudo_c.txt:229190`)
+- `ACCWeenieObject::SetWaitingState` @ `0x0058c0d0`
+ (`named-retail/acclient_2013_pseudo_c.txt:406260`)
+- `UIElement_UIItem` layout child `m_elem_Icon_Ghosted = 0x10000349`;
+ the shared UIItem catalog `0x21000037` gives it DirectState surface
+ `0x0600109A`.
+
+The live DAT catalog and the named retail code agree. The in-tree WorldBuilder
+reference has no retained UI/item-list implementation to compare for this path.
+
+## Literal pseudocode
+
+```text
+IconData.RenderIcons(item):
+ destroy old m_pIcon and m_pDragIcon
+
+ dragSurface = transparent 32 x 32
+ blit item.baseIcon onto dragSurface using normal blit
+ blit item.customOverlay onto dragSurface using alpha blit
+ replace pure-white dragSurface pixels from the effect-color surface
+ m_pDragIcon = Graphic(dragSurface)
+
+ cellSurface = transparent 32 x 32
+ blit typeDefaultUnderlay onto cellSurface using normal blit
+ blit item.customUnderlay onto cellSurface using alpha blit
+ blit dragSurface onto cellSurface using alpha blit
+ m_pIcon = Graphic(cellSurface)
+
+ItemList.PrepareDragIcon(cell):
+ clear cell.m_dragIcon image
+ object = GetWeenieObject(cell.itemID)
+ set cell.m_dragIcon image to object.GetDragIcon() # m_pDragIcon, not m_pIcon
+
+ItemList.ItemList_BeginDrag(cell):
+ if PrepareDragIcon(cell) failed:
+ return
+ if list is not vendor, salvage, or shortcut list:
+ cell.SetWaitingState(true)
+ StartDragAndDrop(cell.m_dragIcon, hotspot = 16,16)
+
+UIItem.SetWaitingState(waiting):
+ object.SetWaitingState(waiting)
+ if cell is not unghostable:
+ set m_elem_Icon_Ghosted visible = waiting
+
+on drag release/cancel:
+ clear source waiting state
+ hide m_elem_Icon_Ghosted
+```
+
+## Port mapping
+
+- `IconComposer` caches the underlay-free composite separately and exposes
+ `GetDragIcon`; the ordinary `GetIcon` still layers the two underlays beneath
+ the same cached drag pixels.
+- `UiItemSlot` stores `IconTexture` and `DragIconTexture` separately.
+ `GetDragGhost` returns the latter, while `OnDraw` keeps the former in the
+ source cell.
+- `UiRoot` owns the generic source-active lifecycle, including release outside
+ UI and source-subtree removal. `UiItemSlot` maps that lifecycle to the retail
+ waiting visual for physical lists; shortcut aliases remain unghosted.
+- `UiItemSlot.WaitingSprite` is the authored `0x0600109A` mesh, not a procedural
+ tint.
+
+## Conformance coverage
+
+- `DragDropSpineTests.GetDragGhost_prefersDedicatedUnderlayFreeTexture`
+- `DragDropSpineTests.InventoryDrag_ghostsSourceUntilRelease`
+- `DragDropSpineTests.ShortcutDrag_doesNotGhostSource`
+- `UiItemSlotTests.DefaultWaitingSprite_isRetailGhostMesh`
+- Existing `IconComposerTests.TwoStageWithEffect_copiesTilePixelBeforeUnderlay`
+ locks the two-stage composition order.
+
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5a0ab1d3..2cb858f6 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2146,6 +2146,7 @@ public sealed class GameWindow : IDisposable
Objects,
() => Shortcuts,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
+ (type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
UseItemByGuid,
Combat,
ItemMana,
@@ -2171,6 +2172,7 @@ public sealed class GameWindow : IDisposable
Objects,
() => _playerServerGuid,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
+ (type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs
index 2ff95019..eb15341f 100644
--- a/src/AcDream.App/UI/IconComposer.cs
+++ b/src/AcDream.App/UI/IconComposer.cs
@@ -34,6 +34,9 @@ public sealed class IconComposer
private readonly DatCollection _dats;
private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
+ private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
+
+ private sealed record ComposedIcon(byte[] Rgba, int Width, int Height, uint Texture);
// ── type-default underlay resolve (EnumIDMap 0x10000004) ─────────────────
// Portal MasterMap (0x25000000) maps enum 0x10000004 → submap DID (0x25000008).
@@ -225,32 +228,15 @@ public sealed class IconComposer
if (_byTuple.TryGetValue(key, out var tex)) return tex;
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
- var dragLayers = new List<(byte[] rgba, int w, int h)>();
- AddLayer(dragLayers, iconId);
- AddLayer(dragLayers, overlayId);
- (byte[] rgba, int w, int h)? drag = null;
- if (dragLayers.Count > 0)
- {
- var composed = Compose(dragLayers);
- // Effect recolor — ALWAYS, matching retail IconData::RenderIcons (0x0058d180):
- // the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null
- // even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5). Retail's RenderIcons
- // calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying
- // the textured effect tile per-pixel into the icon's pure-white pixels — so
- // magical items take the tile's GRADIENT hue and mundane items go solid black.
- // (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a
- // gradient, not a flat tint, and the no-mana scroll's edges are black.)
- if (TryGetEffectTile(effects, out var tile))
- ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
- tile.Rgba8, tile.Width, tile.Height);
- drag = composed;
- }
+ // RenderIcons retains this as a distinct Graphic because the cursor ghost must not
+ // carry the type/custom underlay that fills an inventory cell.
+ ComposedIcon? drag = GetOrCreateDragIcon(iconId, overlayId, effects);
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, typeUnderlayDid);
AddLayer(layers, underlayId);
- if (drag is { } d) layers.Add(d);
+ if (drag is not null) layers.Add((drag.Rgba, drag.Width, drag.Height));
if (layers.Count == 0) return 0;
var (rgba, w, h) = Compose(layers);
@@ -259,6 +245,50 @@ public sealed class IconComposer
return handle;
}
+ ///
+ /// Resolve retail's dedicated cursor-drag graphic (IconData::m_pDragIcon): base icon
+ /// + custom overlay + effect recolor, with neither the type-default nor custom underlay.
+ /// UIElement_ItemList::PrepareDragIcon obtains exactly this graphic through
+ /// ACCWeenieObject::GetDragIcon (0x004e2a50 / 0x0058d180).
+ ///
+ public uint GetDragIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
+ {
+ // itemType/underlayId are deliberately unused: keeping the resolver signature identical
+ // to GetIcon lets every item-panel binding request the two retail siblings from one model.
+ _ = itemType;
+ _ = underlayId;
+ return iconId == 0 ? 0u : GetOrCreateDragIcon(iconId, overlayId, effects)?.Texture ?? 0u;
+ }
+
+ private ComposedIcon? GetOrCreateDragIcon(uint iconId, uint overlayId, uint effects)
+ {
+ var key = (iconId, overlayId, effects);
+ if (_dragByTuple.TryGetValue(key, out var cached)) return cached;
+
+ var dragLayers = new List<(byte[] rgba, int w, int h)>();
+ AddLayer(dragLayers, iconId);
+ AddLayer(dragLayers, overlayId);
+ if (dragLayers.Count == 0) return null;
+
+ var composed = Compose(dragLayers);
+ // Effect recolor — ALWAYS, matching retail IconData::RenderIcons (0x0058d180):
+ // the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null
+ // even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5). Retail's RenderIcons
+ // calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying
+ // the textured effect tile per-pixel into the icon's pure-white pixels — so
+ // magical items take the tile's GRADIENT hue and mundane items go solid black.
+ // (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a
+ // gradient, not a flat tint, and the no-mana scroll's edges are black.)
+ if (TryGetEffectTile(effects, out var tile))
+ ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
+ tile.Rgba8, tile.Width, tile.Height);
+
+ uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
+ var created = new ComposedIcon(composed.rgba, composed.w, composed.h, texture);
+ _dragByTuple[key] = created;
+ return created;
+ }
+
private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId)
{
if (renderSurfaceId == 0) return;
diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs
index 6f8efcb4..bb30d5d6 100644
--- a/src/AcDream.App/UI/Layout/InventoryController.cs
+++ b/src/AcDream.App/UI/Layout/InventoryController.cs
@@ -41,6 +41,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly ClientObjectTable _objects;
private readonly Func _playerGuid;
private readonly Func _iconIds;
+ private readonly Func? _dragIconIds;
private readonly Func _strength;
private readonly Func? _ownerName;
@@ -74,6 +75,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
ClientObjectTable objects,
Func playerGuid,
Func iconIds,
+ Func? dragIconIds,
Func strength,
SelectionState selection,
Func? ownerName,
@@ -94,6 +96,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
+ _dragIconIds = dragIconIds;
_strength = strength;
_ownerName = ownerName;
_sendUse = sendUse;
@@ -214,8 +217,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action? notifyMergeAttempt = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null,
- StackSplitQuantityState? stackSplitQuantity = null)
- => new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
+ StackSplitQuantityState? stackSplitQuantity = null,
+ Func? dragIconIds = null)
+ => new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
@@ -315,7 +319,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
_topContainer.Flush();
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
- main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
+ main.SetItem(
+ p,
+ _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u),
+ dragIconTexture: _dragIconIds?.Invoke(
+ ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u);
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.Clicked = () =>
{
@@ -351,8 +359,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
var item = _objects.Get(guid);
uint tex = item is null ? 0u
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
+ uint dragTex = item is null ? 0u
+ : _dragIconIds?.Invoke(
+ item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
- cell.SetItem(guid, tex);
+ cell.SetItem(guid, tex, dragIconTexture: dragTex);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
cell.DragRejectSprite = 0x060011F8u; // red circle
@@ -400,7 +411,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
/// Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot
- /// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.
+ /// while reveals retail's waiting/ghosted mesh until release.
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs
index d825ae0f..f5b7ea0a 100644
--- a/src/AcDream.App/UI/Layout/PaperdollController.cs
+++ b/src/AcDream.App/UI/Layout/PaperdollController.cs
@@ -78,6 +78,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
private readonly ClientObjectTable _objects;
private readonly Func _playerGuid;
private readonly Func _iconIds;
+ private readonly Func? _dragIconIds;
private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly ItemInteractionController? _itemInteraction;
private readonly SelectionState _selection;
@@ -96,9 +97,11 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
Func iconIds, SelectionState selection,
Action? sendWield,
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction,
- PaperdollClickMap? clickMap)
+ PaperdollClickMap? clickMap,
+ Func? dragIconIds)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
+ _dragIconIds = dragIconIds;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_clickMap = clickMap;
@@ -195,10 +198,11 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
Action? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null,
- PaperdollClickMap? clickMap = null)
+ PaperdollClickMap? clickMap = null,
+ Func? dragIconIds = null)
=> new PaperdollController(
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
- datFont, itemInteraction, clickMap);
+ datFont, itemInteraction, clickMap, dragIconIds);
private void HandleDollClick(int x, int y)
{
@@ -256,7 +260,9 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
if (worn is null) { list.Cell.Clear(); continue; }
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
- list.Cell.SetItem(worn.ObjectId, tex);
+ uint dragTex = _dragIconIds?.Invoke(
+ worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u;
+ list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex);
}
ApplySelectionIndicators();
}
diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs
index dc3bb419..c1d6073f 100644
--- a/src/AcDream.App/UI/Layout/ToolbarController.cs
+++ b/src/AcDream.App/UI/Layout/ToolbarController.cs
@@ -64,6 +64,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly CombatState? _combatState;
private readonly Func> _shortcuts;
private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
+ private readonly Func? _dragIconIds;
private readonly Action _useItem; // guid → fire UseObject
private readonly ShortcutStore _store = new();
private bool _storeLoaded;
@@ -107,12 +108,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Func? selectedObjectId = null,
Func? playerGuid = null,
Action? sendPutItemInContainer = null,
- UiDatFont? ammoFont = null)
+ UiDatFont? ammoFont = null,
+ Func? dragIconIds = null)
{
_repo = repo;
_combatState = combatState;
_shortcuts = shortcuts;
_iconIds = iconIds;
+ _dragIconIds = dragIconIds;
_useItem = useItem;
_peaceDigits = peaceDigits;
_warDigits = warDigits;
@@ -322,12 +325,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Func? selectedObjectId = null,
Func? playerGuid = null,
Action? sendPutItemInContainer = null,
- UiDatFont? ammoFont = null)
+ UiDatFont? ammoFont = null,
+ Func? dragIconIds = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
- selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont);
+ selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont,
+ dragIconIds);
c.Populate();
return c;
}
@@ -401,7 +406,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
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(guid, tex, entry);
+ uint dragTex = _dragIconIds?.Invoke(
+ item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
+ list.Cell.SetItem(guid, tex, entry, dragTex);
}
// Re-stamp slot number labels after any item change.
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index e4343b97..cedeb10a 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -46,6 +46,7 @@ public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func> Shortcuts,
Func ResolveIcon,
+ Func ResolveDragIcon,
Action UseItem,
CombatState Combat,
ItemManaState ItemMana,
@@ -72,6 +73,7 @@ public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func PlayerGuid,
Func ResolveIcon,
+ Func ResolveDragIcon,
Func Strength,
Action? SendUse,
Action? SendNoLongerViewing,
@@ -404,7 +406,8 @@ public sealed class RetailUiRuntime : IDisposable
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer,
- ammoFont: _bindings.Assets.DefaultFont);
+ ammoFont: _bindings.Assets.DefaultFont,
+ dragIconIds: b.ResolveDragIcon);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
SelectedObjectController = Layout.SelectedObjectController.Bind(
layout,
@@ -708,10 +711,12 @@ public sealed class RetailUiRuntime : IDisposable
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
- StackSplitQuantity);
+ StackSplitQuantity,
+ b.ResolveDragIcon);
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
- contents, _bindings.Assets.DefaultFont, b.ItemInteraction, paperdollClickMap);
+ contents, _bindings.Assets.DefaultFont, b.ItemInteraction, paperdollClickMap,
+ b.ResolveDragIcon);
Host.WindowManager.AttachController(
WindowNames.Inventory,
new RetainedPanelControllerGroup(inventory, paperdoll));
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index 05e62297..1b4317ed 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -357,6 +357,14 @@ public abstract class UiElement
/// item-agnostic. Retail analog: m_dragIcon (decomp 229738).
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
+ ///
+ /// Notifies the source widget when the root starts or finishes carrying its drag payload.
+ /// This is a retained-widget lifecycle hook rather than item-specific root logic. Retail's
+ /// item implementation uses it to show/hide m_elem_Icon_Ghosted around a physical
+ /// item drag (ItemList_BeginDrag 0x004e32d0; SetWaitingState 0x004e11b0).
+ ///
+ internal virtual void SetDragSourceActive(bool active, object? payload) { }
+
///
/// Tooltip text for this widget. Retail fires event 0x07 after the configured
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index c54945d9..013698b5 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -22,6 +22,13 @@ public sealed class UiItemSlot : UiElement
/// Pre-composited icon GL texture for the bound item (0 = none).
public uint IconTexture { get; private set; }
+ ///
+ /// Underlay-free cursor graphic for the bound item (retail IconData::m_pDragIcon).
+ /// Falls back to only for callers that have not supplied the
+ /// dedicated composite.
+ ///
+ public uint DragIconTexture { get; private set; }
+
///
/// Lossless shortcut record when this cell belongs to the toolbar. Physical item
/// lists leave it null. Snapshotted into the drag payload before remove-on-lift.
@@ -83,17 +90,39 @@ public sealed class UiItemSlot : UiElement
/// paperdoll equip slots can use their per-slot silhouettes later.
public uint EmptySprite { get; set; } = 0x060074CFu;
+ ///
+ /// Authored grey mesh shown while a physical item is being carried by drag-and-drop.
+ /// Retail m_elem_Icon_Ghosted (element 0x10000349, DirectState 0x0600109A),
+ /// toggled by UIElement_UIItem::SetWaitingState @ 0x004e11b0.
+ ///
+ public uint WaitingSprite { get; set; } = 0x0600109Au;
+
+ private bool _waiting;
+ internal bool WaitingVisual => _waiting;
+
/// RenderSurface id -> (GL texture, w, h). Set by the factory/controller.
public Func? SpriteResolve { get; set; }
- public void SetItem(uint itemId, uint iconTexture, ShortcutEntry? shortcut = null)
+ public void SetItem(
+ uint itemId,
+ uint iconTexture,
+ ShortcutEntry? shortcut = null,
+ uint dragIconTexture = 0)
{
ItemId = itemId;
IconTexture = iconTexture;
+ DragIconTexture = dragIconTexture;
Shortcut = shortcut;
}
- public void Clear() { ItemId = 0; IconTexture = 0; Shortcut = null; }
+ public void Clear()
+ {
+ ItemId = 0;
+ IconTexture = 0;
+ DragIconTexture = 0;
+ Shortcut = null;
+ _waiting = false;
+ }
///
public override object? GetDragPayload()
@@ -101,7 +130,22 @@ public sealed class UiItemSlot : UiElement
///
public override (uint tex, int w, int h)? GetDragGhost()
- => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
+ {
+ if (ItemId == 0) return null;
+ // RenderIcons creates m_pDragIcon on a fixed 0x20 × 0x20 surface, even when its
+ // containing bag cell is 36 × 36. The cursor hotspot is correspondingly (16,16).
+ if (DragIconTexture != 0) return (DragIconTexture, 32, 32);
+ return IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
+ }
+
+ ///
+ internal override void SetDragSourceActive(bool active, object? payload)
+ {
+ // ItemList_BeginDrag ghosts physical lists, but explicitly excludes shortcut lists
+ // (along with vendor/salvage lists, which acdream does not model as ItemDragSource).
+ // Keep the source's full cell icon in place and reveal the authored grey mesh over it.
+ _waiting = active && ItemId != 0 && SourceKind != ItemDragSource.ShortcutBar;
+ }
/// An OCCUPIED slot is a drag source — a press-and-move picks up the item
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
@@ -311,6 +355,16 @@ public sealed class UiItemSlot : UiElement
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
+ // Pending/drag-source mesh — retail UIElement_UIItem::SetWaitingState (0x004e11b0)
+ // reveals m_elem_Icon_Ghosted (0x10000349). ItemList_BeginDrag (0x004e32d0)
+ // sets it for physical inventory/equipment cells while leaving their icon in place.
+ if (_waiting && SpriteResolve is not null && WaitingSprite != 0)
+ {
+ var (tex, _, _) = SpriteResolve(WaitingSprite);
+ if (tex != 0)
+ ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index aa670921..a706dc2b 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -233,6 +233,7 @@ public sealed class UiRoot : UiElement
Modal = null;
if (IsWithinSubtree(DragSource, subtree))
{
+ DragSource?.SetDragSourceActive(false, DragPayload);
DragSource = null;
DragPayload = null;
_dragGhost = null;
@@ -756,6 +757,7 @@ public sealed class UiRoot : UiElement
DragSource = source;
DragPayload = payload;
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
+ source.SetDragSourceActive(true, payload);
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
@@ -787,16 +789,24 @@ public sealed class UiRoot : UiElement
private void FinishDrag(int x, int y)
{
+ UiElement? source = DragSource;
+ object? payload = DragPayload;
+
+ // Retail's source UIItem receives the release and hides m_elem_Icon_Ghosted
+ // before the target handles the move. Keep this at the root lifecycle boundary so
+ // releases over world space and non-item widgets clear the same state deterministically.
+ source?.SetDragSourceActive(false, payload);
+
var (t, lx, ly) = HitTestTopDown(x, y);
if (t is not null)
{
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
- var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
- Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
+ var e = new UiEvent(source!.EventId, t, UiEventType.DropReleased,
+ Data1: (int)lx, Data2: (int)ly, Payload: payload);
t.OnEvent(in e);
}
- else if (DragPayload is { } payload)
+ else if (payload is not null)
{
DragReleasedOutsideUi?.Invoke(payload, x, y);
}
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
index c9741e45..cf0ea3bf 100644
--- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -76,6 +76,16 @@ public class DragDropSpineTests
Assert.Equal(32, g.Value.h);
}
+ [Fact]
+ public void GetDragGhost_prefersDedicatedUnderlayFreeTexture()
+ {
+ var cell = new UiItemSlot { Width = 36, Height = 36 }; // retail bag cell is larger than its icon
+ cell.SetItem(0x5001u, 0x99u, dragIconTexture: 0x77u);
+
+ Assert.Equal((0x77u, 32, 32), cell.GetDragGhost());
+ Assert.Equal(0x99u, cell.IconTexture); // source cell keeps the full m_pIcon
+ }
+
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
{
@@ -187,6 +197,32 @@ public class DragDropSpineTests
Assert.Null(root.DragSource); // cleaned up
}
+ [Fact]
+ public void InventoryDrag_ghostsSourceUntilRelease()
+ {
+ var (root, _, cell) = RootWithBoundSlot(0x5001u);
+ cell.SourceKind = ItemDragSource.Inventory;
+
+ root.OnMouseDown(UiMouseButton.Left, 10, 10);
+ root.OnMouseMove(20, 10);
+ Assert.True(cell.WaitingVisual);
+
+ root.OnMouseUp(UiMouseButton.Left, 600, 500);
+ Assert.False(cell.WaitingVisual);
+ }
+
+ [Fact]
+ public void ShortcutDrag_doesNotGhostSource()
+ {
+ var (root, _, cell) = RootWithBoundSlot(0x5001u);
+ cell.SourceKind = ItemDragSource.ShortcutBar;
+
+ root.OnMouseDown(UiMouseButton.Left, 10, 10);
+ root.OnMouseMove(20, 10);
+
+ Assert.False(cell.WaitingVisual);
+ }
+
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
// A bound, hit-testable slot inside a list, sized for the hit-test.
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
index ad2d5e8a..08da5cbe 100644
--- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
@@ -12,6 +12,10 @@ public class UiItemSlotTests
public void DefaultEmptySprite_isToolbarBorder()
=> Assert.Equal(0x060074CFu, new UiItemSlot().EmptySprite);
+ [Fact]
+ public void DefaultWaitingSprite_isRetailGhostMesh()
+ => Assert.Equal(0x0600109Au, new UiItemSlot().WaitingSprite);
+
[Fact]
public void Empty_whenNoItem()
{