From 82789eea882afdeb0ccdf194b0384d96b0e80a11 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 13:28:18 +0200 Subject: [PATCH] fix(ui): restore effect detail scrolling Reflow selected Helpful/Harmful spell descriptions through the retained retail text shaper, bind their authored 0x10000127 information scrollbar independently from the list scrollbar, and allow display-only text to consume wheel scrolling without becoming selectable. Format Vitae recovery experience through retail's grouped integer presentation. Release build succeeds and all 5,832 tests pass with five intentional skips. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 5 ++ docs/plans/2026-05-12-milestones.md | 3 + ...-retail-magic-ui-and-casting-pseudocode.md | 9 +++ ...-retail-vitae-character-info-pseudocode.md | 5 ++ .../UI/Layout/EffectsUiController.cs | 36 +++++++----- .../UI/Layout/VitaeUiController.cs | 8 ++- src/AcDream.App/UI/UiText.cs | 13 ++++- .../UI/Layout/EffectsUiControllerTests.cs | 55 ++++++++++++++++++- .../IndicatorDetailPanelControllerTests.cs | 1 + 9 files changed, 117 insertions(+), 18 deletions(-) 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]