feat(ui): the spell-bar drop ring — retail's authored drag-accept state, and the ring exposed a real drop off-by-one
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
The green ring is retail's own art: every UIItem cell carries an authored DragAccept child (catalog 0x21000037, child 0x1000045A), and the spell bar's drag-over handler (SpellCastSubMenu::OnItemListDragOver @0x004C5990) flips it to the Accept state (0x10000040 -> surface 0x060011F9) for any spell payload. Ported through a per-slot SetDragAcceptVisual seam + a catalog DragOverAcceptance hook; other lists are untouched (null acceptance = neutral). A polarity error in our older docs (Accept/Reject state ids swapped) was corrected against three independent sources; the shipped art was always right, only the labels lied. The ring shares ONE landing computation with the drop (FavoriteDropIndex) — and that requirement exposed a genuine #354 off-by-one: the empty-tail path double-applied the -1 adjustment (retail gates it on the lift's removal @0x004C7157), landing a reordered spell second-to-last instead of last. Fixed; discriminator-verified both ways. AP-172 narrowed + its false empty-tail claim corrected. Clean-room complete solution: 11,545 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
81a9d85a1d
commit
6bb4cfa795
9 changed files with 434 additions and 34 deletions
|
|
@ -60,6 +60,36 @@ bytes for `AddSpellFavorite`/`RemoveSpellFavorite` (opcodes 0x1E3/0x1E4) and
|
||||||
`RuntimeCharacterState.TryAddFavorite`/`TryRemoveFavorite` were already
|
`RuntimeCharacterState.TryAddFavorite`/`TryRemoveFavorite` were already
|
||||||
covered and needed no change.
|
covered and needed no change.
|
||||||
|
|
||||||
|
**UPDATE 2026-08-08 (follow-up session, drop-ring gate finding):** the user's
|
||||||
|
next gate finding — "Should be the green ring indicator where the spell icon
|
||||||
|
should land, like in retail" — is implemented. Retail's mechanism (grepped and
|
||||||
|
byte-confirmed): the ring is a per-cell authored slot STATE, not a synthetic
|
||||||
|
overlay — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 sets the shared
|
||||||
|
UIItem prototype's `m_elem_Icon_DragAccept` child (element 0x1000045A, bound in
|
||||||
|
`UIElement_UIItem::PostInit` @0x004E1870, catalog LayoutDesc 0x21000037) to
|
||||||
|
`ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9)
|
||||||
|
whenever the dragged payload carries a spell id; leave resets to
|
||||||
|
`ItemSlot_DragOver_Normal` 0x1000003F @0x004E1438. Wired through
|
||||||
|
`UiCatalogSlot.DragOverAcceptance` (the catalog port of retail's per-list drag
|
||||||
|
handler) → the shared `UiItemSlot.DrawDragAcceptOverlay`. Two corrections
|
||||||
|
landed with it: (1) the accept/reject UIStateId labels were SWAPPED in
|
||||||
|
`UiItemSlot`/`InventoryController` comments and in the 2026-06-16 / 2026-07-13
|
||||||
|
research docs (art-per-semantic was always right; polarity pinned by paperdoll
|
||||||
|
`AutoWearIsLegal` @0x004A3AC9/0x004A3AEB, `VendorSellUI` @0x004C2327/0x004C2336,
|
||||||
|
DatReaderWriter's `UIStateId` enum, and the 2026-06-25 layout dump); (2) a real
|
||||||
|
off-by-one in #354's drop path: the `-1` adjustment double-corrected the
|
||||||
|
empty-tail cell's live-count-clamped index, landing a lifted non-last favorite
|
||||||
|
second-to-last instead of last — retail's adjustment is gated on
|
||||||
|
`RemoveSpellFromMenu`'s return (@0x004C7157), which is `-1` (no adjustment) at
|
||||||
|
drop time because the spell left the live list at lift. `FavoriteDropIndex` is
|
||||||
|
now THE one landing computation shared by the ring and the drop
|
||||||
|
(discriminator-verified: the pre-fix computation fails
|
||||||
|
`SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` with landed index 1
|
||||||
|
vs 2). AP-172 narrowed + corrected in the same change-set. New tests: ring
|
||||||
|
appears/tracks/survives-a-tick/clears-on-drop, empty-tail append, ring clears
|
||||||
|
on leave + off-bar release keeps the lift removal, physical payloads stay
|
||||||
|
neutral while both spell payload kinds ring.
|
||||||
|
|
||||||
## #353 — Toolbar selected-object text: count field ignores authored HJustify; name field does not wrap to its authored two lines
|
## #353 — Toolbar selected-object text: count field ignores authored HJustify; name field does not wrap to its authored two lines
|
||||||
|
|
||||||
**Status:** CLOSED 2026-08-08 — user-passed ("Ok slider bar looks ok!" + the wrap confirmed); the OneLine routing fix (4cfcc8b3) completed it. (RightAligned on the authored HJustify=2 entry; two stacked centered one-line labels wrapping at the authored 140 px via WrapNameTwoLines).
|
**Status:** CLOSED 2026-08-08 — user-passed ("Ok slider bar looks ok!" + the wrap confirmed); the OneLine routing fix (4cfcc8b3) completed it. (RightAligned on the authored HJustify=2 entry; two stacked centered one-line labels wrapping at the authored 140 px via WrapNameTwoLines).
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -141,6 +141,19 @@ internal element states `SetDragAcceptState` writes — both are real; the Layou
|
||||||
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
|
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
|
||||||
the C++ side. CONFIRMED.
|
the C++ side. CONFIRMED.
|
||||||
|
|
||||||
|
> **Correction 2026-08-08 (spell-bar drop-ring research):** the parenthetical
|
||||||
|
> above has the accept/reject ids SWAPPED. The true mapping is
|
||||||
|
> `ItemSlot_DragOver_Accept = 0x10000040 → 0x060011F9` and
|
||||||
|
> `ItemSlot_DragOver_Reject = 0x10000041 → 0x060011F8`, confirmed by
|
||||||
|
> DatReaderWriter's retail-derived `UIStateId` enum, by the legal/illegal
|
||||||
|
> branches of `gmPaperDollUI::HandlePaperDollDragOver` @ 0x004A3AC9/0x004A3AEB
|
||||||
|
> and `VendorSellUI::OnItemListDragOver` @ 0x004C2327/0x004C2336, and by the
|
||||||
|
> machine layout dump (`2026-06-25-retail-ui-layout-dump.json`, states
|
||||||
|
> 268435520/268435521 on elements 0x1000046D/0x1000046C). The table row's
|
||||||
|
> name→art column above was always right; only this paragraph's numeric
|
||||||
|
> pairing was inverted (and propagated into
|
||||||
|
> `2026-07-13-retail-item-drag-visuals-pseudocode.md`, corrected the same day).
|
||||||
|
|
||||||
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
|
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
|
||||||
|
|
||||||
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound
|
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,28 @@ if target list is a container selector
|
||||||
target.SetDragAcceptState(0x10000046) # ItemSlot_DragOver_DropIn
|
target.SetDragAcceptState(0x10000046) # ItemSlot_DragOver_DropIn
|
||||||
# 0x060011F7 green arrow
|
# 0x060011F7 green arrow
|
||||||
else if target accepts an ordinary item-list placement:
|
else if target accepts an ordinary item-list placement:
|
||||||
target.SetDragAcceptState(0x10000041) # ItemSlot_DragOver_Accept
|
target.SetDragAcceptState(0x10000040) # ItemSlot_DragOver_Accept
|
||||||
# 0x060011F9 green circle
|
# 0x060011F9 green circle
|
||||||
else:
|
else:
|
||||||
target.SetDragAcceptState(0x10000040) # 0x060011F8 reject
|
target.SetDragAcceptState(0x10000041) # ItemSlot_DragOver_Reject
|
||||||
|
# 0x060011F8 reject
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Correction 2026-08-08 (spell-bar drop-ring research):** the block above
|
||||||
|
> originally had the Accept/Reject numeric ids swapped (`0x10000041` labeled
|
||||||
|
> Accept, `0x10000040` labeled reject). Three primary sources agree the true
|
||||||
|
> mapping is `ItemSlot_DragOver_Accept = 0x10000040 → 0x060011F9` and
|
||||||
|
> `ItemSlot_DragOver_Reject = 0x10000041 → 0x060011F8`: DatReaderWriter's
|
||||||
|
> retail-derived `UIStateId` enum; the legal/illegal branches in
|
||||||
|
> `gmPaperDollUI::HandlePaperDollDragOver` (`AutoWearIsLegal` → 0x10000040
|
||||||
|
> @ 0x004A3AC9, else 0x10000041 @ 0x004A3AEB) and
|
||||||
|
> `VendorSellUI::OnItemListDragOver` (`DragItemAcceptable` → 0x10000040
|
||||||
|
> @ 0x004C2327, else 0x10000041 @ 0x004C2336); and the machine layout dump
|
||||||
|
> (`2026-06-25-retail-ui-layout-dump.json`: state 268435520 = 0x10000040 →
|
||||||
|
> image 0x060011F9, state 268435521 = 0x10000041 → 0x060011F8). The
|
||||||
|
> art-per-semantic mapping in the shipped code was always correct; only the
|
||||||
|
> numeric labels here were swapped.
|
||||||
|
|
||||||
Therefore the backpack contents grid uses the green circle; the side-bag column
|
Therefore the backpack contents grid uses the green circle; the side-bag column
|
||||||
and main-pack container cell use the green drop-in arrow. The selected/open
|
and main-pack container cell use the green drop-in arrow. The selected/open
|
||||||
indicators remain visible while `m_elem_Icon_Ghosted` is active, so the
|
indicators remain visible while `m_elem_Icon_Ghosted` is active, so the
|
||||||
|
|
|
||||||
|
|
@ -527,7 +527,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind the exact ItemList_DragOver state for this destination. A normal contents-grid
|
/// Bind the exact ItemList_DragOver state for this destination. A normal contents-grid
|
||||||
/// insertion uses ItemSlot_DragOver_Accept (0x10000041 → green circle 0x060011F9).
|
/// insertion uses ItemSlot_DragOver_Accept (UIStateId 0x10000040 → green circle 0x060011F9).
|
||||||
/// An occupied container selector uses ItemSlot_DragOver_DropIn
|
/// An occupied container selector uses ItemSlot_DragOver_DropIn
|
||||||
/// (0x10000046 → green arrow 0x060011F7). Retail: 0x004e3400.
|
/// (0x10000046 → green arrow 0x060011F7). Retail: 0x004e3400.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
uint id = spellId;
|
uint id = spellId;
|
||||||
int position = list.GetNumUIItems();
|
int position = list.GetNumUIItems();
|
||||||
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
|
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
|
||||||
var slot = new UiCatalogSlot
|
UiCatalogSlot? slot = null;
|
||||||
|
slot = new UiCatalogSlot
|
||||||
{
|
{
|
||||||
EntryId = id,
|
EntryId = id,
|
||||||
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
|
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
|
||||||
|
|
@ -358,10 +359,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
|
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
|
||||||
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
|
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
|
||||||
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
|
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
|
||||||
|
DragOverAcceptance = FavoriteDragOverAcceptance,
|
||||||
Dropped = payload =>
|
Dropped = payload =>
|
||||||
{
|
{
|
||||||
if (payload is SpellFavoriteDragPayload favorite)
|
if (payload is SpellFavoriteDragPayload favorite)
|
||||||
DropFavorite(favorite, targetTab, position);
|
DropFavorite(favorite, targetTab, list, slot!);
|
||||||
else if (payload is SpellbookShortcutDragPayload shortcut)
|
else if (payload is SpellbookShortcutDragPayload shortcut)
|
||||||
DropSpellbookShortcut(shortcut, targetTab, position);
|
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||||
},
|
},
|
||||||
|
|
@ -386,15 +388,17 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
slot = new UiCatalogSlot
|
slot = new UiCatalogSlot
|
||||||
{
|
{
|
||||||
SpriteResolve = list.SpriteResolve,
|
SpriteResolve = list.SpriteResolve,
|
||||||
|
DragOverAcceptance = FavoriteDragOverAcceptance,
|
||||||
Dropped = payload =>
|
Dropped = payload =>
|
||||||
{
|
{
|
||||||
int position = Math.Max(0, list.IndexOf(slot!));
|
|
||||||
int favoriteCount = _spellbook.GetFavorites(targetTab).Count;
|
|
||||||
position = Math.Min(position, favoriteCount);
|
|
||||||
if (payload is SpellFavoriteDragPayload favorite)
|
if (payload is SpellFavoriteDragPayload favorite)
|
||||||
DropFavorite(favorite, targetTab, position);
|
DropFavorite(favorite, targetTab, list, slot!);
|
||||||
else if (payload is SpellbookShortcutDragPayload shortcut)
|
else if (payload is SpellbookShortcutDragPayload shortcut)
|
||||||
|
{
|
||||||
|
int position = Math.Max(0, list.IndexOf(slot!));
|
||||||
|
position = Math.Min(position, _spellbook.GetFavorites(targetTab).Count);
|
||||||
DropSpellbookShortcut(shortcut, targetTab, position);
|
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ConfigureShortcutOverlay(slot, shortcutIndex);
|
ConfigureShortcutOverlay(slot, shortcutIndex);
|
||||||
|
|
@ -450,16 +454,54 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
_favoriteDragActive = false;
|
_favoriteDragActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
|
/// <summary>
|
||||||
|
/// SpellCastSubMenu::OnItemListDragOver @ 0x004C5990: while a drag hovers a
|
||||||
|
/// favorite-bar cell (occupied OR empty — retail's tail cells are UIItems in
|
||||||
|
/// the same list), retail sets the authored per-cell DragAccept child
|
||||||
|
/// (element 0x1000045A, bound in UIElement_UIItem::PostInit @ 0x004E1870)
|
||||||
|
/// to ItemSlot_DragOver_Accept (UIStateId 0x10000040 → authored ring
|
||||||
|
/// 0x060011F9) when the dragged payload carries a spell id, and leaves it
|
||||||
|
/// neutral otherwise — the handler returns 1, so the generic physical-item
|
||||||
|
/// fallback @ 0x004E3492 never runs for this list.
|
||||||
|
/// </summary>
|
||||||
|
internal static ItemDragAcceptance FavoriteDragOverAcceptance(object payload)
|
||||||
|
=> payload is SpellFavoriteDragPayload or SpellbookShortcutDragPayload
|
||||||
|
? ItemDragAcceptance.Accept
|
||||||
|
: ItemDragAcceptance.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// THE one favorite-landing computation: the index a
|
||||||
|
/// <see cref="SpellFavoriteDragPayload"/> drop on <paramref name="cell"/>
|
||||||
|
/// applies. The drag-over Accept ring and <see cref="DropFavorite"/> both key
|
||||||
|
/// off the same hovered cell through this method, so the ring can never
|
||||||
|
/// promise a different landing than the drop delivers. Two numbering spaces,
|
||||||
|
/// both matching retail SpellCastSubMenu::AddFavorite @ 0x004C7060:
|
||||||
|
/// an OCCUPIED sibling cell is still numbered against the PRE-lift bar
|
||||||
|
/// (Rebuild defers for the gesture — AP-172), so retail's
|
||||||
|
/// -1-if-removed-from-before-target adjustment applies (the
|
||||||
|
/// RemoveSpellFromMenu-return-gated decrement @ 0x004C7157); an EMPTY tail
|
||||||
|
/// cell's index clamps to the LIVE favorite count — a post-lift number whose
|
||||||
|
/// lifted spell is already out of the live list, exactly retail's
|
||||||
|
/// RemoveSpellFromMenu == -1 no-adjustment case, so applying the -1 there too
|
||||||
|
/// would double-correct (the off-by-one this method retired: lifting a
|
||||||
|
/// non-last favorite onto the empty tail landed it second-to-last instead of
|
||||||
|
/// last).
|
||||||
|
/// </summary>
|
||||||
|
internal int FavoriteDropIndex(
|
||||||
|
SpellFavoriteDragPayload payload, int targetTab, UiItemList list, UiItemSlot cell)
|
||||||
{
|
{
|
||||||
// Rebuild() was deferred for the whole gesture (see BeginFavoriteDrag), so
|
int index = Math.Max(0, list.IndexOf(cell));
|
||||||
// every sibling slot's captured target index is still numbered against the
|
if (cell.IsEmptySlot)
|
||||||
// PRE-lift list. Retail's own SpellCastSubMenu::AddFavorite @ 0x004C7060
|
return Math.Min(index, _spellbook.GetFavorites(targetTab).Count);
|
||||||
// corrects for exactly this staleness: when the lifted item's original
|
if (payload.SourceTab == targetTab && payload.SourcePosition < index)
|
||||||
// index was before the drop target, the target index shifts down by one
|
index -= 1;
|
||||||
// to land where the target visually sits once the gap closes.
|
return index;
|
||||||
if (payload.SourceTab == targetTab && payload.SourcePosition < targetPosition)
|
}
|
||||||
targetPosition -= 1;
|
|
||||||
|
private void DropFavorite(
|
||||||
|
SpellFavoriteDragPayload payload, int targetTab, UiItemList list, UiItemSlot cell)
|
||||||
|
{
|
||||||
|
int targetPosition = FavoriteDropIndex(payload, targetTab, list, cell);
|
||||||
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
|
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
|
||||||
_selected[targetTab] = payload.SpellId;
|
_selected[targetTab] = payload.SpellId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,19 @@ public sealed class UiCatalogSlot : UiItemSlot
|
||||||
public Action<object>? DragEnded { get; init; }
|
public Action<object>? DragEnded { get; init; }
|
||||||
public Action<object>? Dropped { get; init; }
|
public Action<object>? Dropped { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-panel drag-over acceptance — the catalog-cell port of retail's
|
||||||
|
/// per-list drag handler deciding the rollover state
|
||||||
|
/// (<c>UIElement_ItemList::ItemList_DragOver</c> @ 0x004E3400 →
|
||||||
|
/// <c>m_dragHandler->OnItemListDragOver</c>; the spell bar's is
|
||||||
|
/// <c>SpellCastSubMenu::OnItemListDragOver</c> @ 0x004C5990). While a drag
|
||||||
|
/// hovers this cell, the returned acceptance drives the authored
|
||||||
|
/// DragAccept frame (accept 0x060011F9 / reject 0x060011F8). Null (the
|
||||||
|
/// default for every list that never set one) keeps the cell neutral —
|
||||||
|
/// no overlay, exactly the pre-existing presentation.
|
||||||
|
/// </summary>
|
||||||
|
public Func<object, ItemDragAcceptance>? DragOverAcceptance { get; init; }
|
||||||
|
|
||||||
protected override bool IsShortcutOccupied => EntryId != 0u;
|
protected override bool IsShortcutOccupied => EntryId != 0u;
|
||||||
public override bool IsEmptySlot => EntryId == 0u;
|
public override bool IsEmptySlot => EntryId == 0u;
|
||||||
|
|
||||||
|
|
@ -77,9 +90,25 @@ public sealed class UiCatalogSlot : UiItemSlot
|
||||||
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
|
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
|
||||||
return true;
|
return true;
|
||||||
case UiEventType.DragEnter:
|
case UiEventType.DragEnter:
|
||||||
|
// Pointer entered mid-drag: ask the panel's acceptance seam and show
|
||||||
|
// the authored frame (retail ItemList_DragOver → the list handler's
|
||||||
|
// SetDragAcceptState on the hovered UIItem's 0x1000045A child).
|
||||||
|
SetDragAcceptVisual(e.Payload is { } enterPayload
|
||||||
|
? DragOverAcceptance?.Invoke(enterPayload) switch
|
||||||
|
{
|
||||||
|
ItemDragAcceptance.Accept => DragAcceptState.Accept,
|
||||||
|
ItemDragAcceptance.Reject => DragAcceptState.Reject,
|
||||||
|
_ => DragAcceptState.None,
|
||||||
|
}
|
||||||
|
: DragAcceptState.None);
|
||||||
|
return true;
|
||||||
case UiEventType.DragOver:
|
case UiEventType.DragOver:
|
||||||
|
// UiRoot fires DragOver on LEAVE — retail's dwParam1 == 0 reset to
|
||||||
|
// ItemSlot_DragOver_Normal (0x1000003F) @ 0x004E1438-0x004E1456.
|
||||||
|
SetDragAcceptVisual(DragAcceptState.None);
|
||||||
return true;
|
return true;
|
||||||
case UiEventType.DropReleased:
|
case UiEventType.DropReleased:
|
||||||
|
SetDragAcceptVisual(DragAcceptState.None);
|
||||||
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
|
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
|
|
@ -141,6 +170,10 @@ public sealed class UiCatalogSlot : UiItemSlot
|
||||||
if (!string.IsNullOrWhiteSpace(Detail))
|
if (!string.IsNullOrWhiteSpace(Detail))
|
||||||
ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
|
ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drag-rollover frame last — same layering as the physical UIItem draw
|
||||||
|
// (accept/reject above selection; catalog cells have no cooldown layer).
|
||||||
|
DrawDragAcceptOverlay(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawSelection(UiRenderContext ctx)
|
private void DrawSelection(UiRenderContext ctx)
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,13 @@ public class UiItemSlot : UiElement
|
||||||
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
|
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
|
||||||
|
|
||||||
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
|
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
|
||||||
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
|
/// UIStateId 0x10000040 — the state paperdoll AutoWearIsLegal @ 0x004A3AC7 and
|
||||||
|
/// VendorSellUI DragItemAcceptable @ 0x004C2320 set on a LEGAL target; the
|
||||||
|
/// 2026-06-16 deep-dive's prose swapped the 40/41 ids, the art column was right).
|
||||||
|
/// Configurable; guard id != 0 before resolving.</summary>
|
||||||
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
|
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
|
||||||
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
|
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
|
||||||
/// state id 0x10000040).</summary>
|
/// UIStateId 0x10000041 — the illegal-target branch at 0x004A3AEB / 0x004C2336).</summary>
|
||||||
public uint DragRejectSprite { get; set; } = 0x060011F8u;
|
public uint DragRejectSprite { get; set; } = 0x060011F8u;
|
||||||
|
|
||||||
/// <summary>True when this cell is the OPEN container (its contents fill the grid). Draws the
|
/// <summary>True when this cell is the OPEN container (its contents fill the grid). Draws the
|
||||||
|
|
@ -85,6 +88,13 @@ public class UiItemSlot : UiElement
|
||||||
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
|
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
|
||||||
internal DragAcceptState DragAcceptVisual => _dragAccept;
|
internal DragAcceptState DragAcceptVisual => _dragAccept;
|
||||||
|
|
||||||
|
/// <summary>Set the drag-rollover overlay state — the port of retail
|
||||||
|
/// <c>UIElement_UIItem::SetDragAcceptState</c> @ 0x004E1290 flipping the authored
|
||||||
|
/// per-cell <c>m_elem_Icon_DragAccept</c> child (element 0x1000045A, bound in
|
||||||
|
/// <c>PostInit</c> @ 0x004E1870). Subclasses whose drops bypass
|
||||||
|
/// <see cref="IItemListDragHandler"/> (catalog cells) drive the same visual here.</summary>
|
||||||
|
private protected void SetDragAcceptVisual(DragAcceptState state) => _dragAccept = state;
|
||||||
|
|
||||||
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
||||||
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
||||||
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
||||||
|
|
@ -445,19 +455,7 @@ public class UiItemSlot : UiElement
|
||||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
|
DrawDragAcceptOverlay(ctx);
|
||||||
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
|
|
||||||
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
|
|
||||||
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
|
|
||||||
{
|
|
||||||
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
|
|
||||||
if (id != 0)
|
|
||||||
{
|
|
||||||
var (tex, _, _) = SpriteResolve(id);
|
|
||||||
if (tex != 0)
|
|
||||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20 selects exactly
|
// UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20 selects exactly
|
||||||
// one of ten authored radial overlays. The shared prototype gives these
|
// one of ten authored radial overlays. The shared prototype gives these
|
||||||
|
|
@ -482,6 +480,24 @@ public class UiItemSlot : UiElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Draws the drag-rollover accept/reject frame (retail
|
||||||
|
/// <c>SetDragAcceptState</c> writing <c>ItemSlot_DragOver_Accept</c> 0x10000040 /
|
||||||
|
/// <c>_Reject</c> 0x10000041 on the authored 0x1000045A child). Shared with catalog
|
||||||
|
/// cells the same way retail shares the one UIItem prototype (catalog 0x21000037).
|
||||||
|
/// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
|
||||||
|
/// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).</summary>
|
||||||
|
protected void DrawDragAcceptOverlay(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
if (_dragAccept == DragAcceptState.None || SpriteResolve is null)
|
||||||
|
return;
|
||||||
|
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
|
||||||
|
if (id == 0)
|
||||||
|
return;
|
||||||
|
var (tex, _, _) = SpriteResolve(id);
|
||||||
|
if (tex != 0)
|
||||||
|
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
|
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
|
||||||
/// call this after their icon layer so magic favorites and physical shortcuts
|
/// call this after their icon layer so magic favorites and physical shortcuts
|
||||||
/// use the same UIItem presentation path.</summary>
|
/// use the same UIItem presentation path.</summary>
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,256 @@ public sealed class SpellcastingUiControllerTests
|
||||||
Assert.Equal(3u, Assert.IsType<UiCatalogSlot>(list.GetItem(2)).EntryId);
|
Assert.Equal(3u, Assert.IsType<UiCatalogSlot>(list.GetItem(2)).EntryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's retail memory after #354 landed: "the green ring indicator where
|
||||||
|
/// the spell icon should land, like in retail." Retail's mechanism is a slot
|
||||||
|
/// STATE, not a synthetic overlay: while a drag hovers any favorite-bar cell,
|
||||||
|
/// SpellCastSubMenu::OnItemListDragOver @ 0x004C5990 sets the authored
|
||||||
|
/// per-cell DragAccept child (element 0x1000045A, bound in
|
||||||
|
/// UIElement_UIItem::PostInit @ 0x004E1870) to ItemSlot_DragOver_Accept
|
||||||
|
/// (UIStateId 0x10000040 -> authored ring art 0x060011F9) whenever the
|
||||||
|
/// dragged payload carries a spell id; the pointer leaving the cell resets it
|
||||||
|
/// to ItemSlot_DragOver_Normal (0x1000003F) @ 0x004E1438. This drives the
|
||||||
|
/// whole gesture through UiRoot's real pointer pipeline and asserts the ring
|
||||||
|
/// appears on the hovered cell, tracks the pointer, survives a live per-frame
|
||||||
|
/// tick, clears on drop, and -- through the ONE shared computation
|
||||||
|
/// (<see cref="SpellcastingUiController.FavoriteDropIndex"/>) -- never
|
||||||
|
/// promises a landing index different from the one the drop applies.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SpellFavoriteDrag_ShowsAuthoredAcceptRing_TracksPointer_AndNeverLiesAboutTheLanding()
|
||||||
|
{
|
||||||
|
var (controller, spellbook, screen, list, adds, _) = BindPointerFixture();
|
||||||
|
using SpellcastingUiController _1 = controller;
|
||||||
|
UiCatalogSlot slot0 = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||||
|
UiCatalogSlot slot1 = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
|
||||||
|
UiCatalogSlot slot2 = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
|
||||||
|
(int x0, int y0) = CellCenter(slot0);
|
||||||
|
(int x1, int y1) = CellCenter(slot1);
|
||||||
|
(int x2, int y2) = CellCenter(slot2);
|
||||||
|
|
||||||
|
screen.OnMouseDown(UiMouseButton.Left, x0, y0);
|
||||||
|
screen.OnMouseMove(x0 + 10, y0);
|
||||||
|
Assert.Same(slot0, screen.DragSource);
|
||||||
|
|
||||||
|
// Hover spell 3's cell: the ring appears there and nowhere else, and the
|
||||||
|
// cell's accept frame is the authored ring art.
|
||||||
|
screen.OnMouseMove(x2, y2);
|
||||||
|
Assert.Equal(2, SingleAcceptRingIndex(list));
|
||||||
|
Assert.Equal(0x060011F9u, slot2.DragAcceptSprite);
|
||||||
|
|
||||||
|
// A live per-frame tick (production RetailUiRuntime.Tick) must not
|
||||||
|
// destroy the ringed cell -- the #354 deferral keeps the frozen bar.
|
||||||
|
controller.Tick();
|
||||||
|
Assert.Equal(2, SingleAcceptRingIndex(list));
|
||||||
|
|
||||||
|
// The ring tracks the pointer: moving onto spell 2's cell moves it.
|
||||||
|
screen.OnMouseMove(x1, y1);
|
||||||
|
Assert.Equal(1, SingleAcceptRingIndex(list));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.None, slot2.DragAcceptVisual);
|
||||||
|
|
||||||
|
// Back over spell 3's cell; capture what the ring promises through the
|
||||||
|
// one shared computation, then drop there.
|
||||||
|
screen.OnMouseMove(x2, y2);
|
||||||
|
var payload = Assert.IsType<SpellFavoriteDragPayload>(screen.DragPayload);
|
||||||
|
int ringIndex = SingleAcceptRingIndex(list);
|
||||||
|
Assert.Equal(2, ringIndex);
|
||||||
|
int promised = controller.FavoriteDropIndex(payload, 0, list, list.GetItem(ringIndex)!);
|
||||||
|
|
||||||
|
screen.OnMouseUp(UiMouseButton.Left, x2, y2);
|
||||||
|
|
||||||
|
Assert.Null(screen.DragSource);
|
||||||
|
Assert.Equal([(0, promised, 1u)], adds);
|
||||||
|
Assert.Equal(new uint[] { 2u, 1u, 3u }, spellbook.GetFavorites(0));
|
||||||
|
Assert.Equal(-1, SingleAcceptRingIndex(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The empty-tail landing: retail's -1 adjustment in
|
||||||
|
/// SpellCastSubMenu::AddFavorite @ 0x004C7060 is gated on
|
||||||
|
/// RemoveSpellFromMenu's RETURN -- the dragged spell's LIVE list index at
|
||||||
|
/// add-time. At a drag-drop the spell already left the live list at lift
|
||||||
|
/// (RecvNotice_ItemListBeginDrag @ 0x004C7360), so a live-numbered target
|
||||||
|
/// gets NO adjustment. acdream's empty-cell index is clamped to the live
|
||||||
|
/// favorite count (a post-lift number); applying the pre-lift -1 on top of
|
||||||
|
/// that double-corrected: lifting a non-last favorite onto the empty tail
|
||||||
|
/// landed it second-to-last ([2,1,3]) instead of last ([2,3,1]). This drags
|
||||||
|
/// spell 1 onto the first empty cell through the real pointer pipeline and
|
||||||
|
/// pins the retail-faithful append -- and that the ring's promise equals
|
||||||
|
/// the landing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd()
|
||||||
|
{
|
||||||
|
var (controller, spellbook, screen, list, adds, _) = BindPointerFixture();
|
||||||
|
using SpellcastingUiController _1 = controller;
|
||||||
|
UiCatalogSlot slot0 = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||||
|
UiCatalogSlot empty = Assert.IsType<UiCatalogSlot>(list.GetItem(3));
|
||||||
|
Assert.True(empty.IsEmptySlot);
|
||||||
|
(int x0, int y0) = CellCenter(slot0);
|
||||||
|
(int ex, int ey) = CellCenter(empty);
|
||||||
|
|
||||||
|
screen.OnMouseDown(UiMouseButton.Left, x0, y0);
|
||||||
|
screen.OnMouseMove(x0 + 10, y0);
|
||||||
|
Assert.Same(slot0, screen.DragSource);
|
||||||
|
|
||||||
|
// Retail's empty tail cells are UIItems in the same list -- they ring too.
|
||||||
|
screen.OnMouseMove(ex, ey);
|
||||||
|
Assert.Equal(3, SingleAcceptRingIndex(list));
|
||||||
|
var payload = Assert.IsType<SpellFavoriteDragPayload>(screen.DragPayload);
|
||||||
|
int promised = controller.FavoriteDropIndex(payload, 0, list, empty);
|
||||||
|
Assert.Equal(2, promised); // live count after the lift -- append at end
|
||||||
|
|
||||||
|
screen.OnMouseUp(UiMouseButton.Left, ex, ey);
|
||||||
|
|
||||||
|
Assert.Equal([(0, promised, 1u)], adds);
|
||||||
|
Assert.Equal(new uint[] { 2u, 3u, 1u }, spellbook.GetFavorites(0));
|
||||||
|
Assert.Equal(-1, SingleAcceptRingIndex(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaving the bar clears the ring (retail's dwParam1 == 0 reset to
|
||||||
|
/// ItemSlot_DragOver_Normal 0x1000003F @ 0x004E1438), and an off-bar release
|
||||||
|
/// keeps the lift's removal (retail's shape: the drag-begin
|
||||||
|
/// RemoveSpellFavorite stands when no drop lands).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SpellFavoriteDrag_RingClearsOnLeave_AndAnOffBarReleaseKeepsTheLiftRemoval()
|
||||||
|
{
|
||||||
|
var (controller, spellbook, screen, list, adds, removes) = BindPointerFixture();
|
||||||
|
using SpellcastingUiController _1 = controller;
|
||||||
|
UiCatalogSlot slot0 = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||||
|
UiCatalogSlot slot2 = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
|
||||||
|
(int x0, int y0) = CellCenter(slot0);
|
||||||
|
(int x2, int y2) = CellCenter(slot2);
|
||||||
|
|
||||||
|
screen.OnMouseDown(UiMouseButton.Left, x0, y0);
|
||||||
|
screen.OnMouseMove(x0 + 10, y0);
|
||||||
|
screen.OnMouseMove(x2, y2);
|
||||||
|
Assert.Equal(2, SingleAcceptRingIndex(list));
|
||||||
|
|
||||||
|
// Pointer leaves every cell (screen corner, off the bar).
|
||||||
|
screen.OnMouseMove((int)screen.Width - 2, (int)screen.Height - 2);
|
||||||
|
Assert.Equal(-1, SingleAcceptRingIndex(list));
|
||||||
|
|
||||||
|
screen.OnMouseUp(UiMouseButton.Left, (int)screen.Width - 2, (int)screen.Height - 2);
|
||||||
|
|
||||||
|
Assert.Null(screen.DragSource);
|
||||||
|
Assert.Empty(adds);
|
||||||
|
Assert.Equal([(0, 1u)], removes);
|
||||||
|
Assert.Equal(new uint[] { 2u, 3u }, spellbook.GetFavorites(0));
|
||||||
|
Assert.Equal(-1, SingleAcceptRingIndex(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's spell-bar handler keys the ring off the dragged payload carrying
|
||||||
|
/// a spell id (@ 0x004C5990: SetDragAcceptState(0x10000040) only when the
|
||||||
|
/// InqDropIconInfo spellID != 0, returning 1 so the generic physical-item
|
||||||
|
/// fallback @ 0x004E3492 never runs for this list). A physical inventory
|
||||||
|
/// payload therefore leaves favorite cells NEUTRAL -- no ring, no reject --
|
||||||
|
/// while both spell payload kinds ring, on occupied and empty cells alike.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FavoriteCellRing_AcceptsSpellPayloads_StaysNeutralForPhysicalItems()
|
||||||
|
{
|
||||||
|
var (controller, _, _, list, _, _) = BindPointerFixture();
|
||||||
|
using SpellcastingUiController _1 = controller;
|
||||||
|
UiCatalogSlot occupied = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||||
|
UiCatalogSlot empty = Assert.IsType<UiCatalogSlot>(list.GetItem(3));
|
||||||
|
var physical = new ItemDragPayload(9u, ItemDragSource.Inventory, 0, new UiItemSlot(), null);
|
||||||
|
|
||||||
|
occupied.OnEvent(new UiEvent(0, occupied, UiEventType.DragEnter, Payload: physical));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.None, occupied.DragAcceptVisual);
|
||||||
|
|
||||||
|
occupied.OnEvent(new UiEvent(
|
||||||
|
0, occupied, UiEventType.DragEnter, Payload: new SpellbookShortcutDragPayload(42u)));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.Accept, occupied.DragAcceptVisual);
|
||||||
|
occupied.OnEvent(new UiEvent(0, occupied, UiEventType.DragOver));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.None, occupied.DragAcceptVisual);
|
||||||
|
|
||||||
|
empty.OnEvent(new UiEvent(
|
||||||
|
0, empty, UiEventType.DragEnter, Payload: new SpellFavoriteDragPayload(0, 0, 1u)));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.Accept, empty.DragAcceptVisual);
|
||||||
|
empty.OnEvent(new UiEvent(0, empty, UiEventType.DropReleased, Payload: physical));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.None, empty.DragAcceptVisual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared real-pointer fixture: favorites [1,2,3] on tab 0, the layout
|
||||||
|
/// mounted in a UiRoot, adds/removes recorded and applied to the Spellbook.</summary>
|
||||||
|
private (SpellcastingUiController Controller,
|
||||||
|
Spellbook Spellbook,
|
||||||
|
UiRoot Screen,
|
||||||
|
UiItemList List,
|
||||||
|
List<(int Tab, int Position, uint SpellId)> Adds,
|
||||||
|
List<(int Tab, uint SpellId)> Removes) BindPointerFixture()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
RetailCombatLayout.FitFavoriteSlots(layout);
|
||||||
|
var spellbook = new Spellbook();
|
||||||
|
spellbook.OnSpellLearned(1u, 1f);
|
||||||
|
spellbook.OnSpellLearned(2u, 1f);
|
||||||
|
spellbook.OnSpellLearned(3u, 1f);
|
||||||
|
spellbook.SetFavorite(0, 0, 1u);
|
||||||
|
spellbook.SetFavorite(0, 1, 2u);
|
||||||
|
spellbook.SetFavorite(0, 2, 3u);
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
var selection = new SelectionState();
|
||||||
|
var casting = new RuntimeSpellCastState(spellbook, selection, new NoopSpellCastOperations());
|
||||||
|
var adds = new List<(int Tab, int Position, uint SpellId)>();
|
||||||
|
var removes = new List<(int Tab, uint SpellId)>();
|
||||||
|
|
||||||
|
SpellcastingUiController? controller = SpellcastingUiController.Bind(
|
||||||
|
layout, spellbook, casting, objects, () => 1u,
|
||||||
|
spellId => spellId,
|
||||||
|
item => item.ObjectId,
|
||||||
|
_ => { },
|
||||||
|
selection,
|
||||||
|
(tab, position, spellId) =>
|
||||||
|
{
|
||||||
|
adds.Add((tab, position, spellId));
|
||||||
|
spellbook.SetFavorite(tab, position, spellId);
|
||||||
|
},
|
||||||
|
(tab, spellId) =>
|
||||||
|
{
|
||||||
|
removes.Add((tab, spellId));
|
||||||
|
spellbook.RemoveFavorite(tab, spellId);
|
||||||
|
});
|
||||||
|
Assert.True(controller is not null, DescribeBinding(layout));
|
||||||
|
|
||||||
|
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
||||||
|
screen.AddChild(layout.Root);
|
||||||
|
controller!.Tick();
|
||||||
|
|
||||||
|
UiElement group = layout.FindElement(0x100000AAu)!;
|
||||||
|
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
||||||
|
return (controller, spellbook, screen, list, adds, removes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int X, int Y) CellCenter(UiItemSlot cell)
|
||||||
|
{
|
||||||
|
System.Numerics.Vector2 p = cell.ScreenPosition;
|
||||||
|
return ((int)(p.X + cell.Width / 2f), (int)(p.Y + cell.Height / 2f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Index of the single cell showing the Accept ring, or -1 when none;
|
||||||
|
/// fails the test if more than one cell rings at once.</summary>
|
||||||
|
private static int SingleAcceptRingIndex(UiItemList list)
|
||||||
|
{
|
||||||
|
int found = -1;
|
||||||
|
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||||
|
{
|
||||||
|
if (list.GetItem(i) is { } cell
|
||||||
|
&& cell.DragAcceptVisual == UiItemSlot.DragAcceptState.Accept)
|
||||||
|
{
|
||||||
|
Assert.Equal(-1, found);
|
||||||
|
found = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FavoriteDrop_IgnoresForeignInventoryPayload()
|
public void FavoriteDrop_IgnoresForeignInventoryPayload()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue