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

File diff suppressed because one or more lines are too long

View file

@ -85,6 +85,114 @@ internal static class RetailSkillFormula
_ => result,
};
}
/// <summary>
/// Retail <c>SkillSystem::InqAttributeName @ 0x005c8d90</c> — the six
/// hardcoded attribute display names (matched exactly against
/// <c>DatReaderWriter.Enums.AttributeId</c>'s Strength=1..Self=6
/// numbering, the same table <see cref="ResolveAttribute"/>-style
/// switches elsewhere in this file already assume).
/// </summary>
public static string AttributeName(DatReaderWriter.Enums.AttributeId attribute) => attribute switch
{
DatReaderWriter.Enums.AttributeId.Strength => "Strength",
DatReaderWriter.Enums.AttributeId.Endurance => "Endurance",
DatReaderWriter.Enums.AttributeId.Quickness => "Quickness",
DatReaderWriter.Enums.AttributeId.Coordination => "Coordination",
DatReaderWriter.Enums.AttributeId.Focus => "Focus",
DatReaderWriter.Enums.AttributeId.Self => "Self",
_ => string.Empty,
};
/// <summary>
/// Retail <c>SkillSystem::InqSkillFormula @ 0x005c89b0</c> — builds the
/// human-readable formula line shown in a skill's tooltip, e.g.
/// <c>"( (Strength + Coordination) / 2 )"</c>, <c>"( Quickness )"</c>, or
/// <c>"( (2 x Quickness) )"</c>. Ported byte-for-byte from the retail
/// binary's string pool: the five short literal fragments below
/// (<c>data_7e7930</c> = <c>" )"</c>, <c>data_7e7934</c> = <c>"+%u"</c>,
/// <c>data_7e7940</c> = <c>" + "</c>, <c>data_7e7950</c> = <c>"("</c>,
/// <c>data_7e7954</c> = <c>"( "</c>, <c>data_797584</c> = <c>")"</c>)
/// sit between two vtable declarations in the pseudo-C dump and Binary
/// Ninja's type inference never recognized them as strings, so they show
/// up unlabeled rather than as readable literals — this port decoded
/// their raw bytes directly as narrow ASCII (the function operates
/// exclusively on <c>AC1Legacy::PStringBase&lt;char&gt;</c>, so 1
/// byte/char, not the 2-byte/char wide encoding used elsewhere in this
/// file's neighborhood). <c>" / %u"</c> (divisor) and <c>"(%u x %s)"</c>
/// (multiplier wrap) are plain, directly-visible literals in the same
/// function and needed no such recovery. Returns null when the skill has
/// neither attribute wired (<c>_x &lt; 1 || _attr1 == 0</c> AND the
/// attr2 equivalent), matching <c>InqSkillFormula</c>'s own false
/// return.
/// </summary>
public static string? FormatFormula(SkillFormula formula)
{
ArgumentNullException.ThrowIfNull(formula);
bool hasAttr1 = formula.Attribute1Multiplier >= 1
&& formula.Attribute1 != 0;
bool hasAttr2 = formula.Attribute2Multiplier >= 1
&& formula.Attribute2 != 0;
if (!hasAttr1 && !hasAttr2)
return null;
var text = new System.Text.StringBuilder("( ");
if (hasAttr1 && hasAttr2)
text.Append('(');
if (hasAttr1)
{
string name1 = AttributeName(formula.Attribute1);
text.Append(formula.Attribute1Multiplier <= 1
? name1
: $"({formula.Attribute1Multiplier} x {name1})");
if (hasAttr2)
text.Append(" + ");
}
if (hasAttr2)
{
string name2 = AttributeName(formula.Attribute2);
text.Append(formula.Attribute2Multiplier <= 1
? name2
: $"({formula.Attribute2Multiplier} x {name2})");
}
if (hasAttr1 && hasAttr2)
text.Append(')');
if (formula.Divisor != 1)
text.Append($" / {formula.Divisor}");
if (formula.AdditiveBonus != 0)
text.Append($"+{formula.AdditiveBonus}");
text.Append(" )");
return text.ToString();
}
/// <summary>
/// Retail <c>SkillInfoRegion::GetTooltip @ 0x004f1fe0</c>, called once
/// from <c>SkillInfoRegion::SkillInfoRegion @ 0x004f2140</c>'s
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is exactly
/// <c>"\n" + formula + description</c> — retail concatenates the
/// description directly onto the formula line with NO separator between
/// them (ported verbatim, not "fixed": <c>append_n_chars</c> runs
/// immediately after the formula assignment with no intervening
/// literal). <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
/// <c>SkillBase._description</c> — the same DAT field
/// <see cref="DatReaderWriter.Types.SkillBase.Description"/> already
/// exposes, so no hand-transcription was needed for the ~30+ skill
/// description strings (unlike the six hardcoded attribute
/// descriptions).
/// </summary>
public static string? BuildTooltip(SkillBase skillBase)
{
ArgumentNullException.ThrowIfNull(skillBase);
string? formula = FormatFormula(skillBase.Formula);
string description = skillBase.Description.Value ?? string.Empty;
string tooltip = (formula is null ? string.Empty : "\n" + formula) + description;
return tooltip.Length == 0 ? null : tooltip;
}
}
/// <summary>

View file

