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>
This commit is contained in:
Erik 2026-08-17 01:11:22 +02:00
parent c623b57ad3
commit 39c49e140e
8 changed files with 269 additions and 9 deletions

View file

@ -563,11 +563,87 @@ public sealed class SpellcastingUiController : IRetainedPanelController
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()
=> _cast.Enabled = _endowmentSelected[_activeTab]
? _endowmentItemId != 0u
: _selected[_activeTab] is uint spellId
&& _casting.IsTargetReady(spellId);
{
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
{
(bool enabled, string? tooltip) = ComputeEndowmentCastState();
_cast.Enabled = enabled;
_cast.TooltipText = tooltip;
return;
}
if (_selected[_activeTab] is uint spellId)
{
_cast.Enabled = _casting.IsTargetReady(spellId);
// TS-85: the plain-spell branch's exact retail wording (untargeted-
// ready / needs-target-none-selected / needs-target-present) is
// unrecovered — its three SetTooltip format-string operands
// (RecvNotice_UpdateCharacterInformation / _EnableChatTargetSelection
// / _UserPreferenceChanged_Menu) are genuine gmNoticeHandler vtable
// SLOTS (real function pointers at 0x7b5e88-0x7b6130), not the
// unlabeled-string-pool case the endowment branch below hits, so
// they can't be byte-decoded. Shows the bare spell name, which every
// one of that branch's states is confirmed (by the narrow-buffer
// prep right before each sprintf) to carry as a substring.
_cast.TooltipText = _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)
? metadata.Name
: null;
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 itemName = endowment.GetAppropriateName();
if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u))
return (true, $"USE the {itemName}");
uint? targetId = _selection.SelectedObjectId;
if (targetId is null or 0u)
return (false, $"You must select a target for the {itemName}");
string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? itemName;
return (true, $"USE the {itemName} on {targetName}");
}
private void ConfigureSpellName()
{