using System; using System.Collections.Generic; using System.Linq; using AcDream.App.Spells; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Spells; using AcDream.Core.Selection; using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; namespace AcDream.App.UI.Layout; /// /// Retail gmSpellcastingUI binding for the authored magic page inside combat /// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one /// request through . /// public sealed class SpellcastingUiController : IRetainedPanelController { public const uint PageId = 0x10000061u; public const uint SpellNameId = 0x1000048Bu; public const uint EndowmentId = 0x100000B1u; public const uint CastButtonId = 0x100000B2u; public const uint FavoriteScrollbarId = 0x100000B5u; public const uint FavoriteListId = 0x100000B6u; private static readonly uint[] TabIds = [ 0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u, 0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u, ]; private static readonly uint[] GroupIds = [ 0x100000AAu, 0x100000ABu, 0x100000ACu, 0x100000ADu, 0x100000AEu, 0x100000AFu, 0x100000B0u, 0x100005C3u, ]; private readonly Spellbook _spellbook; private readonly RuntimeSpellCastState _casting; private readonly SelectionState _selection; private readonly ClientObjectTable _objects; private readonly Func _playerGuid; private readonly Func _resolveSpellIcon; private readonly Func _resolveItemDragIcon; private readonly Action _useItem; private readonly Action? _examineSpell; private readonly Action? _addFavorite; private readonly Action? _removeFavorite; private readonly UiShortcutDigitGraphics? _shortcutDigits; private readonly uint _emptySlotSprite; private readonly UiElement[] _tabs; private readonly UiElement[] _groups; private readonly UiItemList?[] _lists; private readonly UiScrollbar?[] _scrollbars; private readonly UiButton _cast; private readonly UiText? _spellName; private readonly UiElement _endowmentHost; private readonly UiCatalogSlot _endowmentSlot; private int _activeTab; private readonly uint?[] _selected = new uint?[8]; private readonly bool[] _endowmentSelected = new bool[8]; private uint _endowmentItemId; private uint _endowmentSpellId; private bool _disposed; private bool _favoritesDirty; private bool _endowmentDirty; private bool _favoriteDragActive; private SpellcastingUiController( ImportedLayout layout, Spellbook spellbook, RuntimeSpellCastState casting, ClientObjectTable objects, Func playerGuid, Func resolveSpellIcon, Func resolveItemDragIcon, Action useItem, Action? examineSpell, SelectionState selection, Action? addFavorite, Action? removeFavorite, UiElement[] tabs, UiElement[] groups, UiItemList?[] lists, UiScrollbar?[] scrollbars, UiButton cast, UiElement endowmentHost, UiShortcutDigitGraphics? shortcutDigits, uint emptySlotSprite) { _spellbook = spellbook; _casting = casting; _selection = selection; _objects = objects; _playerGuid = playerGuid; _resolveSpellIcon = resolveSpellIcon; _resolveItemDragIcon = resolveItemDragIcon; _useItem = useItem; _examineSpell = examineSpell; _addFavorite = addFavorite; _removeFavorite = removeFavorite; _shortcutDigits = shortcutDigits; _emptySlotSprite = emptySlotSprite; _tabs = tabs; _groups = groups; _lists = lists; _scrollbars = scrollbars; _cast = cast; _spellName = layout.FindElement(SpellNameId) as UiText; _endowmentHost = endowmentHost; foreach (UiElement child in _endowmentHost.Children) child.Visible = false; _endowmentSlot = new UiCatalogSlot { Left = 0f, Top = 0f, Width = endowmentHost.Width, Height = endowmentHost.Height, SpriteResolve = lists.FirstOrDefault(list => list is not null)?.SpriteResolve, }; _endowmentSlot.Clicked = SelectEndowment; _endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); }; _endowmentHost.AddChild(_endowmentSlot); for (int i = 0; i < _lists.Length; i++) { if (_lists[i] is not { } list) continue; list.PrimaryCatalogEntryPressed = SelectSpell; list.ExamineCatalogEntryRequested = _examineSpell; if (_scrollbars[i] is not { } scrollbar) continue; list.HorizontalScroll = true; scrollbar.Horizontal = true; scrollbar.Model = list.Scroll; scrollbar.SpriteResolve ??= list.SpriteResolve; } for (int i = 0; i < tabs.Length; i++) { int index = i; SetClick(tabs[i], () => SelectTab(index)); } _cast.OnClick = CastSelected; ConfigureSpellName(); _spellbook.SpellbookChanged += OnSpellbookChanged; _selection.Changed += OnSelectionChanged; _objects.ObjectAdded += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged; _objects.ObjectRemoved += OnObjectChanged; _objects.Cleared += OnObjectsCleared; UpdateEndowment(); SelectTab(0); Rebuild(); } public int ActiveTab => _activeTab; public static SpellcastingUiController? Bind( ImportedLayout layout, Spellbook spellbook, RuntimeSpellCastState casting, ClientObjectTable objects, Func playerGuid, Func resolveSpellIcon, Func resolveItemDragIcon, Action useItem, SelectionState selection, Action? addFavorite, Action? removeFavorite, UiShortcutDigitGraphics? shortcutDigits = null, uint emptySlotSprite = 0u, Action? examineSpell = null) { if (layout.FindElement(CastButtonId) is not UiButton cast || layout.FindElement(EndowmentId) is not { } endowmentHost) return null; var tabs = new UiElement[8]; var groups = new UiElement[8]; var lists = new UiItemList?[8]; var scrollbars = new UiScrollbar?[8]; for (int i = 0; i < 8; i++) { if (layout.FindElement(TabIds[i]) is not { } tab || layout.FindElement(GroupIds[i]) is not { } group) return null; tabs[i] = tab; groups[i] = group; lists[i] = Descendants(group).OfType().FirstOrDefault(); scrollbars[i] = Descendants(group).OfType().FirstOrDefault( scrollbar => scrollbar.DatElementId == FavoriteScrollbarId); } return new SpellcastingUiController( layout, spellbook, casting, objects, playerGuid, resolveSpellIcon, resolveItemDragIcon, useItem, examineSpell, selection, addFavorite, removeFavorite, tabs, groups, lists, scrollbars, cast, endowmentHost, shortcutDigits, emptySlotSprite); } public void AddFavorite(uint spellId) { IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (spells.Contains(spellId)) { SelectSpell(spellId); return; } int position = spells.Count; _addFavorite?.Invoke(_activeTab, position, spellId); SelectSpell(spellId); } public bool Handle(InputAction action) { if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9) { int index = (int)action - (int)InputAction.UseSpellSlot_1; IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (index < spells.Count) { SelectSpell(spells[index]); CastSelected(); } return true; } switch (action) { case InputAction.CombatPrevSpellTab: SelectTab((_activeTab + 7) % 8); return true; case InputAction.CombatNextSpellTab: SelectTab((_activeTab + 1) % 8); return true; case InputAction.CombatFirstSpellTab: SelectTab(0); return true; case InputAction.CombatLastSpellTab: SelectTab(7); return true; case InputAction.CombatPrevSpell: MoveSelection(-1, false); return true; case InputAction.CombatNextSpell: MoveSelection(1, false); return true; case InputAction.CombatFirstSpell: MoveSelection(0, true); return true; case InputAction.CombatLastSpell: MoveSelection(-1, true); return true; case InputAction.CombatCastCurrentSpell: CastSelected(); return true; default: return false; } } private void SelectTab(int tab) { _activeTab = Math.Clamp(tab, 0, 7); for (int i = 0; i < 8; i++) { SetSelected(_tabs[i], i == _activeTab); _groups[i].Visible = i == _activeTab; } IReadOnlyList favorites = _spellbook.GetFavorites(_activeTab); if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) _selected[_activeTab] = null; else if (_selected[_activeTab] is not uint selected || !favorites.Contains(selected)) { _selected[_activeTab] = favorites.Count == 0 ? null : favorites[0]; _endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u; } SyncSelection(); UpdateCastAvailability(); } private void MoveSelection(int delta, bool edge) { IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); int count = spells.Count + (_endowmentItemId != 0u ? 1 : 0); if (count == 0) return; int current = _endowmentSelected[_activeTab] ? 0 : (_selected[_activeTab] is uint id ? spells.IndexOf(id) : -1) + (_endowmentItemId != 0u ? 1 : 0); int next = edge ? (delta < 0 ? count - 1 : 0) : (current + delta + count) % count; if (_endowmentItemId != 0u && next == 0) SelectEndowment(); else SelectSpell(spells[next - (_endowmentItemId != 0u ? 1 : 0)]); } private void SelectSpell(uint spellId) { _endowmentSelected[_activeTab] = false; _selected[_activeTab] = spellId; SyncSelection(); ScrollSelectedSpellIntoView(_activeTab, spellId); UpdateCastAvailability(); } private void ScrollSelectedSpellIntoView(int tab, uint spellId) { UiItemList? list = _lists[tab]; if (list is null) return; for (int i = 0; i < list.GetNumUIItems(); i++) { if (list.GetItem(i) is UiCatalogSlot { EntryId: var entryId } && entryId == spellId) { // SpellCastSubMenu::SetSelected @ 0x004C5B00 exposes the // selected UIItem through UIElement_ListBox::ScrollToView. list.ScrollItemIntoView(i); return; } } } private void CastSelected() { if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) _useItem(_endowmentItemId); else if (_selected[_activeTab] is uint spellId) _casting.Cast(spellId); } private void SelectEndowment() { if (_endowmentItemId == 0u) return; _endowmentSelected[_activeTab] = true; _selected[_activeTab] = null; SyncSelection(); UpdateCastAvailability(); } private void Rebuild() { for (int tab = 0; tab < 8; tab++) { UiItemList? list = _lists[tab]; if (list is null) continue; IReadOnlyList favorites = _spellbook.GetFavorites(tab); int targetTab = tab; list.CatalogDropped = (payload, x, _) => { if (payload is SpellbookShortcutDragPayload shortcut) DropSpellbookShortcut( shortcut, targetTab, DropPosition(list, x, favorites.Count)); }; using (list.DeferLayout()) { list.Flush(); list.SingleRow = true; list.HorizontalScroll = true; list.CellWidth = 32f; list.CellHeight = 32f; list.CellEmptySprite = _emptySlotSprite; list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab); list.FillVisibleEmptySlots = true; foreach (uint spellId in favorites) { uint id = spellId; int position = list.GetNumUIItems(); _spellbook.TryGetMetadata(id, out SpellMetadata? metadata); UiCatalogSlot? slot = null; slot = new UiCatalogSlot { EntryId = id, CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id), Label = metadata?.Name ?? $"Spell {id}", SpriteResolve = list.SpriteResolve, 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, list, slot!); else if (payload is SpellbookShortcutDragPayload shortcut) DropSpellbookShortcut(shortcut, targetTab, position); }, }; // gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0 // selects on left press (message parameter 7). A completed // double click casts, but release is not required merely to // change the current spell. slot.DoubleClicked = CastSelected; list.AddItem(slot); } } StampShortcutOverlays(list); } SelectTab(_activeTab); } private UiCatalogSlot CreateEmptyFavoriteSlot(UiItemList list, int targetTab) { int shortcutIndex = list.GetNumUIItems(); UiCatalogSlot? slot = null; slot = new UiCatalogSlot { SpriteResolve = list.SpriteResolve, DragOverAcceptance = FavoriteDragOverAcceptance, Dropped = payload => { if (payload is SpellFavoriteDragPayload favorite) 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); return slot; } private void StampShortcutOverlays(UiItemList list) { for (int i = 0; i < list.GetNumUIItems(); i++) { UiItemSlot? slot = list.GetItem(i); if (slot is null) continue; ConfigureShortcutOverlay(slot, i); } } private void ConfigureShortcutOverlay(UiItemSlot slot, int index) { slot.RegularDigits = _shortcutDigits?.RegularDigits; slot.GhostedDigits = _shortcutDigits?.GhostedDigits; slot.EmptyDigits = _shortcutDigits?.EmptyDigits; if (index < 9) slot.SetShortcutNum(index, ghosted: false); else slot.ClearShortcutNum(); } private void BeginFavoriteDrag(SpellFavoriteDragPayload payload) { // gmSpellcastingUI::RecvNotice_ItemListBeginDrag @ 0x004C7360 removes the // lifted item from PlayerModule (+ sends the wire RemoveSpellFavorite) // the instant the drag starts. That removal fires SpellbookChanged, which // would normally set _favoritesDirty and let the next Tick() rebuild the // whole favorite list -- but Rebuild() flushes and recreates every slot // (UiItemList.Flush -> RemoveChild), and UiRoot's subtree-removal safety // net (ClearSubtreeOwnership) cancels any drag rooted in a removed // element. Left alone, a per-frame Tick() would destroy the very cell // driving this gesture and silently cancel the reorder before the user // can complete the drop. _favoriteDragActive defers the rebuild for the // gesture's duration; DropFavorite compensates target indices for the // now-stale numbering (retail's own -1-if-lifted-before-target rule, // ported below). _favoriteDragActive = true; _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId); } private void EndFavoriteDrag(SpellFavoriteDragPayload payload) { // The press-time command already performed retail's PlayerModule // removal on the one Runtime-owned Spellbook. Release the rebuild // deferral -- the next Tick() resyncs the list to the (possibly // further-updated-by-Drop) Spellbook state. _favoriteDragActive = false; } /// /// 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. /// internal static ItemDragAcceptance FavoriteDragOverAcceptance(object payload) => payload is SpellFavoriteDragPayload or SpellbookShortcutDragPayload ? ItemDragAcceptance.Accept : ItemDragAcceptance.None; /// /// THE one favorite-landing computation: the index a /// drop on /// applies. The drag-over Accept ring and 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). /// internal int FavoriteDropIndex( SpellFavoriteDragPayload payload, int targetTab, UiItemList list, UiItemSlot cell) { 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; } private void DropSpellbookShortcut( SpellbookShortcutDragPayload payload, int targetTab, int targetPosition) { // gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0 publishes // CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu // chooses the favorite tab/position and persists it. _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); _selected[targetTab] = payload.SpellId; } private static int DropPosition(UiItemList list, int localX, int favoriteCount) { int position = list.CellWidth <= 0f ? favoriteCount : (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth); return Math.Clamp(position, 0, favoriteCount); } private void OnSpellbookChanged() => _favoritesDirty = true; private void OnObjectChanged(ClientObject _) => _endowmentDirty = true; private void OnObjectsCleared() => _endowmentDirty = true; public void Tick() { if (_endowmentDirty) { _endowmentDirty = false; UpdateEndowment(); SelectTab(_activeTab); } if (_favoritesDirty && !_favoriteDragActive) { _favoritesDirty = false; Rebuild(); } } private void SyncSelection() { for (int tab = 0; tab < 8; tab++) { UiItemList? list = _lists[tab]; if (list is null) continue; for (int i = 0; i < list.GetNumUIItems(); i++) if (list.GetItem(i) is UiCatalogSlot slot) slot.Selected = slot.EntryId == _selected[tab]; } _endowmentSlot.Selected = _endowmentItemId != 0u && _endowmentSelected[_activeTab]; } private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability(); /// /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30. Enabled and /// TooltipText are retail's SAME state machine (SetState + SetTooltip /// side by side throughout that function) — porting the tooltip text /// without correcting Enabled to match would let the tooltip promise an /// action the button doesn't actually allow (TS-85). /// private void UpdateCastAvailability() { if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) { (bool enabled, string? tooltip) = ComputeEndowmentCastState(); _cast.Enabled = enabled; _cast.TooltipText = tooltip; return; } if (_selected[_activeTab] is uint spellId) { (bool enabled, string? tooltip) = ComputeSpellCastState(spellId); _cast.Enabled = enabled; _cast.TooltipText = tooltip; return; } _cast.Enabled = false; bool anyFavorites = false; for (int tab = 0; tab < 8 && !anyFavorites; tab++) anyFavorites = _spellbook.GetFavorites(tab).Count > 0; // Verbatim literals: "Select a spell to cast" @ data_7b64ec, // "You have no spells ready to cast" @ data_7b6520. _cast.TooltipText = anyFavorites ? "Select a spell to cast" : "You have no spells ready to cast"; } /// /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's endowment-item /// branch (m_endowmentItemID != 0). Every literal below is directly /// visible in the decomp (not the mislabeled-vtable-slot class the /// plain-spell branch hits above): "USE the %s" @ data_7b64c0, /// "You must select a target for the %s" @ data_7b6478, /// " on %s" @ data_7b6464. ItemUses::IsUseable_SelfTarget @ /// 0x004fcd30 is exactly /// (both test the target-mask Self bit after shifting the high word down /// 16). NOT ported: the incompatible-target sub-state ("You must select /// an appropriate\ntarget for the %s" @ data_7b6400), which retail /// derives from ItemHolder::TargetCompatibleWithObject @ 0x00587520 /// — a ~400-line function with its own chat-message side effects, out of /// scope for a tooltip batch. A present target is optimistically treated /// as compatible here, same text as the confirmed-compatible case. See /// TS-85. /// private (bool enabled, string? tooltip) ComputeEndowmentCastState() { ClientObject? endowment = _objects.Get(_endowmentItemId); if (endowment is null) return (false, null); string composedName = ComposeEndowmentName(endowment); if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u)) return (true, $"USE the {composedName}"); uint? targetId = _selection.SelectedObjectId; if (targetId is null or 0u) return (false, $"You must select a target for the {composedName}"); string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? composedName; return (true, $"USE the {composedName} on {targetName}"); } /// /// Night-round review F4: the vararg to "USE the %s" (and both /// select-target strings above) is NOT the bare item name — retail /// builds "%s (%hs)" @0x7b64d8 (item name, spell name) once /// at @0x004c6bb6-ef and reuses that composed string as the /// shared operand for all three format strings (byte-confirmed: the /// three sprintf call sites at 0x4c6c7f/0x4c6ca4/ /// 0x4c6d46 all read the SAME [esp+0x18] slot). e.g. /// "USE the Lightning Wand (Lightning Bolt VI)". /// private string ComposeEndowmentName(ClientObject endowment) { string itemName = endowment.GetAppropriateName(); return _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata spellMetadata) ? $"{itemName} ({spellMetadata.Name})" : itemName; } /// /// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's /// plain-spell branch (m_endowmentItemID == 0, a spell is /// highlighted in the open submenu). Night-round review F3 corrects /// TS-85's "cannot be recovered" claim: the three format strings TS-85 /// took for gmNoticeHandler vtable-slot mislabels (a real BN artifact /// class, but not what happened here) are recoverable literals once /// the raw machine code is disassembled directly — the vtable-slot /// names Binary Ninja printed for the sprintf calls were spurious. /// Byte-confirmed pushes: "CAST %hs" @0x7b63a4 at both /// 0x4c6f5d (untargeted/self-cast, always enabled) and /// 0x4c6ea4 (targeted+compatible, enabled, then " on %s" /// @0x7b6464 appended with the target's name at 0x4c6ee8); /// "You must select an appropriate target for %hs" @0x7b6348 at /// 0x4c6f18 (targeted+incompatible, stays disabled); "You /// must select a target for %hs" @0x7b63b8 at 0x4c6e48 (no /// target selected, stays disabled). %hs is the spell's own /// name in every case (CSpellBase::InqName, the same call /// (0x5bbee0) at all four sites) — no item/composed name /// involved here, unlike the endowment branch above. /// private (bool enabled, string? tooltip) ComputeSpellCastState(uint spellId) { if (!_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)) return (false, null); string spellName = metadata.Name; SpellCastGate gate = _casting.EvaluateCastGate(spellId); switch (gate) { case SpellCastGate.NoTargetNeeded: return (true, $"CAST {spellName}"); case SpellCastGate.TargetCompatible: { uint? targetId = _selection.SelectedObjectId; string? targetName = targetId is uint id and not 0u ? _objects.Get(id)?.GetAppropriateName() : null; return (true, targetName is null ? $"CAST {spellName}" : $"CAST {spellName} on {targetName}"); } case SpellCastGate.TargetIncompatible: return (false, $"You must select an appropriate target for {spellName}"); case SpellCastGate.NoTargetSelected: return (false, $"You must select a target for {spellName}"); default: return (false, null); } } private void ConfigureSpellName() { if (_spellName is null) return; _spellName.OneLine = true; _spellName.Centered = true; _spellName.Padding = 0; _spellName.LinesProvider = () => { uint? chosenSpell = _endowmentSelected[_activeTab] ? (_endowmentSpellId == 0u ? null : _endowmentSpellId) : _selected[_activeTab]; string name = chosenSpell is uint spellId && _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata) ? metadata.Name : string.Empty; return [new UiText.Line(name, _spellName.DefaultColor)]; }; } private void UpdateEndowment() { ClientObject? endowment = _objects.GetEquippedBy(_playerGuid()) .FirstOrDefault(item => (item.CurrentlyEquippedLocation & EquipMask.Held) != 0 && (item.Type & ItemType.Caster) != 0 && item.SpellId.GetValueOrDefault() != 0u); _endowmentItemId = endowment?.ObjectId ?? 0u; _endowmentSpellId = endowment?.SpellId ?? 0u; _endowmentHost.Visible = endowment is not null; _endowmentSlot.EntryId = _endowmentItemId; _endowmentSlot.CatalogIconTexture = _endowmentSpellId == 0u ? 0u : _resolveSpellIcon(_endowmentSpellId); _endowmentSlot.CatalogOverlayTexture = endowment is null ? 0u : _resolveItemDragIcon(endowment); string spellName = _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata metadata) ? metadata.Name : $"Spell {_endowmentSpellId}"; _endowmentSlot.Label = endowment is null ? string.Empty : $"{endowment.GetAppropriateName()} ({spellName})"; for (int tab = 0; tab < 8; tab++) { if (_endowmentItemId != 0u && _selected[tab] is null) _endowmentSelected[tab] = true; else if (_endowmentItemId == 0u && _endowmentSelected[tab]) _endowmentSelected[tab] = false; } } private static IEnumerable Descendants(UiElement root) { foreach (UiElement child in root.Children) { yield return child; foreach (UiElement nested in Descendants(child)) yield return nested; } } public void OnShown() => SyncSelection(); public void Dispose() { if (_disposed) return; _disposed = true; _spellbook.SpellbookChanged -= OnSpellbookChanged; _selection.Changed -= OnSelectionChanged; _objects.ObjectAdded -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectRemoved -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; foreach (UiElement tab in _tabs) SetClick(tab, null); foreach (UiItemList? list in _lists) { if (list is null) continue; list.PrimaryCatalogEntryPressed = null; list.ExamineCatalogEntryRequested = null; } _cast.OnClick = null; } private static void SetClick(UiElement element, Action? action) { element.ClickThrough = action is null; switch (element) { case UiButton button: button.OnClick = action; break; case UiText text: text.OnClick = action; break; case UiDatElement dat: dat.OnClick = action; break; } } private static void SetSelected(UiElement element, bool selected) { if (element is IUiDatStateful stateful && stateful.TrySetRetailState(selected ? RetailUiStateIds.Open : RetailUiStateIds.Closed)) return; if (element is UiButton button) button.Selected = selected; else if (element is UiText text) text.DefaultColor = selected ? new System.Numerics.Vector4(1f, 1f, 1f, 1f) : new System.Numerics.Vector4(0.65f, 0.65f, 0.65f, 1f); } } public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId); /// /// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag, /// lifting this payload never removes anything from its source collection. /// public sealed record SpellbookShortcutDragPayload(uint SpellId); internal static class FavoriteListExtensions { public static int IndexOf(this IReadOnlyList values, uint value) { for (int i = 0; i < values.Count; i++) if (values[i] == value) return i; return -1; } }