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>
825 lines
34 KiB
C#
825 lines
34 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
|
|
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
|
|
/// request through <see cref="RuntimeSpellCastState"/>.
|
|
/// </summary>
|
|
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<uint> _playerGuid;
|
|
private readonly Func<uint, uint> _resolveSpellIcon;
|
|
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
|
|
private readonly Action<uint> _useItem;
|
|
private readonly Action<uint>? _examineSpell;
|
|
private readonly Action<int, int, uint>? _addFavorite;
|
|
private readonly Action<int, uint>? _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<uint> playerGuid,
|
|
Func<uint, uint> resolveSpellIcon,
|
|
Func<ClientObject, uint> resolveItemDragIcon,
|
|
Action<uint> useItem,
|
|
Action<uint>? examineSpell,
|
|
SelectionState selection,
|
|
Action<int, int, uint>? addFavorite,
|
|
Action<int, uint>? 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<uint> playerGuid,
|
|
Func<uint, uint> resolveSpellIcon,
|
|
Func<ClientObject, uint> resolveItemDragIcon,
|
|
Action<uint> useItem,
|
|
SelectionState selection,
|
|
Action<int, int, uint>? addFavorite,
|
|
Action<int, uint>? removeFavorite,
|
|
UiShortcutDigitGraphics? shortcutDigits = null,
|
|
uint emptySlotSprite = 0u,
|
|
Action<uint>? 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<UiItemList>().FirstOrDefault();
|
|
scrollbars[i] = Descendants(group).OfType<UiScrollbar>().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<uint> 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<uint> 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<uint> 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<uint> 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<uint> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// SpellCastSubMenu::OnItemListDragOver @ 0x004C5990: while a drag hovers a
|
|
/// favorite-bar cell (occupied OR empty — retail's tail cells are UIItems in
|
|
/// the same list), retail sets the authored per-cell DragAccept child
|
|
/// (element 0x1000045A, bound in UIElement_UIItem::PostInit @ 0x004E1870)
|
|
/// to ItemSlot_DragOver_Accept (UIStateId 0x10000040 → authored ring
|
|
/// 0x060011F9) when the dragged payload carries a spell id, and leaves it
|
|
/// neutral otherwise — the handler returns 1, so the generic physical-item
|
|
/// fallback @ 0x004E3492 never runs for this list.
|
|
/// </summary>
|
|
internal static ItemDragAcceptance FavoriteDragOverAcceptance(object payload)
|
|
=> payload is SpellFavoriteDragPayload or SpellbookShortcutDragPayload
|
|
? ItemDragAcceptance.Accept
|
|
: ItemDragAcceptance.None;
|
|
|
|
/// <summary>
|
|
/// THE one favorite-landing computation: the index a
|
|
/// <see cref="SpellFavoriteDragPayload"/> drop on <paramref name="cell"/>
|
|
/// applies. The drag-over Accept ring and <see cref="DropFavorite"/> both key
|
|
/// off the same hovered cell through this method, so the ring can never
|
|
/// promise a different landing than the drop delivers. Two numbering spaces,
|
|
/// both matching retail SpellCastSubMenu::AddFavorite @ 0x004C7060:
|
|
/// an OCCUPIED sibling cell is still numbered against the PRE-lift bar
|
|
/// (Rebuild defers for the gesture — AP-172), so retail's
|
|
/// -1-if-removed-from-before-target adjustment applies (the
|
|
/// RemoveSpellFromMenu-return-gated decrement @ 0x004C7157); an EMPTY tail
|
|
/// cell's index clamps to the LIVE favorite count — a post-lift number whose
|
|
/// lifted spell is already out of the live list, exactly retail's
|
|
/// RemoveSpellFromMenu == -1 no-adjustment case, so applying the -1 there too
|
|
/// would double-correct (the off-by-one this method retired: lifting a
|
|
/// non-last favorite onto the empty tail landed it second-to-last instead of
|
|
/// last).
|
|
/// </summary>
|
|
internal int FavoriteDropIndex(
|
|
SpellFavoriteDragPayload payload, int targetTab, UiItemList list, UiItemSlot cell)
|
|
{
|
|
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();
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30's endowment-item
|
|
/// branch (<c>m_endowmentItemID != 0</c>). Every literal below is directly
|
|
/// visible in the decomp (not the mislabeled-vtable-slot class the
|
|
/// plain-spell branch hits above): <c>"USE the %s"</c> @ data_7b64c0,
|
|
/// <c>"You must select a target for the %s"</c> @ data_7b6478,
|
|
/// <c>" on %s"</c> @ data_7b6464. <c>ItemUses::IsUseable_SelfTarget @
|
|
/// 0x004fcd30</c> is exactly <see cref="ItemUseability.AllowsSelfTarget"/>
|
|
/// (both test the target-mask Self bit after shifting the high word down
|
|
/// 16). NOT ported: the incompatible-target sub-state (<c>"You must select
|
|
/// an appropriate\ntarget for the %s"</c> @ data_7b6400), which retail
|
|
/// derives from <c>ItemHolder::TargetCompatibleWithObject @ 0x00587520</c>
|
|
/// — 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.
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Night-round review F4: the vararg to <c>"USE the %s"</c> (and both
|
|
/// select-target strings above) is NOT the bare item name — retail
|
|
/// builds <c>"%s (%hs)"</c> @0x7b64d8 (item name, spell name) once
|
|
/// at <c>@0x004c6bb6-ef</c> and reuses that composed string as the
|
|
/// shared operand for all three format strings (byte-confirmed: the
|
|
/// three sprintf call sites at <c>0x4c6c7f</c>/<c>0x4c6ca4</c>/
|
|
/// <c>0x4c6d46</c> all read the SAME <c>[esp+0x18]</c> slot). e.g.
|
|
/// "USE the Lightning Wand (Lightning Bolt VI)".
|
|
/// </summary>
|
|
private string ComposeEndowmentName(ClientObject endowment)
|
|
{
|
|
string itemName = endowment.GetAppropriateName();
|
|
return _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata spellMetadata)
|
|
? $"{itemName} ({spellMetadata.Name})"
|
|
: itemName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>gmSpellcastingUI::UpdateCastButtonTooltip @ 0x004c6a30</c>'s
|
|
/// plain-spell branch (<c>m_endowmentItemID == 0</c>, 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 <c>sprintf</c> calls were spurious.
|
|
/// Byte-confirmed pushes: <c>"CAST %hs"</c> @0x7b63a4 at both
|
|
/// <c>0x4c6f5d</c> (untargeted/self-cast, always enabled) and
|
|
/// <c>0x4c6ea4</c> (targeted+compatible, enabled, then <c>" on %s"</c>
|
|
/// @0x7b6464 appended with the target's name at <c>0x4c6ee8</c>);
|
|
/// <c>"You must select an appropriate target for %hs"</c> @0x7b6348 at
|
|
/// <c>0x4c6f18</c> (targeted+incompatible, stays disabled); <c>"You
|
|
/// must select a target for %hs"</c> @0x7b63b8 at <c>0x4c6e48</c> (no
|
|
/// target selected, stays disabled). <c>%hs</c> is the spell's own
|
|
/// name in every case (<c>CSpellBase::InqName</c>, the same call
|
|
/// (<c>0x5bbee0</c>) at all four sites) — no item/composed name
|
|
/// involved here, unlike the endowment branch above.
|
|
/// </summary>
|
|
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<UiElement> 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);
|
|
|
|
/// <summary>
|
|
/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag,
|
|
/// lifting this payload never removes anything from its source collection.
|
|
/// </summary>
|
|
public sealed record SpellbookShortcutDragPayload(uint SpellId);
|
|
|
|
internal static class FavoriteListExtensions
|
|
{
|
|
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
|
|
{
|
|
for (int i = 0; i < values.Count; i++) if (values[i] == value) return i;
|
|
return -1;
|
|
}
|
|
}
|