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
|
|
@ -275,6 +275,256 @@ public sealed class SpellcastingUiControllerTests
|
|||
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]
|
||||
public void FavoriteDrop_IgnoresForeignInventoryPayload()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue