acdream/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
Erik 39c49e140e feat(ui): spellcasting cast-button + character-panel attribute/skill tooltips — gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30, AttributeInfoRegion/Attribute2ndInfoRegion/SkillInfoRegion @0x004F1530/0x004F1680/0x004F2140
TS-85 remainder batch (hover/UI overnight round, batch B). Audit found
three of the four listed spellcasting SetTooltip sites (endowment icon,
favorite, submenu) were already correct via UiCatalogSlot's pre-existing
Label-driven GetTooltipText; only the cast button (UiButton, no tooltip
wiring at all) was a real gap. Ports the verified literal states
("Select a spell to cast" / "You have no spells ready to cast" / the
full endowment-item USE-the-%s branch) plus a documented, narrower
fallback (spell name only) for the one sub-branch whose exact wording
sits behind a genuine gmNoticeHandler vtable-slot collision in the
pseudo-C dump rather than the unlabeled-string-pool class the rest of
this batch recovered.

Character panel: new UiClickablePanel.TooltipText seam (same pattern as
UiButton.TooltipText) carries the six hardcoded attribute descriptions
and three pair-shared vitals descriptions (byte-decoded from the retail
string pool) plus skill tooltips composed from the already-DAT-parsed
SkillBase.Description/.Formula — no hand-transcription needed for the
~30+ skill strings. The formula-to-text algorithm itself
(SkillSystem::InqSkillFormula) was recovered by byte-decoding six short
fragments Binary Ninja left completely unlabeled between two
gmSpellcastingUI vtable declarations.

Live-verified against the local ACE server: 34 real skills' composed
tooltips and both reachable cast-button states captured via a temporary
probe (stripped before this commit). Full solution suite green
(14,647 tests, 0 failures) both before and after the probe strip.

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

798 lines
35 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);
}
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)
{
SelectionState selectionState = selection ?? new SelectionState();
var casting = new RuntimeSpellCastState(
spellbook,
selectionState,
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() { }
}
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"}"));
}
}