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

@ -46,10 +46,13 @@ public class UiItemSlot : UiElement
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
/// <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;
/// <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;
/// <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>
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
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
/// 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);
}
// 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).
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);
}
}
DrawDragAcceptOverlay(ctx);
// UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20 selects exactly
// 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
/// call this after their icon layer so magic favorites and physical shortcuts
/// use the same UIItem presentation path.</summary>