diff --git a/src/AcDream.App/UI/Layout/IndicatorBarController.cs b/src/AcDream.App/UI/Layout/IndicatorBarController.cs index 6f4becf7..f0e4d4fe 100644 --- a/src/AcDream.App/UI/Layout/IndicatorBarController.cs +++ b/src/AcDream.App/UI/Layout/IndicatorBarController.cs @@ -30,6 +30,14 @@ public sealed class IndicatorBarController : IRetainedPanelController public const uint LinkButtonId = 0x100000F8u; public const uint EndCharacterSessionButtonId = 0x100000FAu; + /// + /// The press-highlight overlay every indicator button authors as its own + /// child: a full-size (20x20) Type-3 element whose DirectState carries a + /// draw-nothing File=0 image and whose only other state, Normal_pressed, + /// carries the green selector sprite 0x06004CE8. + /// + public const uint PressHighlightId = 0x100000F2u; + public const uint UnencumberedState = 14u; public const uint EncumberedState = 15u; public const uint HeavilyEncumberedState = 16u; @@ -64,6 +72,65 @@ public sealed class IndicatorBarController : IRetainedPanelController Disconnected = 4, } + /// + /// Re-attaches the green press-highlight each indicator button authors as a + /// child. + /// + /// + /// + /// drops a button's dat children + /// at import — correct for caption/face art, which the button draws itself, + /// but this child is neither. It is a real overlay carrying its own media, + /// so swallowing it loses the press feedback entirely: the buttons author + /// Normal_pressed with PassToChildren, and the cascade then + /// has nothing left to reach. + /// + /// + /// Same class of loss, and the same repair, as the map hotspot's + /// rollover-highlight child — see 's + /// BuildTownMarkers. Attaching is safe because the child's DirectState + /// authors a File=0 draw-nothing image, so it is invisible until the + /// button's own press state cascades into it. + /// + /// + public void AttachPressHighlights( + ElementInfo layoutRoot, Func build) + { + foreach ((UiButton button, uint id) in Indicators()) + { + if (FindInfo(layoutRoot, id) is not { } info) + continue; + foreach (ElementInfo child in info.Children) + { + if (child.Id != PressHighlightId) + continue; + if (build(child) is { } highlight) + button.AddChild(highlight); + } + } + } + + private (UiButton Button, uint Id)[] Indicators() => + [ + (_link, LinkButtonId), + (_helpful, HelpfulButtonId), + (_harmful, HarmfulButtonId), + (_vitae, VitaeButtonId), + (_burden, BurdenButtonId), + (_miniGame, MiniGameButtonId), + (_endCharacterSession, EndCharacterSessionButtonId), + ]; + + private static ElementInfo? FindInfo(ElementInfo root, uint id) + { + if (root.Id == id) + return root; + foreach (ElementInfo child in root.Children) + if (FindInfo(child, id) is { } found) + return found; + return null; + } + private IndicatorBarController( IndicatorBarBindings bindings, UiButton link, diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs index 7a408d8d..a4f52b15 100644 --- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs +++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs @@ -57,6 +57,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController private readonly Action _addFavorite; private readonly Action _sendFilter; private readonly Action _removeSpell; + private readonly Action? _examineSpell; private readonly Action> _showConfirmation; private readonly Action _setDesiredComponent; private readonly Action _close; @@ -93,6 +94,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController Action addFavorite, Action sendFilter, Action removeSpell, + Action? examineSpell, Action> showConfirmation, Action setDesiredComponent, Action close, @@ -120,6 +122,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController _addFavorite = addFavorite; _sendFilter = sendFilter; _removeSpell = removeSpell; + _examineSpell = examineSpell; _showConfirmation = showConfirmation; _setDesiredComponent = setDesiredComponent; _close = close; @@ -177,6 +180,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController Action addFavorite, Action sendFilter, Action removeSpell, + Action? examineSpell, Action> showConfirmation, Action setDesiredComponent, Action close, @@ -213,7 +217,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController layout, spellbook, objects, playerGuid, components, selection, resolveSpellIcon, resolveComponentIcon, spellLevel, selectObject, - addFavorite, sendFilter, removeSpell, showConfirmation, + addFavorite, sendFilter, removeSpell, examineSpell, showConfirmation, setDesiredComponent, close, spellPage, componentPage, spellTab, componentTab, closeButton, deleteButton, spellList, componentList, componentTemplates, @@ -231,6 +235,10 @@ public sealed class SpellbookWindowController : IRetainedPanelController private void ConfigureSpellList(ImportedLayout layout) { + // Right-click a spell row to assess it, into the same examination window + // an item or creature uses: retail's UIElement_ItemList right-click + // branch @ 0x004E4F1F -> ClientUISystem::ExamineSpell @ 0x00564A70. + _spellList.ExamineCatalogEntryRequested = _examineSpell; _spellList.Columns = 1; _spellList.CellWidth = Math.Max(1f, _spellList.Width); _spellList.CellHeight = _rowStyle.Height; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index c7ffb0ff..9e7e9db9 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1267,6 +1267,22 @@ public sealed class RetailUiRuntime : IDisposable return windowId != 0; } + /// + /// Builds one dat child that a behavioral widget's + /// dropped at import, so a + /// controller can re-attach it. Same seam as the map hotspots' AD-108 + /// icon/highlight rebuild. + /// + private UiElement? BuildSwallowedChild(ElementInfo info) + { + lock (_bindings.Assets.DatLock) + return LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont).Root; + } + private ImportedLayout? Import(uint layoutId) { lock (_bindings.Assets.DatLock) @@ -1921,6 +1937,7 @@ public sealed class RetailUiRuntime : IDisposable spellId => SpellcastingUiController?.AddFavorite(spellId), _bindings.Magic.SendSpellbookFilter, _bindings.Magic.RemoveSpell, + spellId => AppraisalController?.ExamineSpell(spellId), (message, completed) => ShowConfirmation(message, completed), _bindings.Magic.SetDesiredComponent, () => CloseWindow(WindowNames.Spellbook), @@ -2434,6 +2451,17 @@ public sealed class RetailUiRuntime : IDisposable } IndicatorBarController = controller; + + // Restore the green press selector the buttons author as swallowed + // children (see IndicatorBarController.AttachPressHighlights). + lock (_bindings.Assets.DatLock) + { + ElementInfo? infos = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, IndicatorBarController.LayoutId); + if (infos is not null) + controller.AttachPressHighlights(infos, BuildSwallowedChild); + } + RetailWindowFrame.Mount( Host.Root, layout.Root, diff --git a/src/AcDream.App/UI/UiCatalogSlot.cs b/src/AcDream.App/UI/UiCatalogSlot.cs index 61a5befd..0220af8b 100644 --- a/src/AcDream.App/UI/UiCatalogSlot.cs +++ b/src/AcDream.App/UI/UiCatalogSlot.cs @@ -93,9 +93,18 @@ public sealed class UiCatalogSlot : UiItemSlot DoubleClicked?.Invoke(); return true; case UiEventType.RightClick: - if (EntryId != 0u - && FindList() is { ExamineCatalogEntryRequested: { } examine }) - examine(EntryId); + // Retail examines from the LIST, generically: + // UIElement_ItemList::ListenToElementMessage @ 0x004E4ECA takes + // the right-click branch and calls ExamineObject for a row with + // an itemID, ExamineSpell for one with a spellID. + // + // Returning true unconditionally here swallowed the event on any + // list that had not wired the seam -- the spellbook among them -- + // so report it unhandled instead and let UiRoot keep bubbling. + if (EntryId == 0u + || FindList() is not { ExamineCatalogEntryRequested: { } examine }) + return false; + examine(EntryId); return true; case UiEventType.DragBegin: if (e.Payload is not null) DragBegan?.Invoke(e.Payload); diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index 235428f3..e9f7b13d 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -40,9 +40,15 @@ public sealed class BuffSettings 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. + /// The vital regeneration rates — Regeneration (health), Rejuvenation + /// (stamina), Mana Renewal (mana). Cast last, as the tail of a pass. + /// + public bool BuffRegeneration { get; set; } = true; + + /// + /// Anything else self-targeted with a duration. 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; } @@ -122,6 +128,7 @@ public static class BuffPlan BuffTargetKind.Protection => settings.BuffProtections, BuffTargetKind.Aura => settings.BuffAuras, BuffTargetKind.Bane => settings.BuffBanes, + BuffTargetKind.Regeneration => settings.BuffRegeneration, BuffTargetKind.Other => settings.BuffOther, _ => false, }; @@ -187,7 +194,8 @@ public static class BuffPlan /// /// /// Item Enchantment — the banes and weapon auras. - /// Life Magic last — the protections and Armor Self. + /// Life Magic last — the protections and Armor Self, then + /// the vital regeneration rates to finish. /// /// /// Willpower is matched as "Self". Retail's spell is named Willpower @@ -206,11 +214,23 @@ public static class BuffPlan _ => 3, // war/void and anything unschooled trail the rest }; - // Only the creature group has an internal order; the other groups are - // cast in whatever order the tiebreak gives. - return (school * 10) + (school == 0 ? CreatureOrder(line) : 0); + int within = school switch + { + 0 => CreatureOrder(line), + 2 => LifeOrder(line), + _ => 0, // the item group has no internal order + }; + return (school * 10) + within; } + /// + /// Inside Life Magic the protections go first and the vital regeneration + /// rates finish the pass — they are the buffs that matter least if mana + /// runs out, and the ones a character most often wants topped up last. + /// + private static int LifeOrder(BuffLine line) => + line.Kind == BuffTargetKind.Regeneration ? 1 : 0; + private static int CreatureOrder(BuffLine line) { if (line.Kind == BuffTargetKind.Skill diff --git a/src/AcDream.Plugins.MossTank/BuffProfile.cs b/src/AcDream.Plugins.MossTank/BuffProfile.cs index b13f3712..b8e7d326 100644 --- a/src/AcDream.Plugins.MossTank/BuffProfile.cs +++ b/src/AcDream.Plugins.MossTank/BuffProfile.cs @@ -27,7 +27,13 @@ public enum BuffTargetKind /// though it is, in effect, a self buff. /// Bane, - /// Any other self-targeted duration buff (regeneration and friends). + /// + /// A vital regeneration rate buff: Regeneration (health), Rejuvenation + /// (stamina), Mana Renewal (mana), and their Empyrean/Prodigal kin. All + /// Life Magic, and cast at the very end of a pass. + /// + Regeneration, + /// Any other self-targeted duration buff. Other, } @@ -84,9 +90,13 @@ public static partial class BuffProfile /// 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. + /// + /// "magic casting implement's" is the wand buff Aura of Hermetic Link, and + /// it is the ONLY one of retail's six aura lines that none of the other + /// alternatives reach -- it was silently landing in Other. /// [GeneratedRegex( - @"\b(a weapon's|weapon or magic caster|magic caster|missile weapon's)\b", + @"\b(a weapon's|weapon or magic caster|magic caster|missile weapon's|magic casting implement's)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex AuraPattern(); @@ -101,6 +111,36 @@ public static partial class BuffProfile RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex BanePattern(); + /// + /// The three vital-rate lines, matched one vital at a time. Retail words + /// each of them differently and two of the six phrasings do not even begin + /// with "Increases the caster's": + /// + /// Increase caster's natural healing rate by 10%. (Regeneration) + /// Increases your Health Regeneration Rate by 50%. (Empyrean) + /// Increases the rate at which the caster regains Stamina by 10%. (Rejuvenation) + /// Increases your Stamina Regeneration Rate by 50%. (Empyrean) + /// Increases the caster's natural mana rate by 10%. (Mana Renewal) + /// Increases your Mana Regeneration Rate by 50%. (Empyrean) + /// + /// Note "Increase", not "Increases", in the health line — retail's own typo, + /// which is exactly why matching a strict sentence shape lost these. + /// + [GeneratedRegex( + "natural healing rate|Health Regeneration Rate", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex HealthRegenPattern(); + + [GeneratedRegex( + "rate at which the caster regains Stamina|Stamina Regeneration Rate", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex StaminaRegenPattern(); + + [GeneratedRegex( + "natural mana rate|Mana Regeneration Rate", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ManaRegenPattern(); + /// /// Retail's spell text says "Assess Monster" where the skill table says /// "Assess Creature". Without this the skill silently never matches and its @@ -178,6 +218,30 @@ public static partial class BuffProfile return; } + // Regeneration before the generic "Increases the caster's X by N" match + // further down, which would otherwise read Mana Renewal as a buff to a + // stat named "natural mana rate". + if (HealthRegenPattern().IsMatch(description)) + { + kind = BuffTargetKind.Regeneration; + target = "Health"; + return; + } + + if (StaminaRegenPattern().IsMatch(description)) + { + kind = BuffTargetKind.Regeneration; + target = "Stamina"; + return; + } + + if (ManaRegenPattern().IsMatch(description)) + { + kind = BuffTargetKind.Regeneration; + target = "Mana"; + return; + } + // Auras next: "Increases a weapon's damage value" would otherwise be // read as raising something on the caster. if (AuraPattern().IsMatch(description)) diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 532e5321..30b39259 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -128,6 +128,8 @@ internal sealed class MossTankPanel $"Buff weapon auras: {OnOff(_buffSettings.BuffAuras)}"; public string BanesText => $"Buff banes (armor): {OnOff(_buffSettings.BuffBanes)}"; + public string RegenerationText => + $"Buff regen rates: {OnOff(_buffSettings.BuffRegeneration)}"; public string OtherText => $"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}"; @@ -159,6 +161,8 @@ internal sealed class MossTankPanel _buffSettings.BuffProtections = !_buffSettings.BuffProtections; public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras; public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes; + public Action ToggleRegeneration => + () => _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration; public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther; private static double Step(double value, int direction) => diff --git a/src/AcDream.Plugins.MossTank/mosstank-settings.xml b/src/AcDream.Plugins.MossTank/mosstank-settings.xml index 0e2a2317..47bf4a27 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. --> - +