@ -228,4 +228,11 @@ public sealed record CharacterSkill(
// retail SkillInfoRegion::GetVitaeModifier (0x004f0fa0). Used for the
// footer-title vitae-specific parenthetical, separate from the buff delta
// (CurrentLevel VitaeModifier BaseLevel).
int VitaeModifier = 0);
int VitaeModifier = 0,
// TS-85 (character-panel tooltips): retail SkillInfoRegion::GetTooltip
// (0x004f1fe0), composed once at row construction — formula line + skill
// description, DAT-sourced via SkillBase.Description/Formula
// (RetailSkillFormula.BuildTooltip). Null when the DAT SkillTable had no
// entry for this skill (fallback-named skills) or GetTooltip would have
// produced empty text.
string? TooltipText = null);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.App.Net;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
@ -382,6 +383,10 @@ public sealed class CharacterSheetProvider
int specializedCost = skillBase?.SpecializedCost ?? 0;
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0) — formula line +
// DAT description, composed once here (matches retail's once-at-
// construction SetTooltip; the row never recomputes it per frame).
string? tooltipText = skillBase is null ? null : RetailSkillFormula.BuildTooltip(skillBase);
// Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
@ -406,7 +411,8 @@ public sealed class CharacterSheetProvider
specializedCost,
raiseCost,
raise10Cost,
values.VitaeModifier));
values.VitaeModifier,
tooltipText));
}
return result;

View file

@ -192,6 +192,43 @@ public static class CharacterStatController
("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6
};
/// <summary>
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttributeDescription
/// @ 0x005c8e30</c> — six hardcoded strings, byte-decoded from the retail binary's
/// string pool (the pseudo-C dump truncates them with "…"). Ported from
/// <c>AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530</c>'s
/// <c>UIElement::SetTooltip</c> call at 0x004f1617, keyed by retail attribute id
/// (matches <see cref="AttrRows"/>' statId column, NOT the array index — the
/// authored row order swaps Coordination/Quickness relative to the id numbering).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> AttributeDescriptions =
new Dictionary<uint, string>
{
[1u] = "Measures your character's muscular power.", // Strength
[2u] = "Measures how healthy your character is.", // Endurance
[3u] = "Measures how fast your character is.", // Quickness
[4u] = "Measures your character's reflexes", // Coordination (no trailing period — verified byte-exact)
[5u] = "Measures your character's mind and senses.", // Focus
[6u] = "Measures your character's willpower.", // Self
};
/// <summary>
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttribute2ndDescription
/// @ 0x005c8f70</c> — three hardcoded strings shared by each Max/Current pair (1&amp;2,
/// 3&amp;4, 5&amp;6), byte-decoded from the retail string pool. Ported from
/// <c>Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680</c>'s
/// <c>UIElement::SetTooltip</c> call at 0x004f1777, keyed by <see cref="VitalRows"/>'
/// maxStatId column (1/3/5 — either member of the pair resolves the same text in
/// retail).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> Attribute2ndDescriptions =
new Dictionary<uint, string>
{
[1u] = "(Endurance/2)\nIf you run out of health, you will die!", // Health
[3u] = "(Endurance)\nAffects your actions and movement.", // Stamina
[5u] = "(Self)\nAffects how much magic you can cast.", // Mana
};
/// <summary>
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
@ -671,7 +708,7 @@ public static class CharacterStatController
for (int i = 0; i < AttrRows.Length; i++)
{
var (rowName, iconDid, _) = AttrRows[i];
var (rowName, iconDid, statId) = AttrRows[i];
int rowIndex = i;
var row = AddRow(list, datFont, spriteResolve,
@ -694,6 +731,7 @@ public static class CharacterStatController
return v.ToString();
},
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId);
row.OnClick = () =>
{
@ -706,7 +744,7 @@ public static class CharacterStatController
for (int i = 0; i < VitalRows.Length; i++)
{
var (rowName, iconDid, _) = VitalRows[i];
var (rowName, iconDid, maxStatId) = VitalRows[i];
int rowIndex = i;
int absIndex = AttrRows.Length + i;
@ -726,6 +764,7 @@ public static class CharacterStatController
};
},
valueColorProvider: () => VitalValueColor(data(), rowIndex));
row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId);
row.OnClick = () =>
{
@ -785,6 +824,9 @@ public static class CharacterStatController
valueProvider: () => LiveSkill().CurrentLevel.ToString(),
valueColorProvider: () => SkillValueColor(LiveSkill()),
nameColor: Vector4.One);
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0), stamped once at
// row construction — matches retail (never recomputed per frame).
row.TooltipText = skill.TooltipText;
row.OnClick = () =>
{
HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10);

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()
{

View file

@ -152,6 +152,20 @@ public class UiClickablePanel : UiPanel
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
public float SelectionBarHeight { get; set; } = 3f;
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
/// character-panel gap: retail's <c>AttributeInfoRegion</c> /
/// <c>Attribute2ndInfoRegion</c> / <c>SkillInfoRegion</c> row constructors
/// (<c>UIElement::SetTooltip</c> at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp
/// this once per row at construction — retail never updates it afterward, so a
/// plain settable string (not a live provider) matches.</summary>
public string? TooltipText { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
public UiClickablePanel()
{
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,

View file

@ -152,6 +152,13 @@ public sealed class SpellcastingUiControllerTests
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();