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() {