fix(ui): port retail item drag visuals
Keep retail's underlay-free m_pDragIcon separate from the full cell icon and reveal the authored ghost mesh on physical source cells for the complete drag lifecycle. This removes the backpack backing from the cursor and retires AP-47. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
3e84027885
commit
ace5880fed
14 changed files with 307 additions and 44 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,12 +228,48 @@ 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.
|
||||
// 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 not null) layers.Add((drag.Rgba, drag.Width, drag.Height));
|
||||
if (layers.Count == 0) return 0;
|
||||
|
||||
var (rgba, w, h) = Compose(layers);
|
||||
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
|
||||
_byTuple[key] = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve retail's dedicated cursor-drag graphic (<c>IconData::m_pDragIcon</c>): base icon
|
||||
/// + custom overlay + effect recolor, with neither the type-default nor custom underlay.
|
||||
/// <c>UIElement_ItemList::PrepareDragIcon</c> obtains exactly this graphic through
|
||||
/// <c>ACCWeenieObject::GetDragIcon</c> (0x004e2a50 / 0x0058d180).
|
||||
/// </summary>
|
||||
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);
|
||||
(byte[] rgba, int w, int h)? drag = null;
|
||||
if (dragLayers.Count > 0)
|
||||
{
|
||||
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
|
||||
|
|
@ -243,20 +282,11 @@ public sealed class IconComposer
|
|||
if (TryGetEffectTile(effects, out var tile))
|
||||
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
|
||||
tile.Rgba8, tile.Width, tile.Height);
|
||||
drag = composed;
|
||||
}
|
||||
|
||||
// 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 (layers.Count == 0) return 0;
|
||||
|
||||
var (rgba, w, h) = Compose(layers);
|
||||
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
|
||||
_byTuple[key] = handle;
|
||||
return handle;
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
|
||||
private readonly Func<int?> _strength;
|
||||
private readonly Func<string>? _ownerName;
|
||||
|
||||
|
|
@ -74,6 +75,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds,
|
||||
Func<int?> strength,
|
||||
SelectionState selection,
|
||||
Func<string>? 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<uint, uint>? notifyMergeAttempt = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action? onClose = null,
|
||||
StackSplitQuantityState? stackSplitQuantity = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
|
||||
StackSplitQuantityState? stackSplitQuantity = null,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? 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) ──────────────
|
||||
/// <summary>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.</summary>
|
||||
/// while <see cref="UiItemSlot"/> reveals retail's waiting/ghosted mesh until release.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
|
||||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly SelectionState _selection;
|
||||
|
|
@ -96,9 +97,11 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||
Action<uint, uint>? sendWield,
|
||||
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction,
|
||||
PaperdollClickMap? clickMap)
|
||||
PaperdollClickMap? clickMap,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? 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<uint, uint>? sendWield = null,
|
||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
PaperdollClickMap? clickMap = null)
|
||||
PaperdollClickMap? clickMap = null,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private readonly CombatState? _combatState;
|
||||
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
|
||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||||
private readonly ShortcutStore _store = new();
|
||||
private bool _storeLoaded;
|
||||
|
|
@ -107,12 +108,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Func<uint>? selectedObjectId = null,
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
UiDatFont? ammoFont = null)
|
||||
UiDatFont? ammoFont = null,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? 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<uint>? selectedObjectId = null,
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
UiDatFont? ammoFont = null)
|
||||
UiDatFont? ammoFont = null,
|
||||
Func<ItemType, uint, uint, uint, uint, uint>? 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.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public sealed record ToolbarRuntimeBindings(
|
|||
ClientObjectTable Objects,
|
||||
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
Action<uint> UseItem,
|
||||
CombatState Combat,
|
||||
ItemManaState ItemMana,
|
||||
|
|
@ -72,6 +73,7 @@ public sealed record InventoryRuntimeBindings(
|
|||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
Func<int?> Strength,
|
||||
Action<uint>? SendUse,
|
||||
Action<uint>? 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));
|
||||
|
|
|
|||
|
|
@ -357,6 +357,14 @@ public abstract class UiElement
|
|||
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
||||
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>m_elem_Icon_Ghosted</c> around a physical
|
||||
/// item drag (<c>ItemList_BeginDrag</c> 0x004e32d0; <c>SetWaitingState</c> 0x004e11b0).
|
||||
/// </summary>
|
||||
internal virtual void SetDragSourceActive(bool active, object? payload) { }
|
||||
|
||||
/// <summary>
|
||||
/// 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"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ public sealed class UiItemSlot : UiElement
|
|||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||
public uint IconTexture { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Underlay-free cursor graphic for the bound item (retail <c>IconData::m_pDragIcon</c>).
|
||||
/// Falls back to <see cref="IconTexture"/> only for callers that have not supplied the
|
||||
/// dedicated composite.
|
||||
/// </summary>
|
||||
public uint DragIconTexture { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.</summary>
|
||||
public uint EmptySprite { get; set; } = 0x060074CFu;
|
||||
|
||||
/// <summary>
|
||||
/// Authored grey mesh shown while a physical item is being carried by drag-and-drop.
|
||||
/// Retail <c>m_elem_Icon_Ghosted</c> (element 0x10000349, DirectState 0x0600109A),
|
||||
/// toggled by <c>UIElement_UIItem::SetWaitingState</c> @ 0x004e11b0.
|
||||
/// </summary>
|
||||
public uint WaitingSprite { get; set; } = 0x0600109Au;
|
||||
|
||||
private bool _waiting;
|
||||
internal bool WaitingVisual => _waiting;
|
||||
|
||||
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetDragPayload()
|
||||
|
|
@ -101,7 +130,22 @@ public sealed class UiItemSlot : UiElement
|
|||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue