fix(ui): FA3 fix-round — wire Friends/Squelch scrollbars, gate rebuild on visibility, fix revision-latch ordering

Campaign FA slice FA3 dual-lens review fix round.

Blast MUST-FIX 1: SocialFriendsPageController/SocialSquelchPageController
never wired their ListBox's own sibling scrollbar (Friends
0x10000517->0x10000518, Squelch 0x1000053E->0x10000543) — the two lists
had NO scroll driver at all (no wheel fallback exists on
UiScrollablePanel), making any roster past the visible extent completely
unreachable, not just awkward to scroll. Both controllers now wire
`scrollbar.Model = listBox.Scroll` scoped to their own page root, exactly
like the four existing UiTemplateListBox consumers
(Character/Chat/Config/KeyboardConfig). New tests prove the wiring AND
that a >panel-height roster is actually reachable through it.

Blast SHOULD-FIX 2/3: the Friends/Squelch resolver re-ran
LayoutImporter.ImportInfos (a full DAT tree walk) under the shared DAT
lock on EVERY row, every revision, even while the panel was closed —
RetailUiRuntime.MountSocialPanel's TemplateResolver now caches each row
template's ElementInfo the first time it is resolved and never
re-Imports for that template id again. SocialPanelController additionally
gates the Friends/Squelch Tick-driven rebuild on the panel's own
visibility via the (previously unused) IRetainedPanelController
OnShown/OnHidden hooks, so no DAT-locked rebuild work runs at all while
the panel is closed. SocialFriendsPageController/SocialSquelchPageController
also now only advance _lastRevision after every row resolves — a
transient resolver miss no longer latches an empty roster until the next
server-side change; it retries on the next Tick instead.

Mechanism SHOULD-FIX 8: SocialPanelController.Tick() now returns early
once disposed, matching every other J-slice teardown discipline (it was
being ticked unconditionally forever since RetailUiRuntime never nulls
the field).

Mechanism SHOULD-FIX 1: IsShowingAllegiance's doc claimed the F3/F4
close-on-second-press semantics were "retail's Toggle-action semantics" —
re-derived and confirmed NO retail OnAction consumer exists for either
action anywhere in the binary. Relabeled as acdream's own
OpenSpellbook-precedent convention; no register row added, following the
same no-row precedent OpenSpellbook and every other non-toolbar Toggle
panel already sets. Unknown filed as U11 in the panel-structure research
doc's §8 table.

Tests: 5 new (2 scrollbar-wiring pins, 2 long-roster-reachable-via-
scrollbar, 1 hidden-panel-does-not-rebuild/shown-panel-catches-up); 2
existing tests updated for the new visibility gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 03:43:35 +02:00
parent 9e622f565e
commit 9afa05b563
5 changed files with 355 additions and 40 deletions

View file

@ -12,14 +12,16 @@ namespace AcDream.App.UI.Layout;
/// character's own display name) and squelched accounts
/// (<see cref="SquelchDatabase.Accounts"/>, whose dictionary KEY is the
/// account name itself). Rebuilds on <see cref="SquelchState.Revision"/>
/// change; polled every frame from <see cref="SocialPanelController.Tick"/>.
/// change; polled every frame from <see cref="SocialPanelController.Tick"/>
/// WHILE THE PANEL IS VISIBLE (see the rebuild-discipline paragraph below).
///
/// <para>
/// <b>D1 — inert actions.</b> Same disposition as
/// <see cref="SocialFriendsPageController"/>: the three buttons
/// (<c>0x10000547</c>/<c>0x1000054B</c>/<c>0x1000054C</c>) 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
/// (<c>docs/architecture/retail-divergence-register.md</c>), which covers
/// the whole D1 Friends/Squelch inert-actions scope.
/// </para>
///
/// <para>
@ -27,11 +29,32 @@ namespace AcDream.App.UI.Layout;
/// see <see cref="SocialPanelRowText"/> for the name-text resolution note
/// (<c>gmSquelchUI</c> is outside this campaign's decompiled scope).
/// </para>
///
/// <para>
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x1000053E</c>
/// authors a direct sibling scrollbar, <c>0x10000543</c> — wired to
/// <see cref="UiTemplateListBox.Scroll"/> the same way
/// <see cref="SocialFriendsPageController"/> wires its own (see that
/// class's doc for the full rationale, including why there is no wheel
/// fallback).
/// </para>
///
/// <para>
/// <b>Rebuild discipline (fix-round blast SF-2/SF-3).</b> Same discipline as
/// <see cref="SocialFriendsPageController"/>: <see cref="Refresh"/> only
/// advances <see cref="_lastRevision"/> after every row resolves, and
/// <see cref="SocialPanelController"/> gates <see cref="Tick"/> on the
/// panel's own visibility so no DAT-locked rebuild work runs while the
/// panel is closed.
/// </para>
/// </summary>
public sealed class SocialSquelchPageController
{
private const uint ListBoxId = 0x1000053Eu;
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
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;
}
}