fix(ui): night-round review — F3/F4/F7 cast-button tooltip strings

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 04:39:17 +02:00
parent 353ae3bb0c
commit 4a24614fd1
4 changed files with 344 additions and 30 deletions

View file

@ -582,20 +582,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
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;
(bool enabled, string? tooltip) = ComputeSpellCastState(spellId);
_cast.Enabled = enabled;
_cast.TooltipText = tooltip;
return;
}
@ -633,16 +622,85 @@ public sealed class SpellcastingUiController : IRetainedPanelController
if (endowment is null)
return (false, null);
string itemName = endowment.GetAppropriateName();
string composedName = ComposeEndowmentName(endowment);
if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u))
return (true, $"USE the {itemName}");
return (true, $"USE the {composedName}");
uint? targetId = _selection.SelectedObjectId;
if (targetId is null or 0u)
return (false, $"You must select a target for the {itemName}");
return (false, $"You must select a target for the {composedName}");
string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? itemName;
return (true, $"USE the {itemName} on {targetName}");
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()

View file

@ -50,18 +50,34 @@ public sealed class RuntimeSpellCastState
public uint? LastRequestedTargetId { get; private set; }
public event Action? StateChanged;
public bool IsTargetReady(uint spellId)
public bool IsTargetReady(uint spellId) =>
EvaluateCastGate(spellId)
is SpellCastGate.NoTargetNeeded or SpellCastGate.TargetCompatible;
/// <summary>
/// <c>gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30</c>'s
/// plain-spell branch (<c>m_endowmentItemID == 0</c>) gate, split out
/// (night-round review F3) so the tooltip presenter can distinguish
/// retail's four states rather than just the enabled/disabled boolean
/// <see cref="IsTargetReady"/> collapses them to. Byte-decoded call
/// sites: the untargeted/self-cast branch at <c>0x4c6f35</c>, the
/// targeted-and-compatible branch at <c>0x4c6e57</c>
/// (<c>ClientMagicSystem::ObjectCompatibleWithSpell @0x567c30</c>), and
/// the two disabled tails at <c>0x4c6f04</c> (incompatible) and
/// <c>0x4c6e2b</c> (nothing selected).
/// </summary>
public SpellCastGate EvaluateCastGate(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
return SpellCastGate.Unknown;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selection.SelectedObjectId is uint target and not 0u
&& _operations.IsTargetCompatible(
target,
spell,
showMessage: false);
return SpellCastGate.NoTargetNeeded;
if (_selection.SelectedObjectId is not (uint target and not 0u))
return SpellCastGate.NoTargetSelected;
return _operations.IsTargetCompatible(target, spell, showMessage: false)
? SpellCastGate.TargetCompatible
: SpellCastGate.TargetIncompatible;
}
public CastRequestResult Cast(uint spellId)
@ -159,3 +175,27 @@ public enum CastRequestResult
MissingComponents,
Unavailable,
}
/// <summary>
/// The four retail cast-button states <c>gmSpellcastingUI::
/// UpdateCastButtonTooltip @0x004c6a30</c>'s plain-spell branch
/// distinguishes — see <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.
/// </summary>
public enum SpellCastGate
{
/// <summary>Spell metadata is missing / not known.</summary>
Unknown,
/// <summary>Untargeted, self-targeted, or no target mask — always
/// castable. Retail: <c>"CAST %hs"</c> @0x7b63a4.</summary>
NoTargetNeeded,
/// <summary>A target is selected and compatible. Retail: <c>"CAST
/// %hs"</c> @0x7b63a4, then <c>" on %s"</c> @0x7b6464 appended with
/// the target's name.</summary>
TargetCompatible,
/// <summary>A target is selected but incompatible. Retail: <c>"You
/// must select an appropriate target for %hs"</c> @0x7b6348.</summary>
TargetIncompatible,
/// <summary>No target is selected. Retail: <c>"You must select a
/// target for %hs"</c> @0x7b63b8.</summary>
NoTargetSelected,
}