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

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:
Erik 2026-08-08 20:18:51 +02:00
parent 81a9d85a1d
commit 6bb4cfa795
9 changed files with 434 additions and 34 deletions

View file

@ -349,7 +349,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
uint id = spellId;
int position = list.GetNumUIItems();
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
var slot = new UiCatalogSlot
UiCatalogSlot? slot = null;
slot = new UiCatalogSlot
{
EntryId = id,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
@ -358,10 +359,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
DragOverAcceptance = FavoriteDragOverAcceptance,
Dropped = payload =>
{
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, targetTab, position);
DropFavorite(favorite, targetTab, list, slot!);
else if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, position);
},
@ -386,15 +388,17 @@ public sealed class SpellcastingUiController : IRetainedPanelController
slot = new UiCatalogSlot
{
SpriteResolve = list.SpriteResolve,
DragOverAcceptance = FavoriteDragOverAcceptance,
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)
DropFavorite(favorite, targetTab, position);
DropFavorite(favorite, targetTab, list, slot!);
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);
}
},
};
ConfigureShortcutOverlay(slot, shortcutIndex);
@ -450,16 +454,54 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_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
// every sibling slot's captured target index is still numbered against the
// PRE-lift list. Retail's own SpellCastSubMenu::AddFavorite @ 0x004C7060
// corrects for exactly this staleness: when the lifted item's original
// index was before the drop target, the target index shifts down by one
// to land where the target visually sits once the gap closes.
if (payload.SourceTab == targetTab && payload.SourcePosition < targetPosition)
targetPosition -= 1;
int index = Math.Max(0, list.IndexOf(cell));
if (cell.IsEmptySlot)
return Math.Min(index, _spellbook.GetFavorites(targetTab).Count);
if (payload.SourceTab == targetTab && payload.SourcePosition < index)
index -= 1;
return index;
}
private void DropFavorite(
SpellFavoriteDragPayload payload, int targetTab, UiItemList list, UiItemSlot cell)
{
int targetPosition = FavoriteDropIndex(payload, targetTab, list, cell);
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}