diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 58d15625..94cc5bf3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -80,6 +80,11 @@ invented character-sheet summary. Character/Vitae authored center surfaces are no longer composited twice, and the end-session button enters its Normal DAT state before hover. +The polish follow-up restores `gmEffectsUI`'s lower information viewport: +selected spell name/description now reflows to the authored width and its own +`0x10000127` scrollbar works independently of the upper effect-list scrollbar. +Vitae recovery XP also uses retail's grouped integer presentation. + **Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`; `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `src/AcDream.Core.Net/WorldSession.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 698b3a3e..fc097404 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -726,6 +726,9 @@ The detail conformance pass also restores Vitae's omitted penalty explanation, ports the exact Character Information report/property meanings, prevents double-compositing authored translucent panel centers, and initializes the end-session control in its visible Normal DAT state before hover. +Helpful/Harmful selected descriptions now use retail text reflow and the +authored lower information scrollbar independently of the spell-list +scrollbar; Vitae recovery XP uses grouped client-language integer formatting. The first connected gate exposed and corrected two retained-import defects: spellbook tabs are authored stateful text controls (not buttons), and retail text starts with zero margins. Real-DAT fixtures now pin spellbook binding, diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md index 53758c54..7f5f0ff6 100644 --- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md +++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md @@ -568,7 +568,9 @@ positive root 0x1000011F (inherits 0x10000122) negative root 0x10000121 (inherits 0x10000122) shared effects template 0x10000122 effect list 0x10000123 +effect-list scrollbar 0x10000124 information text 0x10000126 +information scrollbar 0x10000127 effect row template 0x10000128 (300 x 32) row icon / name / duration 0x10000129 / 0x1000012A / 0x1000012B effect UI type property 0x1000000C @@ -624,6 +626,13 @@ an active token stores its spell ID, so every layer of that spell highlights together and shows the shared name/description; clicking either layer again clears the selection. +`gmEffectsUI::UpdateSelection @ 0x004B7F90` concatenates spell name, a blank +line, and spell description, then passes that one literal to +`UIElement_Text::SetText`. The text element reflows the literal to its authored +280-pixel information width. LayoutDesc `0x2100001B` supplies a separate +scrollbar `0x10000127` for that 78-pixel-high information text; it does not +reuse the effect-list scrollbar `0x10000124`. + `InfoRegion::InfoRegion @ 0x004F1450` creates each row with `UIElement_ListBox::AddItemFromTemplateList(template 0)`. In installed retail data the icon is x=0 width 32, name is x=37 width 188, and duration is x=225 diff --git a/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md b/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md index c340b78a..22696c36 100644 --- a/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md +++ b/docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md @@ -25,6 +25,11 @@ The skills paragraph is not optional. It explains that health, stamina, mana, and skills are reduced by the same percentage and warns about penalties above 15 percent. +Every integer above is inserted with `StringInfo::AddVariable_Int`. Its +`LInt_StringInfoData::ToString @ 0x0042F7E0` calls `NumToString` with grouping +enabled, so recovery XP is rendered with the client language's thousands +separator (for English, `61,000,000`, never `61000000`). + ## Character Information `gmCharacterInfoUI::Update @ 0x004BA790` clears `m_pMainText`, appends six diff --git a/src/AcDream.App/UI/Layout/EffectsUiController.cs b/src/AcDream.App/UI/Layout/EffectsUiController.cs index 772b2af2..3d45a5d5 100644 --- a/src/AcDream.App/UI/Layout/EffectsUiController.cs +++ b/src/AcDream.App/UI/Layout/EffectsUiController.cs @@ -16,8 +16,9 @@ public sealed class EffectsUiController : IRetainedPanelController public const uint NegativeRootId = 0x10000121u; public const uint CloseId = 0x100000FCu; public const uint ListId = 0x10000123u; - public const uint ScrollbarId = 0x10000124u; + public const uint ListScrollbarId = 0x10000124u; public const uint InfoTextId = 0x10000126u; + public const uint InfoScrollbarId = 0x10000127u; public const uint RowTemplateId = 0x10000128u; public const uint RowIconId = 0x10000129u; public const uint RowLabelId = 0x1000012Au; @@ -32,7 +33,8 @@ public sealed class EffectsUiController : IRetainedPanelController private readonly UiItemList _list; private readonly UiText? _info; private readonly UiButton? _close; - private readonly UiScrollbar? _scrollbar; + private readonly UiScrollbar? _listScrollbar; + private readonly UiScrollbar? _infoScrollbar; private readonly Dictionary _rows = new(); private uint? _selectedSpellId; private double _lastDurationUpdate = double.NaN; @@ -60,13 +62,14 @@ public sealed class EffectsUiController : IRetainedPanelController _list = list; _info = layout.FindElement(InfoTextId) as UiText; _close = layout.FindElement(CloseId) as UiButton; - _scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar; + _listScrollbar = layout.FindElement(ListScrollbarId) as UiScrollbar; + _infoScrollbar = layout.FindElement(InfoScrollbarId) as UiScrollbar; if (_close is not null) _close.OnClick = close; _list.Columns = 1; _list.CellWidth = templates.Width; _list.CellHeight = templates.Height; - if (_scrollbar is not null) - _scrollbar.Model = _list.Scroll; + if (_listScrollbar is not null) + _listScrollbar.Model = _list.Scroll; ConfigureInfo(); _spellbook.EnchantmentsChanged += Rebuild; Rebuild(); @@ -198,21 +201,25 @@ public sealed class EffectsUiController : IRetainedPanelController { if (_info is null) return; _info.PreserveEndOnLayout = false; + _info.WheelScrollEnabled = true; + _info.ClickThrough = false; + if (_infoScrollbar is not null) + _infoScrollbar.Model = _info.Scroll; _info.LinesProvider = () => { if (_selectedSpellId is not uint spellId) - return [new UiText.Line(_selectPrompt, _info.DefaultColor)]; + return IndicatorDetailText.Shape(_info, _selectPrompt); ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot .Cast() .FirstOrDefault(record => record?.SpellId == spellId); if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata)) - return [new UiText.Line(_selectPrompt, _info.DefaultColor)]; - return - [ - new UiText.Line(metadata.Name, _info.DefaultColor), - new UiText.Line(string.Empty, _info.DefaultColor), - new UiText.Line(metadata.Description, _info.DefaultColor), - ]; + return IndicatorDetailText.Shape(_info, _selectPrompt); + // gmEffectsUI::UpdateSelection @ 0x004B7F90 supplies one literal + // name + blank line + description to UIElement_Text::SetText. The + // text widget reflows it to the authored information width. + return IndicatorDetailText.Shape( + _info, + metadata.Name + "\n\n" + metadata.Description); }; } @@ -224,6 +231,7 @@ public sealed class EffectsUiController : IRetainedPanelController _disposed = true; _spellbook.EnchantmentsChanged -= Rebuild; if (_close is not null) _close.OnClick = null; - if (_scrollbar is not null) _scrollbar.Model = null; + if (_listScrollbar is not null) _listScrollbar.Model = null; + if (_infoScrollbar is not null) _infoScrollbar.Model = null; } } diff --git a/src/AcDream.App/UI/Layout/VitaeUiController.cs b/src/AcDream.App/UI/Layout/VitaeUiController.cs index 8dc2a8ca..518205b3 100644 --- a/src/AcDream.App/UI/Layout/VitaeUiController.cs +++ b/src/AcDream.App/UI/Layout/VitaeUiController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using AcDream.Core.Items; using AcDream.Core.Spells; @@ -109,7 +110,9 @@ public sealed class VitaeUiController : IRetainedPanelController + lostPercent + _strings.SkillsSuffix + _strings.RecoveryPrefix - + remaining + // StringInfo::AddVariable_Int -> LInt_StringInfoData::ToString + // @ 0x0042F7E0 always enables localized digit grouping. + + FormatExperience(remaining) + _strings.RecoverySuffix; } _lines = IndicatorDetailText.Shape(_mainText, body); @@ -133,6 +136,9 @@ public sealed class VitaeUiController : IRetainedPanelController * Math.Pow(vitae, 5d) + 0.5d); + internal static string FormatExperience(int experience) + => experience.ToString("N0", CultureInfo.InvariantCulture); + public void Dispose() { if (_disposed) return; diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 5fc596d0..df54eea1 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -24,7 +24,8 @@ public sealed class UiText : UiElement, IUiDatStateful { /// Optional base-element click notice used by authored text tabs. public Action? OnClick { get; set; } - public override bool HandlesClick => OnClick is not null || base.HandlesClick; + public override bool HandlesClick + => OnClick is not null || WheelScrollEnabled || base.HandlesClick; /// Dat element id for imported UIElement_Text widgets. 0 for synthesized text. public uint ElementId { get; set; } @@ -159,6 +160,14 @@ public sealed class UiText : UiElement, IUiDatStateful /// public bool PreserveEndOnLayout { get; set; } = true; + /// + /// Allows a display-only text surface to consume mouse-wheel input without + /// making its text selectable/editable. Retail text scrolling and text + /// selection are independent capabilities; authored report/detail fields + /// commonly expose a scrollbar while remaining non-selectable. + /// + public bool WheelScrollEnabled { get; set; } + private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── @@ -551,7 +560,7 @@ public sealed class UiText : UiElement, IUiDatStateful { case UiEventType.Scroll: { - if (!Selectable) return false; + if (!Selectable && !WheelScrollEnabled) return false; // Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY. // ScrollByLines sign: +down/newer, -up/older. // e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines. diff --git a/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs index e1e83757..86c0122f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs @@ -107,6 +107,58 @@ public sealed class EffectsUiControllerTests Assert.False(second.Selected); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SelectedEffect_WrapsDescription_AndBindsAuthoredInfoScrollbar( + bool positive) + { + string flags = positive ? "0x4" : "0x0"; + var table = SpellTable.LoadFromReader(new StringReader( + "Spell ID,Name,Description,Flags [Hex]\n" + + $"42,Boon,This description contains enough words to wrap across several lines.,{flags}\n")); + var spellbook = new Spellbook(table); + ImportedLayout layout = LayoutImporter.Build( + positive + ? FixtureLoader.LoadPositiveEffectsInfos() + : FixtureLoader.LoadNegativeEffectsInfos(), + NoTex, + datFont: null); + UiText info = Assert.IsType( + layout.FindElement(EffectsUiController.InfoTextId)); + info.Width = 96f; + + using EffectsUiController controller = EffectsUiController.Bind( + layout, spellbook, positive, () => 0d, NoTex, id => id, + Templates(), "SELECT A SPELL")!; + spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( + 42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 42u)); + UiElement listHost = Assert.IsType( + layout.FindElement(EffectsUiController.ListId)); + UiItemList list = Assert.IsType(Assert.Single(listHost.Children)); + UiTemplateListSlot row = Assert.IsType(list.GetItem(0)); + row.OnEvent(new UiEvent(0, row, UiEventType.Click)); + + IReadOnlyList lines = info.LinesProvider(); + Assert.Equal("Boon", lines[0].Text); + Assert.Equal(string.Empty, lines[1].Text); + Assert.True(lines.Count > 3); + Assert.All(lines, line => Assert.True(line.Text.Length <= 12)); + Assert.True(info.WheelScrollEnabled); + Assert.False(info.ClickThrough); + UiScrollbar listScrollbar = Assert.IsType( + layout.FindElement(EffectsUiController.ListScrollbarId)); + UiScrollbar infoScrollbar = Assert.IsType( + layout.FindElement(EffectsUiController.InfoScrollbarId)); + Assert.Same(list.Scroll, listScrollbar.Model); + Assert.Same(info.Scroll, infoScrollbar.Model); + + info.Scroll.SetExtents(contentHeight: 100, viewHeight: 20); + Assert.True(info.OnEvent(new UiEvent( + 0, info, UiEventType.Scroll, Data0: -1))); + Assert.Equal(info.Scroll.LineHeight, info.Scroll.ScrollY); + } + [Fact] public void PermanentEffect_LeavesRetailDurationColumnEmpty() { @@ -179,7 +231,8 @@ public sealed class EffectsUiControllerTests Assert.NotNull(controller); Assert.NotNull(layout.FindElement(EffectsUiController.ListId)); Assert.IsType(layout.FindElement(EffectsUiController.InfoTextId)); - Assert.IsType(layout.FindElement(EffectsUiController.ScrollbarId)); + Assert.IsType(layout.FindElement(EffectsUiController.ListScrollbarId)); + Assert.IsType(layout.FindElement(EffectsUiController.InfoScrollbarId)); Assert.Equal(expectedRoot, layout.Root.DatElementId); UiButton close = Assert.IsType(layout.FindElement(EffectsUiController.CloseId)); close.OnEvent(new UiEvent(0, close, UiEventType.Click)); diff --git a/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs index 7f7aa848..95245ea5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/IndicatorDetailPanelControllerTests.cs @@ -131,6 +131,7 @@ public sealed class IndicatorDetailPanelControllerTests StringComparison.Ordinal); Assert.Contains("earn 379 more experience", text, StringComparison.Ordinal); Assert.Equal(479, VitaeUiController.VitaeCpPoolThreshold(0.9f, 10)); + Assert.Equal("61,000,000", VitaeUiController.FormatExperience(61_000_000)); } [Fact]