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
File diff suppressed because one or more lines are too long
|
|
@ -582,20 +582,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
|
|
||||||
if (_selected[_activeTab] is uint spellId)
|
if (_selected[_activeTab] is uint spellId)
|
||||||
{
|
{
|
||||||
_cast.Enabled = _casting.IsTargetReady(spellId);
|
(bool enabled, string? tooltip) = ComputeSpellCastState(spellId);
|
||||||
// TS-85: the plain-spell branch's exact retail wording (untargeted-
|
_cast.Enabled = enabled;
|
||||||
// ready / needs-target-none-selected / needs-target-present) is
|
_cast.TooltipText = tooltip;
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,16 +622,85 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
if (endowment is null)
|
if (endowment is null)
|
||||||
return (false, null);
|
return (false, null);
|
||||||
|
|
||||||
string itemName = endowment.GetAppropriateName();
|
string composedName = ComposeEndowmentName(endowment);
|
||||||
if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u))
|
if (ItemUseability.AllowsSelfTarget(endowment.Useability ?? 0u))
|
||||||
return (true, $"USE the {itemName}");
|
return (true, $"USE the {composedName}");
|
||||||
|
|
||||||
uint? targetId = _selection.SelectedObjectId;
|
uint? targetId = _selection.SelectedObjectId;
|
||||||
if (targetId is null or 0u)
|
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;
|
string targetName = _objects.Get(targetId.Value)?.GetAppropriateName() ?? composedName;
|
||||||
return (true, $"USE the {itemName} on {targetName}");
|
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()
|
private void ConfigureSpellName()
|
||||||
|
|
|
||||||
|
|
@ -50,18 +50,34 @@ public sealed class RuntimeSpellCastState
|
||||||
public uint? LastRequestedTargetId { get; private set; }
|
public uint? LastRequestedTargetId { get; private set; }
|
||||||
public event Action? StateChanged;
|
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)
|
if (!_spellbook.Knows(spellId)
|
||||||
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
||||||
return false;
|
return SpellCastGate.Unknown;
|
||||||
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
|
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
|
||||||
return true;
|
return SpellCastGate.NoTargetNeeded;
|
||||||
return _selection.SelectedObjectId is uint target and not 0u
|
if (_selection.SelectedObjectId is not (uint target and not 0u))
|
||||||
&& _operations.IsTargetCompatible(
|
return SpellCastGate.NoTargetSelected;
|
||||||
target,
|
return _operations.IsTargetCompatible(target, spell, showMessage: false)
|
||||||
spell,
|
? SpellCastGate.TargetCompatible
|
||||||
showMessage: false);
|
: SpellCastGate.TargetIncompatible;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CastRequestResult Cast(uint spellId)
|
public CastRequestResult Cast(uint spellId)
|
||||||
|
|
@ -159,3 +175,27 @@ public enum CastRequestResult
|
||||||
MissingComponents,
|
MissingComponents,
|
||||||
Unavailable,
|
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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -718,6 +718,170 @@ public sealed class SpellcastingUiControllerTests
|
||||||
Assert.Null(selection.SelectedObjectId);
|
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(
|
private static SpellcastingUiController? Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
|
|
@ -727,13 +891,14 @@ public sealed class SpellcastingUiControllerTests
|
||||||
UiShortcutDigitGraphics? shortcutDigits = null,
|
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||||
uint emptySlotSprite = 0u,
|
uint emptySlotSprite = 0u,
|
||||||
SelectionState? selection = null,
|
SelectionState? selection = null,
|
||||||
Action<uint>? examineSpell = null)
|
Action<uint>? examineSpell = null,
|
||||||
|
IRuntimeSpellCastOperations? operations = null)
|
||||||
{
|
{
|
||||||
SelectionState selectionState = selection ?? new SelectionState();
|
SelectionState selectionState = selection ?? new SelectionState();
|
||||||
var casting = new RuntimeSpellCastState(
|
var casting = new RuntimeSpellCastState(
|
||||||
spellbook,
|
spellbook,
|
||||||
selectionState,
|
selectionState,
|
||||||
new NoopSpellCastOperations());
|
operations ?? new NoopSpellCastOperations());
|
||||||
return SpellcastingUiController.Bind(
|
return SpellcastingUiController.Bind(
|
||||||
layout, spellbook, casting, objects, () => 1u,
|
layout, spellbook, casting, objects, () => 1u,
|
||||||
spellId => spellId,
|
spellId => spellId,
|
||||||
|
|
@ -767,6 +932,57 @@ public sealed class SpellcastingUiControllerTests
|
||||||
public void IncrementBusy() { }
|
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)
|
private static void ApplyAnchors(UiElement parent)
|
||||||
{
|
{
|
||||||
foreach (UiElement child in parent.Children)
|
foreach (UiElement child in parent.Children)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue