acdream/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
Erik 4a24614fd1 fix(ui): night-round review — F3/F4/F7 cast-button tooltip strings
F3: TS-85 had claimed the plain-spell branch's three SetTooltip format
strings were "genuine gmNoticeHandler vtable SLOTS" and unrecoverable
from the decomp dump. That was itself the artifact — Binary Ninja's
pseudo-C rendering of PStringBase::sprintf's second argument as
"&gmSpellcastingUI::`vftable'.RecvNotice_XXX" was a spurious symbol
match, not the true operand. A direct capstone disassembly of the raw
bytes at gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30's four
call sites (0x4c6e48/0x4c6ea4/0x4c6f18/0x4c6f5d) resolves the actual
pushed literals: "CAST %hs" @0x7b63a4 (untargeted/self-cast, and
targeted+compatible with " on %s" @0x7b6464 appended), "You must
select an appropriate target for %hs" @0x7b6348 (incompatible target),
"You must select a target for %hs" @0x7b63b8 (no target). %hs is the
spell's own name throughout.

Added RuntimeSpellCastState.EvaluateCastGate (SpellCastGate: NoTarget-
Needed/TargetCompatible/TargetIncompatible/NoTargetSelected/Unknown),
refactoring IsTargetReady to use it, and wired
SpellcastingUiController.ComputeSpellCastState to the four-state
tooltip text, replacing the bare-spell-name fallback.

F4: the endowment branch's "USE the %s" (and both select-target
strings) vararg is NOT the bare item name — retail composes
"%s (%hs)" @0x7b64d8 (item name, spell name) once at @0x004c6bb6-ef
and reuses it for all three format strings, byte-confirmed by all
three sprintf call sites (0x4c6c7f/0x4c6ca4/0x4c6d46) reading the
identical stack slot. Added ComposeEndowmentName and wired it in place
of the bare item name.

F7: added test coverage for the two genuinely NEW disabled states
(needs-target, needs-appropriate-target) neither branch had any
coverage for before, plus the enabled untargeted/targeted-compatible
states and both endowment-branch composed-name cases.

Corrected the register's TS-85 row (the "cannot be recovered" claim
and the endowment operand claim) with the byte-decoded findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:39:17 +02:00

1014 lines
45 KiB
C#

using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellcastingUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void ImportedFixture_UsesRetailEmptyCells_ShortcutDigits_AndCompleteCastButton()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
uint[] regular = [101u, 102u, 103u, 104u, 105u, 106u, 107u, 108u, 109u];
uint[] ghosted = [201u, 202u, 203u, 204u, 205u, 206u, 207u, 208u, 209u];
uint[] empty = [301u, 302u, 303u, 304u, 305u, 306u, 307u, 308u, 309u];
var digits = new UiShortcutDigitGraphics(regular, ghosted, empty);
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { },
shortcutDigits: digits,
emptySlotSprite: 0x06001A97u)!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(list.SingleRow);
Assert.True(list.HorizontalScroll);
Assert.True(list.FillVisibleEmptySlots);
Assert.Same(list.Scroll, scrollbar.Model);
Assert.True(scrollbar.Horizontal);
Assert.True(scrollbar.HideWhenDisabled);
Assert.Equal(23f, scrollbar.DecrementButtonExtent);
Assert.Equal(23f, scrollbar.IncrementButtonExtent);
Assert.Equal(0x06004CDEu, scrollbar.UpSprite);
Assert.Equal(0x06004CDFu, scrollbar.UpRolloverSprite);
Assert.Equal(0x06004CDCu, scrollbar.DownSprite);
Assert.Equal(0x06004CDDu, scrollbar.DownRolloverSprite);
Assert.False(scrollbar.IsPresentationVisible);
Assert.Equal(576f, list.Width);
Assert.Equal(18, list.GetNumUIItems());
UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
Assert.Equal(42u, favorite.EntryId);
Assert.Equal(0, favorite.ShortcutNum);
Assert.Same(regular, favorite.ActiveDigitArray());
for (int i = 1; i < 9; i++)
{
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(i));
Assert.True(slot.IsEmptySlot);
Assert.Equal(0x06001A97u, slot.EmptySprite);
Assert.Equal(i, slot.ShortcutNum);
Assert.Same(empty, slot.ActiveDigitArray());
}
for (int i = 9; i < 18; i++)
{
Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite);
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
}
var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(3, cast.FaceSegmentCount);
Assert.Equal(
[
new UiPixelRect(0, 0, 31, 31),
new UiPixelRect(32, 0, 42, 31),
new UiPixelRect(43, 0, 74, 31),
],
cast.FaceSegmentRectsForTest());
}
[Fact]
public void FavoriteOverflow_ArrowButtonsStepOneCell_AndSelectionScrollsIntoView()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
for (uint spellId = 1u; spellId <= 20u; spellId++)
{
spellbook.OnSpellLearned(spellId, 1f);
spellbook.SetFavorite(0, (int)spellId - 1, spellId);
}
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { })!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().Single();
UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(scrollbar.IsPresentationVisible);
Assert.Equal(64, list.Scroll.MaxScroll);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1)));
Assert.Equal(32, list.Scroll.ScrollY);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: 0)));
Assert.Equal(0, list.Scroll.ScrollY);
controller.AddFavorite(20u);
Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: 0)));
int manualOffset = list.Scroll.ScrollY;
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Unrelated" });
controller.Tick();
Assert.Equal(manualOffset, list.Scroll.ScrollY);
}
[Fact]
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var used = new List<uint>();
using SpellcastingUiController? controller = Bind(
layout, spellbook, objects, used.Add);
Assert.True(controller is not null, DescribeBinding(layout));
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Orb",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
IconId = 0x06001234u,
// TS-85: gmSpellcastingUI::UpdateCastButtonTooltip's endowment
// branch (0x004c6a30) gates immediate-use on
// ItemUses::IsUseable_SelfTarget (0x004fcd30) reading the ITEM's
// own Useability target mask -- a self-castable orb authors the
// Self target bit so clicking Cast fires without a target
// selection, matching this test's intent.
Useability = (ItemUseability.Self << 16) | ItemUseability.Wielded,
});
controller.Tick();
UiElement host = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(SpellcastingUiController.EndowmentId));
Assert.True(host.Visible);
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
Assert.Equal(2u, slot.EntryId);
Assert.Equal(2670u, slot.CatalogIconTexture);
Assert.Equal(2u, slot.CatalogOverlayTexture);
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId));
cast.OnEvent(new UiEvent(0, cast, UiEventType.Click));
Assert.Equal([2u], used);
}
/// <summary>
/// #354 (user gate finding): "I should be able to rearrange spells on the
/// spell bar ... drag one out and drop it in another position." Every prior
/// test in this file drives <c>Dropped</c>/<c>OnEvent</c> directly, bypassing
/// <see cref="UiRoot"/>'s real pointer pipeline -- which is exactly why the
/// regression this test guards against was invisible: BeginFavoriteDrag's
/// press-time removal fires SpellbookChanged, and a per-frame Tick() (as
/// production's RetailUiRuntime.Tick drives unconditionally) used to call
/// Rebuild(), which flushes and recreates every slot. UiRoot's subtree-removal
/// safety net then canceled the in-flight drag because its source cell had
/// just been destroyed out from under it. This drives the WHOLE gesture
/// through <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
/// a mid-drag <see cref="SpellcastingUiController.Tick"/>/
/// <see cref="UiRoot.OnMouseUp"/> and asserts both the retail-faithful final
/// order (insert-shift, not swap) and the exact remove-then-add wire pair.
/// </summary>
[Fact]
public void DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire()
{
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)>();
using 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();
UiCatalogSlot slot0 = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot slot2 = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
Assert.Equal(1u, slot0.EntryId);
Assert.Equal(3u, slot2.EntryId);
System.Numerics.Vector2 p0 = slot0.ScreenPosition;
System.Numerics.Vector2 p2 = slot2.ScreenPosition;
int x0 = (int)(p0.X + slot0.Width / 2f);
int y0 = (int)(p0.Y + slot0.Height / 2f);
int x2 = (int)(p2.X + slot2.Width / 2f);
int y2 = (int)(p2.Y + slot2.Height / 2f);
// Press on spell 1's cell and drag past the 3px promotion threshold --
// BeginFavoriteDrag fires here and removes spell 1 from the model + wire.
screen.OnMouseDown(UiMouseButton.Left, x0, y0);
screen.OnMouseMove(x0 + 10, y0);
Assert.Same(slot0, screen.DragSource);
Assert.Equal([(0, 1u)], removes);
// A live per-frame Tick, exactly like production's RetailUiRuntime.Tick,
// must NOT cancel the drag just because the lift already changed the
// Spellbook -- this is the regression this test exists to catch.
controller.Tick();
Assert.Same(slot0, screen.DragSource);
// Move onto spell 3's cell and release there.
screen.OnMouseMove(x2, y2);
screen.OnMouseUp(UiMouseButton.Left, x2, y2);
Assert.Null(screen.DragSource);
// Insert-shift, not swap: dropping spell 1 onto spell 3's (pre-lift index 2)
// cell lands it directly before spell 3, matching PlayerModule::AddSpellFavorite
// @ 0x005D43E0's InsertPos semantics.
Assert.Equal(0, adds[0].Tab);
Assert.Equal(1, adds[0].Position);
Assert.Equal(1u, adds[0].SpellId);
Assert.Equal(new uint[] { 2u, 1u, 3u }, spellbook.GetFavorites(0));
// The list resyncs to the final state on the next tick.
controller.Tick();
Assert.Equal(2u, Assert.IsType<UiCatalogSlot>(list.GetItem(0)).EntryId);
Assert.Equal(1u, Assert.IsType<UiCatalogSlot>(list.GetItem(1)).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 -&gt; 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()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController? controller = Bind(
layout, spellbook, objects, _ => { });
Assert.True(controller is not null, DescribeBinding(layout));
controller.Tick();
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
var foreign = new ItemDragPayload(9u, ItemDragSource.Inventory, 0, new UiItemSlot(), null);
bool handled = slot.OnEvent(new UiEvent(
0, slot, UiEventType.DropReleased, Payload: foreign));
Assert.True(handled);
Assert.Equal([42u], spellbook.GetFavorites(0));
}
/// <summary>
/// #354 discriminator sabotage check, symmetric to
/// <see cref="FavoriteDrop_IgnoresForeignInventoryPayload"/>: a catalog payload
/// minted by the favorite bar must be rejected by every PHYSICAL item slot too
/// (toolbar / inventory / vendor / paperdoll all route drops through
/// <see cref="IItemListDragHandler"/>, which only accepts <see cref="ItemDragPayload"/>).
/// If <see cref="SpellFavoriteDragPayload"/> were ever widened to match, a spell
/// icon dragged onto the toolbar would silently misbehave instead of no-op'ing.
/// </summary>
[Fact]
public void SpellFavoritePayload_IsRejectedByAPhysicalItemListDragHandler()
{
var recordedDrops = new List<uint>();
var handler = new RecordingDragHandler(recordedDrops);
var list = new UiItemList();
list.RegisterDragHandler(handler);
var cell = list.Cell;
var payload = new SpellFavoriteDragPayload(0, 0, 42u);
bool handled = cell.OnEvent(new UiEvent(
0, cell, UiEventType.DropReleased, Payload: payload));
Assert.True(handled);
Assert.Empty(recordedDrops);
}
private sealed class RecordingDragHandler(List<uint> drops) : IItemListDragHandler
{
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
=> drops.Add(payload.ObjId);
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> ItemDragAcceptance.None;
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> drops.Add(payload.ObjId);
}
[Fact]
public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var added = new List<(int Tab, int Position, uint Spell)>();
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { },
(tab, position, spell) => added.Add((tab, position, spell)))!;
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
UiItemList list = Descendants(group).OfType<UiItemList>().First();
bool handled = list.OnEvent(new UiEvent(
0,
list,
UiEventType.DropReleased,
Data1: 5,
Payload: new SpellbookShortcutDragPayload(42u)));
Assert.True(handled);
Assert.Equal([(0, 0, 42u)], added);
Assert.Equal([42u], spellbook.GetFavorites(0));
Assert.True(spellbook.Knows(42u));
}
[Fact]
public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { })!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiItemSlot before = list.GetItem(0)!;
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Rock" });
controller.Tick();
Assert.Same(before, list.GetItem(0));
}
[Fact]
public void ObjectTableClear_RemovesEquippedEndowmentAndCannotUseStaleGuid()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Orb",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
});
var used = new List<uint>();
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, used.Add)!;
UiElement host = layout.FindElement(SpellcastingUiController.EndowmentId)!;
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
objects.Clear();
controller.Tick();
Assert.False(host.Visible);
Assert.Equal(0u, slot.EntryId);
slot.OnEvent(new UiEvent(0, slot, UiEventType.DoubleClick));
Assert.Empty(used);
}
[Fact]
public void FavoritePressSelectsImmediately_AndRightClickExaminesLocally()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.OnSpellLearned(43u, 1f);
spellbook.SetFavorite(0, 0, 42u);
spellbook.SetFavorite(0, 1, 43u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var selection = new SelectionState();
var examined = new List<uint>();
using SpellcastingUiController controller = Bind(
layout,
spellbook,
objects,
_ => { },
selection: selection,
examineSpell: examined.Add)!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
Assert.True(first.Selected);
second.OnEvent(new UiEvent(0u, second, UiEventType.MouseDown));
Assert.False(first.Selected);
Assert.True(second.Selected);
Assert.Null(selection.SelectedObjectId);
second.OnEvent(new UiEvent(0u, second, UiEventType.RightClick));
Assert.Equal(new uint[] { 43u }, examined);
Assert.Null(selection.SelectedObjectId);
}
// ── Night-round review F3/F4/F7: cast-button tooltip states ────────────
//
// gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30, byte-decoded
// (see SpellcastingUiController.ComputeSpellCastState/
// ComposeEndowmentName's own doc comments). These pin the four
// plain-spell states (two pre-existed only as a bare-name fallback; the
// two DISABLED states are genuinely new coverage per F7) and the
// endowment branch's composed-name fix (F4).
[Fact]
public void CastAvailability_UntargetedSpell_IsEnabled_WithCastSpellNameTooltip()
{
SpellMetadata spell = BuildSpell(
42u, "Test Untargeted", isUntargeted: true, isSelfTargeted: false, targetMask: 0u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.True(cast.Enabled);
Assert.Equal("CAST Test Untargeted", cast.TooltipText);
}
[Fact]
public void CastAvailability_TargetedSpell_CompatibleTargetSelected_AppendsOnTargetName()
{
SpellMetadata spell = BuildSpell(
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
var selection = new SelectionState();
var operations = new ConfigurableSpellCastOperations { TargetCompatible = true };
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
selection.Select(5u, SelectionChangeSource.World);
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.True(cast.Enabled);
Assert.Equal("CAST Test Targeted on Drudge", cast.TooltipText);
}
[Fact]
public void CastAvailability_TargetedSpell_NoTargetSelected_IsDisabled_WithNeedsTargetTooltip()
{
// F7: this DISABLED state was previously untested — the bare-name
// fallback the old code shipped never distinguished it from the
// ready/enabled case.
SpellMetadata spell = BuildSpell(
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.False(cast.Enabled);
Assert.Equal("You must select a target for Test Targeted", cast.TooltipText);
}
[Fact]
public void CastAvailability_TargetedSpell_IncompatibleTargetSelected_IsDisabled_WithNeedsAppropriateTargetTooltip()
{
// F7: this DISABLED state was previously untested.
SpellMetadata spell = BuildSpell(
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
spellbook.OnSpellLearned(42u);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
var selection = new SelectionState();
var operations = new ConfigurableSpellCastOperations { TargetCompatible = false };
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
selection.Select(5u, SelectionChangeSource.World);
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.False(cast.Enabled);
Assert.Equal("You must select an appropriate target for Test Targeted", cast.TooltipText);
}
[Fact]
public void CastAvailability_EndowmentSelfTarget_ComposesItemAndSpellName()
{
SpellMetadata spell = BuildSpell(
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Lightning Wand",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
// ItemUseability.Self shifted into the TARGET half.
Useability = ItemUseability.Self << 16,
});
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.True(cast.Enabled);
Assert.Equal("USE the Lightning Wand (Lightning Bolt VI)", cast.TooltipText);
}
[Fact]
public void CastAvailability_EndowmentNeedsTarget_NoTargetSelected_ComposesItemAndSpellName()
{
SpellMetadata spell = BuildSpell(
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
var spellbook = new Spellbook(SpellTable.Create([spell]));
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Lightning Wand",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
// Remote (not Self) shifted into the TARGET half — requires an
// external target, same as retail's non-self-castable wands.
Useability = ItemUseability.Remote << 16,
});
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.False(cast.Enabled);
Assert.Equal(
"You must select a target for the Lightning Wand (Lightning Bolt VI)",
cast.TooltipText);
}
private static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Action<uint> useItem,
Action<int, int, uint>? addFavorite = null,
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u,
SelectionState? selection = null,
Action<uint>? examineSpell = null,
IRuntimeSpellCastOperations? operations = null)
{
SelectionState selectionState = selection ?? new SelectionState();
var casting = new RuntimeSpellCastState(
spellbook,
selectionState,
operations ?? new NoopSpellCastOperations());
return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
item => item.ObjectId,
useItem,
selectionState,
(tab, position, spellId) =>
{
spellbook.SetFavorite(tab, position, spellId);
addFavorite?.Invoke(tab, position, spellId);
},
(tab, spellId) => spellbook.RemoveFavorite(tab, spellId),
shortcutDigits,
emptySlotSprite,
examineSpell);
}
private sealed class NoopSpellCastOperations : IRuntimeSpellCastOperations
{
public uint LocalPlayerId => 1u;
public bool CanSend => true;
public bool HasRequiredComponents(uint spellId) => true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => true;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
/// <summary>Same as <see cref="NoopSpellCastOperations"/> but with a
/// settable target-compatibility answer — the night-round review
/// (F3/F7) cast-button-tooltip tests need to force both the
/// compatible and incompatible target branches.</summary>
private sealed class ConfigurableSpellCastOperations : IRuntimeSpellCastOperations
{
public bool TargetCompatible = true;
public uint LocalPlayerId => 1u;
public bool CanSend => true;
public bool HasRequiredComponents(uint spellId) => true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => TargetCompatible;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
/// <summary>Minimal <see cref="SpellMetadata"/> builder for cast-gate
/// tests — most of the record's fields are irrelevant to
/// <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.</summary>
private static SpellMetadata BuildSpell(
uint spellId, string name, bool isUntargeted, bool isSelfTargeted, uint targetMask) =>
new(
SpellId: spellId,
Name: name,
School: "Life",
Family: 0u,
IconId: 0u,
SpellWords: "",
Duration: 0f,
ManaCost: 0,
IsDebuff: false,
IsFellowship: false,
Description: "",
SortKey: 0,
Difficulty: 0,
Flags: isSelfTargeted ? (uint)SpellFlags.SelfTargeted : 0u,
Generation: 1,
IsFastWindup: false,
IsOffensive: false,
IsUntargeted: isUntargeted,
Speed: 0f,
CasterEffect: 0u,
TargetEffect: 0u,
TargetMask: targetMask,
SpellType: 0);
private static void ApplyAnchors(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyAnchors(child);
}
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement nested in Descendants(child)) yield return nested;
}
}
private static string DescribeBinding(ImportedLayout layout)
{
uint[] ids =
[
SpellcastingUiController.CastButtonId, SpellcastingUiController.EndowmentId,
0x100000A3u, 0x100000AAu, 0x100005C2u, 0x100005C3u,
];
return string.Join(", ", ids.Select(id =>
$"{id:X8}={layout.FindElement(id)?.GetType().Name ?? "missing"}"));
}
}