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:
parent
353ae3bb0c
commit
4a24614fd1
4 changed files with 344 additions and 30 deletions
|
|
@ -718,6 +718,170 @@ public sealed class SpellcastingUiControllerTests
|
|||
Assert.Null(selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
// ── Night-round review F3/F4/F7: cast-button tooltip states ────────────
|
||||
//
|
||||
// gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30, byte-decoded
|
||||
// (see SpellcastingUiController.ComputeSpellCastState/
|
||||
// ComposeEndowmentName's own doc comments). These pin the four
|
||||
// plain-spell states (two pre-existed only as a bare-name fallback; the
|
||||
// two DISABLED states are genuinely new coverage per F7) and the
|
||||
// endowment branch's composed-name fix (F4).
|
||||
|
||||
[Fact]
|
||||
public void CastAvailability_UntargetedSpell_IsEnabled_WithCastSpellNameTooltip()
|
||||
{
|
||||
SpellMetadata spell = BuildSpell(
|
||||
42u, "Test Untargeted", isUntargeted: true, isSelfTargeted: false, targetMask: 0u);
|
||||
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||
spellbook.OnSpellLearned(42u);
|
||||
spellbook.SetFavorite(0, 0, 42u);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
|
||||
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||
|
||||
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.True(cast.Enabled);
|
||||
Assert.Equal("CAST Test Untargeted", cast.TooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CastAvailability_TargetedSpell_CompatibleTargetSelected_AppendsOnTargetName()
|
||||
{
|
||||
SpellMetadata spell = BuildSpell(
|
||||
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||
spellbook.OnSpellLearned(42u);
|
||||
spellbook.SetFavorite(0, 0, 42u);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
|
||||
var selection = new SelectionState();
|
||||
var operations = new ConfigurableSpellCastOperations { TargetCompatible = true };
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
|
||||
using SpellcastingUiController controller = Bind(
|
||||
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
|
||||
selection.Select(5u, SelectionChangeSource.World);
|
||||
|
||||
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.True(cast.Enabled);
|
||||
Assert.Equal("CAST Test Targeted on Drudge", cast.TooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CastAvailability_TargetedSpell_NoTargetSelected_IsDisabled_WithNeedsTargetTooltip()
|
||||
{
|
||||
// F7: this DISABLED state was previously untested — the bare-name
|
||||
// fallback the old code shipped never distinguished it from the
|
||||
// ready/enabled case.
|
||||
SpellMetadata spell = BuildSpell(
|
||||
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||
spellbook.OnSpellLearned(42u);
|
||||
spellbook.SetFavorite(0, 0, 42u);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
|
||||
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||
|
||||
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.False(cast.Enabled);
|
||||
Assert.Equal("You must select a target for Test Targeted", cast.TooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CastAvailability_TargetedSpell_IncompatibleTargetSelected_IsDisabled_WithNeedsAppropriateTargetTooltip()
|
||||
{
|
||||
// F7: this DISABLED state was previously untested.
|
||||
SpellMetadata spell = BuildSpell(
|
||||
42u, "Test Targeted", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||
spellbook.OnSpellLearned(42u);
|
||||
spellbook.SetFavorite(0, 0, 42u);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 5u, Name = "Drudge" });
|
||||
var selection = new SelectionState();
|
||||
var operations = new ConfigurableSpellCastOperations { TargetCompatible = false };
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
|
||||
using SpellcastingUiController controller = Bind(
|
||||
layout, spellbook, objects, _ => { }, selection: selection, operations: operations)!;
|
||||
selection.Select(5u, SelectionChangeSource.World);
|
||||
|
||||
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.False(cast.Enabled);
|
||||
Assert.Equal("You must select an appropriate target for Test Targeted", cast.TooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CastAvailability_EndowmentSelfTarget_ComposesItemAndSpellName()
|
||||
{
|
||||
SpellMetadata spell = BuildSpell(
|
||||
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 2u,
|
||||
Name = "Lightning Wand",
|
||||
Type = ItemType.Caster,
|
||||
WielderId = 1u,
|
||||
CurrentlyEquippedLocation = EquipMask.Held,
|
||||
SpellId = 2670u,
|
||||
// ItemUseability.Self shifted into the TARGET half.
|
||||
Useability = ItemUseability.Self << 16,
|
||||
});
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
|
||||
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||
|
||||
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.True(cast.Enabled);
|
||||
Assert.Equal("USE the Lightning Wand (Lightning Bolt VI)", cast.TooltipText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CastAvailability_EndowmentNeedsTarget_NoTargetSelected_ComposesItemAndSpellName()
|
||||
{
|
||||
SpellMetadata spell = BuildSpell(
|
||||
2670u, "Lightning Bolt VI", isUntargeted: false, isSelfTargeted: false, targetMask: 1u);
|
||||
var spellbook = new Spellbook(SpellTable.Create([spell]));
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 2u,
|
||||
Name = "Lightning Wand",
|
||||
Type = ItemType.Caster,
|
||||
WielderId = 1u,
|
||||
CurrentlyEquippedLocation = EquipMask.Held,
|
||||
SpellId = 2670u,
|
||||
// Remote (not Self) shifted into the TARGET half — requires an
|
||||
// external target, same as retail's non-self-castable wands.
|
||||
Useability = ItemUseability.Remote << 16,
|
||||
});
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
|
||||
using SpellcastingUiController controller = Bind(layout, spellbook, objects, _ => { })!;
|
||||
|
||||
var cast = Assert.IsType<UiButton>(layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.False(cast.Enabled);
|
||||
Assert.Equal(
|
||||
"You must select a target for the Lightning Wand (Lightning Bolt VI)",
|
||||
cast.TooltipText);
|
||||
}
|
||||
|
||||
private static SpellcastingUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
|
|
@ -727,13 +891,14 @@ public sealed class SpellcastingUiControllerTests
|
|||
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||
uint emptySlotSprite = 0u,
|
||||
SelectionState? selection = null,
|
||||
Action<uint>? examineSpell = null)
|
||||
Action<uint>? examineSpell = null,
|
||||
IRuntimeSpellCastOperations? operations = null)
|
||||
{
|
||||
SelectionState selectionState = selection ?? new SelectionState();
|
||||
var casting = new RuntimeSpellCastState(
|
||||
spellbook,
|
||||
selectionState,
|
||||
new NoopSpellCastOperations());
|
||||
operations ?? new NoopSpellCastOperations());
|
||||
return SpellcastingUiController.Bind(
|
||||
layout, spellbook, casting, objects, () => 1u,
|
||||
spellId => spellId,
|
||||
|
|
@ -767,6 +932,57 @@ public sealed class SpellcastingUiControllerTests
|
|||
public void IncrementBusy() { }
|
||||
}
|
||||
|
||||
/// <summary>Same as <see cref="NoopSpellCastOperations"/> but with a
|
||||
/// settable target-compatibility answer — the night-round review
|
||||
/// (F3/F7) cast-button-tooltip tests need to force both the
|
||||
/// compatible and incompatible target branches.</summary>
|
||||
private sealed class ConfigurableSpellCastOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public bool TargetCompatible = true;
|
||||
public uint LocalPlayerId => 1u;
|
||||
public bool CanSend => true;
|
||||
public bool HasRequiredComponents(uint spellId) => true;
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => TargetCompatible;
|
||||
public void StopCompletely() { }
|
||||
public void SendUntargeted(uint spellId) { }
|
||||
public void SendTargeted(uint targetId, uint spellId) { }
|
||||
public void DisplayMessage(string message) { }
|
||||
public void IncrementBusy() { }
|
||||
}
|
||||
|
||||
/// <summary>Minimal <see cref="SpellMetadata"/> builder for cast-gate
|
||||
/// tests — most of the record's fields are irrelevant to
|
||||
/// <see cref="RuntimeSpellCastState.EvaluateCastGate"/>.</summary>
|
||||
private static SpellMetadata BuildSpell(
|
||||
uint spellId, string name, bool isUntargeted, bool isSelfTargeted, uint targetMask) =>
|
||||
new(
|
||||
SpellId: spellId,
|
||||
Name: name,
|
||||
School: "Life",
|
||||
Family: 0u,
|
||||
IconId: 0u,
|
||||
SpellWords: "",
|
||||
Duration: 0f,
|
||||
ManaCost: 0,
|
||||
IsDebuff: false,
|
||||
IsFellowship: false,
|
||||
Description: "",
|
||||
SortKey: 0,
|
||||
Difficulty: 0,
|
||||
Flags: isSelfTargeted ? (uint)SpellFlags.SelfTargeted : 0u,
|
||||
Generation: 1,
|
||||
IsFastWindup: false,
|
||||
IsOffensive: false,
|
||||
IsUntargeted: isUntargeted,
|
||||
Speed: 0f,
|
||||
CasterEffect: 0u,
|
||||
TargetEffect: 0u,
|
||||
TargetMask: targetMask,
|
||||
SpellType: 0);
|
||||
|
||||
private static void ApplyAnchors(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue