diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs index 86a18ae3..4226d310 100644 --- a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs @@ -9,8 +9,10 @@ namespace AcDream.App.UI.Layout; /// only, bound directly to the J4.1 owner /// (RuntimeCommunicationState.Friends). Rebuilds on /// change; polled every frame from -/// (cheap — a single -/// comparison when nothing changed). +/// WHILE THE PANEL IS VISIBLE +/// (cheap — a single comparison when nothing +/// changed; see the scrollbar/rebuild-discipline paragraph below for the +/// fix-round visibility gate). /// /// /// D1 — inert actions. This campaign implements no Friends @@ -20,9 +22,10 @@ namespace AcDream.App.UI.Layout; /// "Appear Offline" checkbox (0x1000052C) are left AUTHORED and /// CLICKABLE with no handler — already /// builds every button/checkbox with a null OnClick by default, so -/// no explicit "leave unbound" code is needed here. See this commit's -/// single register row covering the whole D1 Friends/Squelch inert-actions -/// scope (one row, not one per button). +/// no explicit "leave unbound" code is needed here. See register row +/// AD-79 (docs/architecture/retail-divergence-register.md), which +/// covers the whole D1 Friends/Squelch inert-actions scope (one row, not +/// one per button). /// /// /// @@ -31,11 +34,39 @@ namespace AcDream.App.UI.Layout; /// name-text leaf — see that class's doc for why the deepest match is used /// (gmFriendsUI is outside this campaign's decompiled scope). /// +/// +/// +/// Scrollbar (fix-round blast MF-1). The ListBox 0x10000517 +/// authors a direct sibling scrollbar, 0x10000518 — wired to +/// the SAME way every other +/// UiTemplateListBox consumer wires its own scrollbar +/// (CharacterOptionsPageController/ChatOptionsPageController/ +/// ConfigOptionsPageController/KeyboardConfigController). +/// Without it the list has NO wheel fallback ( +/// has no wheel handler) and is completely unreachable past the box's +/// visible extent. +/// +/// +/// +/// Rebuild discipline (fix-round blast SF-2/SF-3). +/// only advances after a successful rebuild — a +/// transient resolver miss ( +/// returning null) leaves the revision UNCONSUMED so the next +/// retries instead of latching an empty list until the +/// NEXT server-side change. also gates +/// this controller's on the panel's own visibility (its +/// IRetainedPanelController.OnShown/OnHidden hooks) — the +/// per-row template resolve takes the shared DAT lock, so no rebuild work +/// runs while the panel is closed. +/// /// public sealed class SocialFriendsPageController { private const uint ListBoxId = 0x10000517u; + /// The ListBox's own linked scrollbar (see class doc). + private const uint ScrollbarElementId = 0x10000518u; + private readonly UiTemplateListBox _listBox; private readonly FriendsState _friends; private long _lastRevision = long.MinValue; @@ -64,6 +95,13 @@ public sealed class SocialFriendsPageController } listBox.TemplateResolver = templateResolver; + if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar) + scrollbar.Model = listBox.Scroll; + else + Console.WriteLine( + $"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} " + + "not found — the Friends list will not scroll."); + var controller = new SocialFriendsPageController(listBox, friends); controller.Refresh(); return controller; @@ -78,17 +116,21 @@ public sealed class SocialFriendsPageController private void Refresh() { - _lastRevision = _friends.Revision; + long revision = _friends.Revision; _listBox.Flush(); + bool allRowsResolved = true; foreach (FriendEntry friend in _friends.Snapshot()) { UiElement? row = _listBox.AddItemFromTemplateList(0); - if (row is null) continue; + if (row is null) { allRowsResolved = false; continue; } if (SocialPanelRowText.FindDeepest(row) is { } text) { string name = friend.Name; text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; } } + // SF-3: only latch the revision once the rebuild actually reflects it — + // a resolver miss must not silently swallow a future revision bump. + if (allRowsResolved) _lastRevision = revision; } } diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index 41326f65..b0e8ad32 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -198,27 +198,72 @@ public sealed class SocialPanelController : IRetainedPanelController public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId); /// True when the Allegiance tab is the active page — lets - /// implement retail's - /// Toggle-action close-on-second-press semantics (same shape as - /// OpenSpellbook's own page-aware toggle). + /// implement the + /// close-on-second-press-of-the-SAME-tab semantics every other + /// Toggle*Panel action already uses (OpenSpellbook's own + /// page-aware toggle is the precedent this follows). Fix-round + /// mechanism SF-1: this is acdream's OWN convention, not a ported + /// retail behavior — no 0x1000000E/0x1000000F + /// OnAction consumer exists anywhere in the binary (searched + /// exhaustively: no GetAttribute_Enum(this, 0x57, …) site, + /// ClientUISystem::OnAction's m_InputAction > 0x7c + /// branch only handles three unrelated ids, and gmPanelUI's + /// global-message listener is COMDAT-folded onto a no-op), so what a + /// repeat F3/F4 press does when the panel is already open on the OTHER + /// tab is not established from retail decomp — see + /// docs/research/2026-08-11-fa-panel-structure.md §8 unknown + /// U11. public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId; /// True when the Fellowship tab is the active page. public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId; + /// True while the social panel's own window is shown — set by + /// /. Fix-round blast SF-2: + /// gates the Friends/Squelch rebuild (see ) so their + /// DAT-locked row-template resolve never runs while the panel is + /// closed. + private bool _visible; + + /// hook, fired by the + /// window manager on every hidden-to-shown transition (fix-round blast + /// SF-2). Does not force an immediate Friends/Squelch rebuild — the + /// next naturally picks up any revision bump that + /// accumulated while hidden. + public void OnShown() => _visible = true; + + /// hook, fired on every + /// shown-to-hidden transition (fix-round blast SF-2). + public void OnHidden() => _visible = false; + /// /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no /// data-driven provider exists) and the /// Friends/Squelch read-only list rebuilds (revision-gated — cheap when /// nothing changed). Called from /// alongside every other retained panel's own Tick(). + /// + /// + /// Fix-round blast SF-2. The Friends/Squelch rebuild + /// (/ + /// ) takes the shared DAT + /// lock per row template resolve — it only runs while + /// is true. Fellowship/Allegiance stay unconditional: their own + /// Tick() methods are two bool/delegate writes with no DAT + /// access, cheap enough to keep polling every frame the way every other + /// retained panel's empty-state gate already does. + /// /// public void Tick() { + if (_disposed) return; _fellowship?.Tick(); _allegiance?.Tick(); - _friends?.Tick(); - _squelch?.Tick(); + if (_visible) + { + _friends?.Tick(); + _squelch?.Tick(); + } } public void Dispose() diff --git a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs index 6bbc98ad..6a010521 100644 --- a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs @@ -12,14 +12,16 @@ namespace AcDream.App.UI.Layout; /// character's own display name) and squelched accounts /// (, whose dictionary KEY is the /// account name itself). Rebuilds on -/// change; polled every frame from . +/// change; polled every frame from +/// WHILE THE PANEL IS VISIBLE (see the rebuild-discipline paragraph below). /// /// /// D1 — inert actions. Same disposition as /// : the three buttons /// (0x10000547/0x1000054B/0x1000054C) are left -/// AUTHORED and CLICKABLE with no handler. See this commit's single -/// register row covering the whole D1 Friends/Squelch inert-actions scope. +/// AUTHORED and CLICKABLE with no handler. See register row AD-79 +/// (docs/architecture/retail-divergence-register.md), which covers +/// the whole D1 Friends/Squelch inert-actions scope. /// /// /// @@ -27,11 +29,32 @@ namespace AcDream.App.UI.Layout; /// see for the name-text resolution note /// (gmSquelchUI is outside this campaign's decompiled scope). /// +/// +/// +/// Scrollbar (fix-round blast MF-1). The ListBox 0x1000053E +/// authors a direct sibling scrollbar, 0x10000543 — wired to +/// the same way +/// wires its own (see that +/// class's doc for the full rationale, including why there is no wheel +/// fallback). +/// +/// +/// +/// Rebuild discipline (fix-round blast SF-2/SF-3). Same discipline as +/// : only +/// advances after every row resolves, and +/// gates on the +/// panel's own visibility so no DAT-locked rebuild work runs while the +/// panel is closed. +/// /// public sealed class SocialSquelchPageController { private const uint ListBoxId = 0x1000053Eu; + /// The ListBox's own linked scrollbar (see class doc). + private const uint ScrollbarElementId = 0x10000543u; + private readonly UiTemplateListBox _listBox; private readonly SquelchState _squelch; private long _lastRevision = long.MinValue; @@ -60,6 +83,13 @@ public sealed class SocialSquelchPageController } listBox.TemplateResolver = templateResolver; + if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar) + scrollbar.Model = listBox.Scroll; + else + Console.WriteLine( + $"[D.2b] SocialSquelchPageController: scrollbar 0x{ScrollbarElementId:X8} " + + "not found — the Squelch list will not scroll."); + var controller = new SocialSquelchPageController(listBox, squelch); controller.Refresh(); return controller; @@ -74,21 +104,27 @@ public sealed class SocialSquelchPageController private void Refresh() { - _lastRevision = _squelch.Revision; + long revision = _squelch.Revision; _listBox.Flush(); SquelchDatabase database = _squelch.Snapshot(); + bool allRowsResolved = true; foreach (SquelchInfo character in database.Characters.Values) - AddRow(character.Name); + allRowsResolved &= AddRow(character.Name); foreach (string accountName in database.Accounts.Keys) - AddRow(accountName); + allRowsResolved &= AddRow(accountName); + + // SF-3: only latch the revision once the rebuild actually reflects it — + // a resolver miss must not silently swallow a future revision bump. + if (allRowsResolved) _lastRevision = revision; } - private void AddRow(string name) + private bool AddRow(string name) { UiElement? row = _listBox.AddItemFromTemplateList(0); - if (row is null) return; + if (row is null) return false; if (SocialPanelRowText.FindDeepest(row) is { } text) text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; + return true; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index dc491450..cd7c005c 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2618,26 +2618,50 @@ public sealed class RetailUiRuntime : IDisposable return; } - // The Friends/Squelch row templates (0x2100005D/0x21000060) are - // resolved lazily, per row — the SAME "second dat-lock scope per - // resolve" shape the Character/Chat/Config tab row templates use - // (MountOptionsPanel, above), since each row's own template is a - // live DAT read. + // The Friends/Squelch row templates (0x2100005D/0x21000060) differ + // from the Character/Chat/Config tab row templates: those build a + // FIXED row set exactly once at Bind (MountOptionsPanel, above) and + // never revisit their resolver again. Friends/Squelch instead poll + // a live roster whose COUNT can change all session (a friend logs + // in/out), so this resolver is called on every rebuild. + // + // Fix-round blast SF-2: the ORIGINAL shape re-ran + // LayoutImporter.ImportInfos — a full DAT tree walk — under the + // shared DatLock on EVERY row, every revision, even while the + // social panel was closed (SocialPanelController.Tick had no + // visibility gate). The fix has two parts: this resolver now caches + // each template's ElementInfo the FIRST time it is resolved (i.e. + // once, effectively at Bind — the page controllers' own Bind() + // calls Refresh() once unconditionally) and never re-Imports for + // that template id again; SocialPanelController separately gates + // Friends/Squelch's Tick-driven rebuild on the panel's own + // visibility (see that class's OnShown/OnHidden), so no rebuild — + // cached or not — runs while the panel is closed. Build() itself + // still runs (and still needs the DatLock: its sprite/font/string + // resolvers read the shared, non-thread-safe DatCollection) because + // each row needs its OWN UiElement instance — but the expensive + // per-row DAT tree walk is gone after the first resolve. + var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>(); UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId) { lock (_bindings.Assets.DatLock) { + var key = (templateLayoutId, templateElementId); + if (!rowTemplateCache.TryGetValue(key, out ElementInfo? info)) + { + info = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, templateLayoutId, templateElementId); + rowTemplateCache[key] = info; + } + if (info is null) return null; + var strings = new DatStringResolver(_bindings.Assets.Dats); - ElementInfo? info = LayoutImporter.ImportInfos( - _bindings.Assets.Dats, templateLayoutId, templateElementId); - return info is null - ? null - : LayoutImporter.Build( - info, - _bindings.Assets.ResolveSprite, - _bindings.Assets.DefaultFont, - _bindings.Assets.ResolveFont, - strings.Resolve).Root; + return LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + strings.Resolve).Root; } } diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs index ebd53ee2..ff7391c0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs @@ -34,13 +34,16 @@ public sealed class SocialPanelControllerTests } /// Mirrors the real Friends/Squelch row templates' own two-deep - /// nested-text shape (FA3 live-mount probe) so + /// nested-text shape AND their own authored extent (270x24, mechanism + /// SF-9's live dump) — FA3 live-mount probe — so /// has something realistic - /// to resolve, without needing the templates' own (uncommitted, separate + /// to resolve and grows + /// realistically per row (blast MF-1's long-roster scroll tests), + /// without needing the templates' own (uncommitted, separate /// LayoutDesc) fixtures. private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId) { - var outer = new UiText(); + var outer = new UiText { Width = 270f, Height = 24f }; var inner = new UiText(); outer.AddChild(inner); return outer; @@ -228,7 +231,13 @@ public sealed class SocialPanelControllerTests layout, MakeCallbacks(friends: friends)); Assert.NotNull(controller); - UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!; + // Fix-round blast SF-2: Friends/Squelch rebuild is gated on the + // panel's own visibility — simulate the window being shown, the + // same IRetainedPanelController.OnShown() the window manager calls + // in production. + controller!.OnShown(); + + UiElement friendsPage = UiElement.FindDescendant(controller.TabPanel, 0x10000513u)!; var listBox = Assert.IsType( UiElement.FindDescendant(friendsPage, 0x10000517u)); Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0); @@ -269,6 +278,14 @@ public sealed class SocialPanelControllerTests // ── D1: Friends/Squelch action buttons are honest INERT ──────────────── + /// + /// Pins the INERT contract register row AD-79 enumerates. Fix-round + /// mechanism SF-5: AD-79 names SEVEN controls (three Friends buttons, + /// the Friends "Appear Offline"-shaped checkbox, three Squelch + /// buttons) but this test previously only covered the six buttons — + /// 0x1000052C builds through + /// as a too, so it slots into the same loop. + /// [Fact] public void FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler() { @@ -276,7 +293,7 @@ public sealed class SocialPanelControllerTests SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks()); Assert.NotNull(controller); - foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u }) + foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u, 0x1000052Cu }) { var button = Assert.IsType( UiElement.FindDescendant(controller!.TabPanel, buttonId)); @@ -289,4 +306,155 @@ public sealed class SocialPanelControllerTests Assert.Null(button.OnClick); } } + + // ── Scrollbar wiring (blast MF-1) ─────────────────────────────────────── + + [Fact] + public void Friends_ScrollbarModel_IsWiredToListBoxScroll() + { + ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); + SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks()); + Assert.NotNull(controller); + + UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(friendsPage, 0x10000517u)); + var scrollbar = Assert.IsType( + UiElement.FindDescendant(friendsPage, 0x10000518u)); + + Assert.Same(listBox.Scroll, scrollbar.Model); + } + + [Fact] + public void Squelch_ScrollbarModel_IsWiredToListBoxScroll() + { + ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); + SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks()); + Assert.NotNull(controller); + + UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(squelchPage, 0x1000053Eu)); + var scrollbar = Assert.IsType( + UiElement.FindDescendant(squelchPage, 0x10000543u)); + + Assert.Same(listBox.Scroll, scrollbar.Model); + } + + /// + /// Blast MF-1: proves a roster taller than the panel is actually + /// REACHABLE through the scrollbar, not merely wired-but-unused. 40 rows + /// at the real Friends row template's own height (270x24, mechanism + /// SF-9's live dump) comfortably exceed the ListBox's authored extent + /// (270x400) — this roster genuinely needs scrolling. + /// + [Fact] + public void Friends_LongRoster_IsReachableViaScrollbar() + { + var friends = new FriendsState(); + var entries = new List(); + for (uint i = 0; i < 40; i++) + entries.Add(new(i, $"Friend{i}", true, false, System.Array.Empty(), System.Array.Empty())); + friends.Apply(new FriendsUpdate(FriendsUpdateType.Full, entries)); + + ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); + SocialPanelController? controller = SocialPanelController.Bind( + layout, MakeCallbacks(friends: friends)); + Assert.NotNull(controller); + + UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(friendsPage, 0x10000517u)); + var scrollbar = Assert.IsType( + UiElement.FindDescendant(friendsPage, 0x10000518u)); + + Assert.True( + listBox.ContentHeight > (int)listBox.Height, + $"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})"); + + // The scrollbar's Model IS the ListBox's own Scroll — moving it + // moves the SAME state that positions the rows + // (UiScrollablePanel.LayoutScrollableChildren, driven by the real + // render loop; simulated directly here since this is a dat-free + // controller test with no Draw pass). + scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height); + Assert.True(scrollbar.Model.HasOverflow); + int before = scrollbar.Model.ScrollY; + scrollbar.Model.ScrollByLines(4); + Assert.True(scrollbar.Model.ScrollY > before); + } + + /// Squelch counterpart of + /// (270x430 authored extent). + [Fact] + public void Squelch_LongRoster_IsReachableViaScrollbar() + { + var characters = new Dictionary(); + for (uint i = 0; i < 40; i++) + characters[i] = new SquelchInfo($"Grief{i}", false, new HashSet()); + var squelch = new SquelchState(); + squelch.Replace(new SquelchDatabase( + new Dictionary(System.StringComparer.OrdinalIgnoreCase), + characters, + new SquelchInfo(string.Empty, false, new HashSet()))); + + ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); + SocialPanelController? controller = SocialPanelController.Bind( + layout, MakeCallbacks(squelch: squelch)); + Assert.NotNull(controller); + + UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(squelchPage, 0x1000053Eu)); + var scrollbar = Assert.IsType( + UiElement.FindDescendant(squelchPage, 0x10000543u)); + + Assert.True( + listBox.ContentHeight > (int)listBox.Height, + $"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})"); + + scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height); + Assert.True(scrollbar.Model.HasOverflow); + int before = scrollbar.Model.ScrollY; + scrollbar.Model.ScrollByLines(4); + Assert.True(scrollbar.Model.ScrollY > before); + } + + // ── Rebuild discipline (blast SF-2/SF-3) ──────────────────────────────── + + /// + /// Blast SF-2: the panel's Friends/Squelch rebuild must not run while + /// the window is closed. starts + /// with no visibility notification (matches production: the window + /// mounts Visible = false and nothing calls OnShown until + /// F3/F4), so a revision bump with no OnShown call must NOT + /// rebuild the list; the SAME bump picked up on the next Tick() + /// after OnShown() proves the rebuild is deferred, not lost. + /// + [Fact] + public void Friends_RevisionBumpWhileHidden_DoesNotRebuild_ButRebuildsOnShow() + { + var friends = new FriendsState(); + ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); + SocialPanelController? controller = SocialPanelController.Bind( + layout, MakeCallbacks(friends: friends)); + Assert.NotNull(controller); + + UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(friendsPage, 0x10000517u)); + + friends.Apply(new FriendsUpdate( + FriendsUpdateType.Add, + new List { new(1u, "Alice", true, false, System.Array.Empty(), System.Array.Empty()) })); + + // Panel never shown — Tick() must not rebuild. + controller!.Tick(); + Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0); + + // Showing the panel and ticking again picks up the accumulated bump. + controller.OnShown(); + controller.Tick(); + Assert.Single(listBox.ViewportForTest!.Children); + } }