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
|
|
@ -527,7 +527,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (0x10000046 → green arrow 0x060011F7). Retail: 0x004e3400.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,19 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
public Action<object>? DragEnded { 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;
|
||||
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);
|
||||
return true;
|
||||
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:
|
||||
// UiRoot fires DragOver on LEAVE — retail's dwParam1 == 0 reset to
|
||||
// ItemSlot_DragOver_Normal (0x1000003F) @ 0x004E1438-0x004E1456.
|
||||
SetDragAcceptVisual(DragAcceptState.None);
|
||||
return true;
|
||||
case UiEventType.DropReleased:
|
||||
SetDragAcceptVisual(DragAcceptState.None);
|
||||
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
|
||||
return true;
|
||||
default:
|
||||
|
|
@ -141,6 +170,10 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
if (!string.IsNullOrWhiteSpace(Detail))
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue