diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs
index 90e929ed..7ad88d74 100644
--- a/src/AcDream.App/Plugins/AppAutomationSurface.cs
+++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs
@@ -160,7 +160,11 @@ internal sealed class AppAutomationSurface
{
if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
continue;
- if (!meta.IsSelfTargeted || !meta.IsBeneficial || meta.IsDebuff)
+ // Beneficial and not a debuff is the whole filter. Requiring the
+ // self-targeted flag here is what hid every bane: they are cast by
+ // selecting yourself, and the flag only says "needs no selection".
+ // Whether a given target accepts the spell is EvaluateGate's job.
+ if (!meta.IsBeneficial || meta.IsDebuff || meta.IsUntargeted)
continue;
built.Add(Project(meta));
}
@@ -236,6 +240,17 @@ internal sealed class AppAutomationSurface
// ── ICharacterInfo ────────────────────────────────────────────────────
public bool IsInWorld => IsAvailable;
+ public uint ObjectId
+ {
+ get
+ {
+ GameRuntime? runtime;
+ lock (_gate)
+ runtime = _runtime;
+ return runtime?.Lifecycle.PlayerGuid ?? 0u;
+ }
+ }
+
public uint CurrentHealth => Vital(LocalPlayerState.VitalKind.Health).Current;
public uint MaxHealth => Vital(LocalPlayerState.VitalKind.Health).Maximum;
public uint CurrentStamina => Vital(LocalPlayerState.VitalKind.Stamina).Current;
diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs
index ab0db948..23a0a00c 100644
--- a/src/AcDream.Plugin.Abstractions/Automation.cs
+++ b/src/AcDream.Plugin.Abstractions/Automation.cs
@@ -93,6 +93,14 @@ public interface ICharacterInfo
{
bool IsInWorld { get; }
+ ///
+ /// The local player's own object id, or 0 when not in world. Needed to
+ /// target yourself: retail's banes are Item Enchantments whose description
+ /// says "Target yourself to cast this spell on all of your equipped armor",
+ /// so a plugin has to select the player before casting them.
+ ///
+ uint ObjectId { get; }
+
uint CurrentHealth { get; }
uint MaxHealth { get; }
uint CurrentStamina { get; }
@@ -119,9 +127,18 @@ public interface ICharacterInfo
public interface ISpellCatalog
{
///
- /// Every spell in the character's spellbook that targets self and is
- /// beneficial.
+ /// Every beneficial spell in the character's spellbook that can be cast on
+ /// the player.
///
+ ///
+ /// This is NOT "spells with the self-targeted flag". That flag means the
+ /// spell needs no selection; it does not mean a spell without it cannot be
+ /// cast on you. Banes carry no self flag yet are cast by targeting
+ /// yourself, so filtering on the flag silently loses every one of them.
+ /// Spells needing a selection are included here, and
+ /// decides whether the current
+ /// target actually accepts them.
+ ///
IReadOnlyList KnownSelfBuffs { get; }
bool TryGet(uint spellId, out PluginSpellInfo info);
@@ -196,6 +213,7 @@ public sealed class NoOpAutomationSurface
}
public bool IsInWorld => false;
+ public uint ObjectId => 0;
public uint CurrentHealth => 0;
public uint MaxHealth => 0;
public uint CurrentStamina => 0;
diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs
index fa8c7604..ee808bf8 100644
--- a/src/AcDream.Plugins.MossTank/BuffPlan.cs
+++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs
@@ -21,6 +21,31 @@ public sealed class BuffSettings
/// Buff every attribute (VTank's default).
public bool BuffAttributes { get; set; } = true;
+ ///
+ /// The elemental/physical protections and Armor Self. VTank keeps these in
+ /// their own profile (BuffProfile_Prots) and casts them by default.
+ ///
+ public bool BuffProtections { get; set; } = true;
+
+ ///
+ /// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift
+ /// Killer, Defender, Spirit Drinker.
+ ///
+ public bool BuffAuras { get; set; } = true;
+
+ ///
+ /// Banes — armour resistance, cast by targeting yourself. VTank keeps them
+ /// in their own profile (BuffProfile_Banes) and casts them by default.
+ ///
+ public bool BuffBanes { get; set; } = true;
+
+ ///
+ /// Anything else self-targeted with a duration (regeneration and friends).
+ /// Off by default: useful to some characters, wasted mana for others, and
+ /// it is the bucket anything unrecognised falls into.
+ ///
+ public bool BuffOther { get; set; }
+
///
/// Buff trained and specialised skills only — VTank's stated default:
/// "automatically buffs every Attribute and Skill you have trained".
@@ -94,6 +119,10 @@ public static class BuffPlan
BuffTargetKind.Attribute =>
settings.BuffAttributes && attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName),
+ BuffTargetKind.Protection => settings.BuffProtections,
+ BuffTargetKind.Aura => settings.BuffAuras,
+ BuffTargetKind.Bane => settings.BuffBanes,
+ BuffTargetKind.Other => settings.BuffOther,
_ => false,
};
if (!wanted)
diff --git a/src/AcDream.Plugins.MossTank/BuffProfile.cs b/src/AcDream.Plugins.MossTank/BuffProfile.cs
index 47143e06..b13f3712 100644
--- a/src/AcDream.Plugins.MossTank/BuffProfile.cs
+++ b/src/AcDream.Plugins.MossTank/BuffProfile.cs
@@ -3,15 +3,35 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
-/// What a buff line raises.
+/// What a buff line does, which is also how it is toggled.
public enum BuffTargetKind
{
Unknown = 0,
+ /// Raises a skill: "Increases the caster's Life Magic skill by 10 points."
Skill,
+ /// Raises an attribute: "Increases the caster's Strength by 10 points."
Attribute,
+ ///
+ /// Defensive self-buff: the elemental/physical protections, and Armor Self.
+ ///
+ Protection,
+ ///
+ /// A self-cast aura that buffs the wielded weapon or caster — Blood Drinker,
+ /// Heart Seeker, Swift Killer, Defender, Spirit Drinker.
+ ///
+ Aura,
+ ///
+ /// A bane: an Item Enchantment raising armour resistance. Cast by selecting
+ /// YOURSELF — retail's own description says "Target yourself to cast this
+ /// spell on all of your equipped armor" — so it needs a selection even
+ /// though it is, in effect, a self buff.
+ ///
+ Bane,
+ /// Any other self-targeted duration buff (regeneration and friends).
+ Other,
}
-/// One buff line: a family, what it raises, and its known tiers.
+/// One buff line: a family, what it does, and its known tiers.
public sealed record BuffLine(
uint Family,
BuffTargetKind Kind,
@@ -19,26 +39,33 @@ public sealed record BuffLine(
List Tiers);
///
-/// Works out which stat each known self-buff raises, straight from retail data.
+/// Works out what each known self-buff does, straight from retail data.
///
///
///
/// The client's spell table carries no link between a spell and the stat it
/// modifies — that arrives from the server with the enchantment. But retail
-/// writes it in the spell's own description:
+/// writes it in the spell's own description, so the classification is derived
+/// from shipped data rather than hard-coded:
///
///
-/// Increases the caster's Life Magic skill by 10 points.
-/// Increases the caster's Strength by 10 points.
+/// Increases the caster's Life Magic skill by 10 points. -> Skill
+/// Increases the caster's Strength by 10 points. -> Attribute
+/// Reduces damage the caster takes from Fire by 9%. -> Protection
+/// Increases the caster's natural armor by 20 points. -> Protection
+/// Increases a weapon's damage value by 2 points. -> Aura
///
///
-/// So the mapping is derived from shipped data rather than hard-coded, which
-/// matters because the naming is genuinely irregular and no rule would cover
-/// it: Invulnerability raises Melee Defense, Impregnability
-/// raises Missile Defense, Fealty raises Loyalty, Sprint raises
-/// Run, Arcane Enlightenment raises Arcane Lore, and — the one that
-/// would silently poison any name-matching scheme — the spell line called
-/// Willpower raises the attribute named Self.
+/// The irregular naming is why this reads descriptions instead of names:
+/// Invulnerability raises Melee Defense, Impregnability raises
+/// Missile Defense, Fealty raises Loyalty, Sprint raises Run, and
+/// the line called Willpower raises the attribute named Self.
+///
+///
+/// Banes are included. They carry no self-targeted flag, but that flag
+/// means "needs no selection", not "cannot be cast on you": retail's own text
+/// says "Target yourself to cast this spell on all of your equipped armor". So
+/// they are classified here and the caller selects the player before casting.
///
///
public static partial class BuffProfile
@@ -48,6 +75,32 @@ public static partial class BuffProfile
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex IncreasesPattern();
+ [GeneratedRegex(
+ @"^Reduces damage (?:the caster|you) takes? from (?.+?) by ",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ProtectionPattern();
+
+ ///
+ /// Retail's own wording for the weapon/caster auras. Matched on the
+ /// description rather than the "Aura of" name prefix so the older
+ /// non-aura phrasings classify the same way.
+ ///
+ [GeneratedRegex(
+ @"\b(a weapon's|weapon or magic caster|magic caster|missile weapon's)\b",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex AuraPattern();
+
+ ///
+ /// Banes, matched on retail's own instruction rather than on the word
+ /// "Bane": "Target yourself to cast this spell on all of your equipped
+ /// armor." That sentence is what says these are cast at the player, which
+ /// the self-targeted flag does not.
+ ///
+ [GeneratedRegex(
+ @"Target yourself to cast this spell on all of your equipped",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BanePattern();
+
///
/// Retail's spell text says "Assess Monster" where the skill table says
/// "Assess Creature". Without this the skill silently never matches and its
@@ -59,9 +112,16 @@ public static partial class BuffProfile
["Assess Monster"] = "Assess Creature",
};
+ /// The six primary attributes, by the names retail's spells use.
+ private static readonly HashSet AttributeNames =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self",
+ };
+
///
/// Group the character's known self-buffs into buff lines, keeping only
- /// lines that raise a stat and last long enough to be worth maintaining.
+ /// those that last long enough to be worth maintaining.
///
public static List Build(IReadOnlyList knownSelfBuffs)
{
@@ -75,8 +135,16 @@ public static partial class BuffProfile
if (spell.DurationSeconds <= 0f)
continue;
- if (!TryParseTarget(spell.Description, out BuffTargetKind kind, out string target))
- continue;
+ Classify(spell.Description, out BuffTargetKind kind, out string target);
+ if (kind == BuffTargetKind.Unknown)
+ {
+ // Nothing self-targeted is discarded for being unrecognised.
+ // Dropping what the patterns do not match is how protections
+ // and weapon auras went missing without a word; an unknown
+ // spell belongs in Other, which the user can switch on.
+ kind = BuffTargetKind.Other;
+ target = spell.Name;
+ }
if (!byFamily.TryGetValue(spell.Family, out BuffLine? line))
{
@@ -92,28 +160,77 @@ public static partial class BuffProfile
return byFamily.Values.ToList();
}
- /// Parse "Increases the caster's X [skill] by N points."
- public static bool TryParseTarget(
+ /// Classify one spell from its retail description.
+ public static void Classify(
string? description, out BuffTargetKind kind, out string target)
{
kind = BuffTargetKind.Unknown;
target = string.Empty;
- if (string.IsNullOrEmpty(description))
- return false;
+ if (string.IsNullOrWhiteSpace(description))
+ return;
- Match match = IncreasesPattern().Match(description);
- if (!match.Success)
- return false;
+ // Banes first: their text also mentions armour resistance, and the
+ // "target yourself" instruction is what actually identifies them.
+ if (BanePattern().IsMatch(description))
+ {
+ kind = BuffTargetKind.Bane;
+ target = "equipped armor";
+ return;
+ }
- target = match.Groups["target"].Value.Trim();
+ // Auras next: "Increases a weapon's damage value" would otherwise be
+ // read as raising something on the caster.
+ if (AuraPattern().IsMatch(description))
+ {
+ kind = BuffTargetKind.Aura;
+ target = "weapon";
+ return;
+ }
+
+ Match protection = ProtectionPattern().Match(description);
+ if (protection.Success)
+ {
+ kind = BuffTargetKind.Protection;
+ target = protection.Groups["target"].Value.Trim();
+ return;
+ }
+
+ Match increases = IncreasesPattern().Match(description);
+ if (!increases.Success)
+ return;
+
+ target = increases.Groups["target"].Value.Trim();
if (target.Length == 0)
- return false;
- if (SkillNameAliases.TryGetValue(target, out string? alias))
- target = alias;
+ return;
- // The word "skill" is what separates a skill buff from an attribute
- // buff in retail's own wording.
- kind = match.Groups["skill"].Success ? BuffTargetKind.Skill : BuffTargetKind.Attribute;
- return true;
+ if (increases.Groups["skill"].Success)
+ {
+ // The word "skill" is what separates a skill buff from an attribute
+ // buff in retail's own wording.
+ kind = BuffTargetKind.Skill;
+ if (SkillNameAliases.TryGetValue(target, out string? alias))
+ target = alias;
+ return;
+ }
+
+ if (AttributeNames.Contains(target))
+ {
+ kind = BuffTargetKind.Attribute;
+ return;
+ }
+
+ // "Increases the caster's natural armor by 20 points" — defensive, but
+ // neither a skill nor an attribute.
+ kind = target.Contains("armor", StringComparison.OrdinalIgnoreCase)
+ ? BuffTargetKind.Protection
+ : BuffTargetKind.Other;
+ }
+
+ /// Back-compatible shim for the skill/attribute cases.
+ public static bool TryParseTarget(
+ string? description, out BuffTargetKind kind, out string target)
+ {
+ Classify(description, out kind, out target);
+ return kind != BuffTargetKind.Unknown;
}
}
diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
index 95e71546..532e5321 100644
--- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
@@ -35,6 +35,12 @@ internal sealed class MossTankPanel
private int _castThisPass;
private string _status = "Idle.";
+ ///
+ /// What the player had selected before the pass, so targeting yourself for
+ /// banes does not quietly steal the selection and leave it changed.
+ ///
+ private uint? _selectionBeforePass;
+
public MossTankPanel(IPluginHost host) => _host = host;
// ── main panel bindings ───────────────────────────────────────────────
@@ -116,6 +122,14 @@ internal sealed class MossTankPanel
$"Trained skills only: {OnOff(_buffSettings.BuffTrainedSkillsOnly)}";
public string AttributesText =>
$"Buff attributes: {OnOff(_buffSettings.BuffAttributes)}";
+ public string ProtectionsText =>
+ $"Buff protections: {OnOff(_buffSettings.BuffProtections)}";
+ public string AurasText =>
+ $"Buff weapon auras: {OnOff(_buffSettings.BuffAuras)}";
+ public string BanesText =>
+ $"Buff banes (armor): {OnOff(_buffSettings.BuffBanes)}";
+ public string OtherText =>
+ $"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}";
public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty =
Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5);
@@ -141,6 +155,11 @@ internal sealed class MossTankPanel
_buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly;
public Action ToggleAttributes => () =>
_buffSettings.BuffAttributes = !_buffSettings.BuffAttributes;
+ public Action ToggleProtections => () =>
+ _buffSettings.BuffProtections = !_buffSettings.BuffProtections;
+ public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras;
+ public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes;
+ public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther;
private static double Step(double value, int direction) =>
Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0);
@@ -179,6 +198,7 @@ internal sealed class MossTankPanel
_queueIndex = 0;
_castThisPass = 0;
_sinceProgress = 0;
+ _selectionBeforePass = _host.Selection.SelectedObjectId;
_running = _queue.Count > 0;
_status = _queue.Count == 0
? "Nothing to buff."
@@ -195,6 +215,16 @@ internal sealed class MossTankPanel
_queue = new List();
_queueIndex = 0;
_status = status;
+ RestoreSelection();
+ }
+
+ private void RestoreSelection()
+ {
+ if (_selectionBeforePass is { } previous && previous != 0)
+ _host.Selection.Select(previous);
+ else
+ _host.Selection.Clear();
+ _selectionBeforePass = null;
}
private List BuildPlan(IAutomationSurface automation, bool force) =>
@@ -286,6 +316,22 @@ internal sealed class MossTankPanel
private bool TryCast(
IAutomationSurface automation, PluginSpellInfo spell, string label)
{
+ // A spell without the self-targeted flag still needs a target, and for
+ // a bane that target is the player: retail's text is "Target yourself
+ // to cast this spell on all of your equipped armor". Select first, or
+ // the gate refuses for want of a target.
+ if (!spell.IsSelfTargeted)
+ {
+ uint self = automation.Character.ObjectId;
+ if (self == 0)
+ {
+ _status = $"{spell.Name}: no self target";
+ return false;
+ }
+ if (_host.Selection.SelectedObjectId != self)
+ _host.Selection.Select(self);
+ }
+
PluginCastGate gate = automation.Magic.EvaluateGate(spell.SpellId);
if (gate != PluginCastGate.Ready)
{
diff --git a/src/AcDream.Plugins.MossTank/mosstank-settings.xml b/src/AcDream.Plugins.MossTank/mosstank-settings.xml
index 3f5f8fbe..0e2a2317 100644
--- a/src/AcDream.Plugins.MossTank/mosstank-settings.xml
+++ b/src/AcDream.Plugins.MossTank/mosstank-settings.xml
@@ -4,7 +4,7 @@
vocabulary, and the toggle is just another Action.
Adjuster buttons rather than typed fields, because editable text in a
plugin panel needs keyboard routing plumbed through first. -->
-
+
@@ -28,11 +28,23 @@
-
-
+
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs b/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs
index ef1889ac..a5ee5d79 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs
@@ -54,9 +54,92 @@ public class BuffPlanTests
Assert.Equal(expectedTarget, target);
}
+ [Theory]
+ // Protections are self-buffs, but retail words them as damage reduction --
+ // the "Increases the caster's..." pattern alone silently dropped every one.
+ [InlineData("Reduces damage the caster takes from Fire by 9%.",
+ BuffTargetKind.Protection)]
+ [InlineData("Increases the caster's natural armor by 20 points.",
+ BuffTargetKind.Protection)]
+ // The self-cast weapon/caster auras: Blood Drinker, Heart Seeker, and kin.
+ [InlineData("Increases a weapon's damage value by 2 points.", BuffTargetKind.Aura)]
+ [InlineData("Improves a weapon's speed by 10 points.", BuffTargetKind.Aura)]
+ [InlineData("Increases the Melee Defense skill modifier of a weapon or magic caster by 3%.",
+ BuffTargetKind.Aura)]
+ [InlineData("Increases the elemental damage bonus of an elemental magic caster by 1%.",
+ BuffTargetKind.Aura)]
+ public void ClassifiesProtectionsAndAuras(string description, BuffTargetKind expected)
+ {
+ BuffProfile.Classify(description, out BuffTargetKind kind, out _);
+ Assert.Equal(expected, kind);
+ }
+
+ [Fact]
+ public void ProtectionsAndAurasAreCastWhenEnabledAndSkippedWhenNot()
+ {
+ var lines = Lines(
+ Spell(1, 109, 1, "Reduces damage the caster takes from Fire by 9%."),
+ Spell(2, 154, 1, "Increases a weapon's damage value by 2 points."));
+
+ var all = BuffPlan.Build(lines, Array.Empty(),
+ Array.Empty(), Array.Empty(), Default);
+ Assert.Equal(2, all.Count);
+
+ var none = BuffPlan.Build(lines, Array.Empty(),
+ Array.Empty(), Array.Empty(),
+ new BuffSettings { BuffProtections = false, BuffAuras = false });
+ Assert.Empty(none);
+ }
+
+ [Fact]
+ public void BanesAreClassifiedFromRetailsTargetYourselfInstruction()
+ {
+ // Banes carry no self-targeted flag, but retail's own text says how they
+ // are cast. Filtering on the flag is what hid every one of them.
+ const string bane =
+ "Increases a shield or piece of armor's resistance to slashing damage by 10%. "
+ + "Target yourself to cast this spell on all of your equipped armor.";
+
+ BuffProfile.Classify(bane, out BuffTargetKind kind, out _);
+ Assert.Equal(BuffTargetKind.Bane, kind);
+ }
+
+ [Fact]
+ public void BanesAreCastWhenEnabledAndSkippedWhenNot()
+ {
+ var bane = new PluginSpellInfo(
+ 1, "Blade Bane I", Family: 174, Tier: 1, Difficulty: 50, ManaCost: 10,
+ DurationSeconds: 1800f, School: CreatureEnchantmentSkill,
+ Description: "Increases a shield or piece of armor's resistance to slashing "
+ + "damage by 10%. Target yourself to cast this spell on all of your equipped armor.",
+ IsSelfTargeted: false, IsBeneficial: true);
+ var lines = BuffProfile.Build(new[] { bane });
+
+ Assert.Single(BuffPlan.Build(lines, Array.Empty(),
+ Array.Empty(), Array.Empty(), Default));
+ Assert.Empty(BuffPlan.Build(lines, Array.Empty(),
+ Array.Empty(), Array.Empty(),
+ new BuffSettings { BuffBanes = false }));
+ }
+
+ [Fact]
+ public void UnrecognisedSelfBuffsFallIntoOtherAndAreOffByDefault()
+ {
+ var lines = Lines(
+ Spell(1, 93, 1, "Restores 10 points of the caster's Health over 20 seconds."));
+
+ Assert.Equal(BuffTargetKind.Other, lines[0].Kind);
+ Assert.Empty(BuffPlan.Build(lines, Array.Empty(),
+ Array.Empty(), Array.Empty(), Default));
+ Assert.Single(BuffPlan.Build(lines, Array.Empty(),
+ Array.Empty(), Array.Empty(),
+ new BuffSettings { BuffOther = true }));
+ }
+
[Fact]
public void IgnoresSpellsWhoseDescriptionSaysNothingAboutAStat()
{
+ // A vital transfer describes a drain, not a buff.
Assert.False(BuffProfile.TryParseTarget(
"Drains one-half of the caster's Stamina and gives 90% of that to his/her Mana.",
out _, out _));
diff --git a/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs
index 3debc8b5..8a55e24b 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs
@@ -14,6 +14,7 @@ public class VitalPlanTests
private sealed class Character : ICharacterInfo
{
public bool IsInWorld => true;
+ public uint ObjectId => 1u;
public uint CurrentHealth { get; init; }
public uint MaxHealth { get; init; } = 100;
public uint CurrentStamina { get; init; }
diff --git a/tools/SpellDump/Program.cs b/tools/SpellDump/Program.cs
index 052ff9e4..d6b4c6fd 100644
--- a/tools/SpellDump/Program.cs
+++ b/tools/SpellDump/Program.cs
@@ -16,6 +16,39 @@ MagicCatalog catalog = MagicCatalog.Load(adapter);
SpellTable table = catalog.SpellTable;
Console.WriteLine($"spells loaded: {table.Count}");
+if (args.Length > 0 && args[0] == "--cursors")
+{
+ // Resolve the retail global-cursor enum table (6) to DAT surface ids, the
+ // same walk RetailCursorResolver does: portal master map -> table 6 -> id.
+ uint masterDid = (uint)dats.Portal.Header.MasterMapId;
+ if (!dats.Portal.TryGet(masterDid, out var master) || master is null)
+ throw new InvalidOperationException("no master enum map");
+ if (!master.ClientEnumToID.TryGetValue(6u, out uint cursorMapDid))
+ throw new InvalidOperationException("no cursor enum table 6");
+ if (!dats.Portal.TryGet(cursorMapDid, out var cursorMap) || cursorMap is null)
+ throw new InvalidOperationException("cursor map missing");
+ foreach (var kv in cursorMap.ClientEnumToID.OrderBy(k => k.Key))
+ Console.WriteLine($"cursorEnum 0x{kv.Key:X2} -> surface 0x{kv.Value:X8}");
+ return;
+}
+
+if (args.Length > 0 && args[0] == "--flags")
+{
+ string want = args.Length > 1 ? args[1].ToLowerInvariant() : "bane";
+ foreach (uint id in table.SpellIds.OrderBy(i => i))
+ {
+ if (!table.TryGet(id, out var m)) continue;
+ if (!m.Name.ToLowerInvariant().Contains(want)) continue;
+ if (m.Generation != 1 && !m.Name.Contains(" I", StringComparison.Ordinal)) continue;
+ Console.WriteLine(
+ $"0x{m.SpellId:X4} fam{m.Family,-5} gen{m.Generation,-3} " +
+ $"flags=0x{m.Flags:X4} mask=0x{m.TargetMask:X4} " +
+ $"self={m.IsSelfTargeted,-5} unt={m.IsUntargeted,-5} ben={m.IsBeneficial,-5} " +
+ $"school={m.School,-20} {m.Name,-28} | {m.Description}");
+ }
+ return;
+}
+
if (args.Length > 0 && args[0] == "--desc")
{
foreach (uint id in table.SpellIds.OrderBy(i => i))