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

@ -9,8 +9,10 @@ namespace AcDream.App.UI.Layout;
/// only, bound directly to the J4.1 <see cref="FriendsState"/> owner /// only, bound directly to the J4.1 <see cref="FriendsState"/> owner
/// (<c>RuntimeCommunicationState.Friends</c>). Rebuilds on /// (<c>RuntimeCommunicationState.Friends</c>). Rebuilds on
/// <see cref="FriendsState.Revision"/> change; polled every frame from /// <see cref="FriendsState.Revision"/> change; polled every frame from
/// <see cref="SocialPanelController.Tick"/> (cheap — a single /// <see cref="SocialPanelController.Tick"/> WHILE THE PANEL IS VISIBLE
/// <see langword="long"/> comparison when nothing changed). /// (cheap — a single <see langword="long"/> comparison when nothing
/// changed; see the scrollbar/rebuild-discipline paragraph below for the
/// fix-round visibility gate).
/// ///
/// <para> /// <para>
/// <b>D1 — inert actions.</b> This campaign implements no Friends /// <b>D1 — inert actions.</b> This campaign implements no Friends
@ -20,9 +22,10 @@ namespace AcDream.App.UI.Layout;
/// "Appear Offline" checkbox (<c>0x1000052C</c>) are left AUTHORED and /// "Appear Offline" checkbox (<c>0x1000052C</c>) are left AUTHORED and
/// CLICKABLE with no handler — <see cref="DatWidgetFactory"/> already /// CLICKABLE with no handler — <see cref="DatWidgetFactory"/> already
/// builds every button/checkbox with a null <c>OnClick</c> by default, so /// builds every button/checkbox with a null <c>OnClick</c> by default, so
/// no explicit "leave unbound" code is needed here. See this commit's /// no explicit "leave unbound" code is needed here. See register row
/// single register row covering the whole D1 Friends/Squelch inert-actions /// AD-79 (<c>docs/architecture/retail-divergence-register.md</c>), which
/// scope (one row, not one per button). /// covers the whole D1 Friends/Squelch inert-actions scope (one row, not
/// one per button).
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -31,11 +34,39 @@ namespace AcDream.App.UI.Layout;
/// name-text leaf — see that class's doc for why the deepest match is used /// name-text leaf — see that class's doc for why the deepest match is used
/// (gmFriendsUI is outside this campaign's decompiled scope). /// (gmFriendsUI is outside this campaign's decompiled scope).
/// </para> /// </para>
///
/// <para>
/// <b>Scrollbar (fix-round blast MF-1).</b> The ListBox <c>0x10000517</c>
/// authors a direct sibling scrollbar, <c>0x10000518</c> — wired to
/// <see cref="UiTemplateListBox.Scroll"/> the SAME way every other
/// <c>UiTemplateListBox</c> consumer wires its own scrollbar
/// (<c>CharacterOptionsPageController</c>/<c>ChatOptionsPageController</c>/
/// <c>ConfigOptionsPageController</c>/<c>KeyboardConfigController</c>).
/// Without it the list has NO wheel fallback (<see cref="UiScrollablePanel"/>
/// has no wheel handler) and is completely unreachable past the box's
/// visible extent.
/// </para>
///
/// <para>
/// <b>Rebuild discipline (fix-round blast SF-2/SF-3).</b> <see cref="Refresh"/>
/// only advances <see cref="_lastRevision"/> after a successful rebuild — a
/// transient resolver miss (<see cref="UiTemplateListBox.AddItemFromTemplateList"/>
/// returning null) leaves the revision UNCONSUMED so the next
/// <see cref="Tick"/> retries instead of latching an empty list until the
/// NEXT server-side change. <see cref="SocialPanelController"/> also gates
/// this controller's <see cref="Tick"/> on the panel's own visibility (its
/// <c>IRetainedPanelController.OnShown</c>/<c>OnHidden</c> hooks) — the
/// per-row template resolve takes the shared DAT lock, so no rebuild work
/// runs while the panel is closed.
/// </para>
/// </summary> /// </summary>
public sealed class SocialFriendsPageController public sealed class SocialFriendsPageController
{ {
private const uint ListBoxId = 0x10000517u; private const uint ListBoxId = 0x10000517u;
/// <summary>The ListBox's own linked scrollbar (see class doc).</summary>
private const uint ScrollbarElementId = 0x10000518u;
private readonly UiTemplateListBox _listBox; private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends; private readonly FriendsState _friends;
private long _lastRevision = long.MinValue; private long _lastRevision = long.MinValue;
@ -64,6 +95,13 @@ public sealed class SocialFriendsPageController
} }
listBox.TemplateResolver = templateResolver; 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); var controller = new SocialFriendsPageController(listBox, friends);
controller.Refresh(); controller.Refresh();
return controller; return controller;
@ -78,17 +116,21 @@ public sealed class SocialFriendsPageController
private void Refresh() private void Refresh()
{ {
_lastRevision = _friends.Revision; long revision = _friends.Revision;
_listBox.Flush(); _listBox.Flush();
bool allRowsResolved = true;
foreach (FriendEntry friend in _friends.Snapshot()) foreach (FriendEntry friend in _friends.Snapshot())
{ {
UiElement? row = _listBox.AddItemFromTemplateList(0); UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) continue; if (row is null) { allRowsResolved = false; continue; }
if (SocialPanelRowText.FindDeepest(row) is { } text) if (SocialPanelRowText.FindDeepest(row) is { } text)
{ {
string name = friend.Name; string name = friend.Name;
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; 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;
} }
} }

View file

@ -198,27 +198,72 @@ public sealed class SocialPanelController : IRetainedPanelController
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId); public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
/// <summary>True when the Allegiance tab is the active page — lets /// <summary>True when the Allegiance tab is the active page — lets
/// <see cref="RetailUiRuntime.HandleInputAction"/> implement retail's /// <see cref="RetailUiRuntime.HandleInputAction"/> implement the
/// Toggle-action close-on-second-press semantics (same shape as /// close-on-second-press-of-the-SAME-tab semantics every other
/// <c>OpenSpellbook</c>'s own page-aware toggle).</summary> /// <c>Toggle*Panel</c> action already uses (<c>OpenSpellbook</c>'s own
/// page-aware toggle is the precedent this follows). <b>Fix-round
/// mechanism SF-1:</b> this is acdream's OWN convention, not a ported
/// retail behavior — no <c>0x1000000E</c>/<c>0x1000000F</c>
/// <c>OnAction</c> consumer exists anywhere in the binary (searched
/// exhaustively: no <c>GetAttribute_Enum(this, 0x57, …)</c> site,
/// <c>ClientUISystem::OnAction</c>'s <c>m_InputAction &gt; 0x7c</c>
/// branch only handles three unrelated ids, and <c>gmPanelUI</c>'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
/// <c>docs/research/2026-08-11-fa-panel-structure.md</c> §8 unknown
/// U11.</summary>
public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId; public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId;
/// <summary>True when the Fellowship tab is the active page.</summary> /// <summary>True when the Fellowship tab is the active page.</summary>
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId; public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
/// <summary>True while the social panel's own window is shown — set by
/// <see cref="OnShown"/>/<see cref="OnHidden"/>. Fix-round blast SF-2:
/// gates the Friends/Squelch rebuild (see <see cref="Tick"/>) so their
/// DAT-locked row-template resolve never runs while the panel is
/// closed.</summary>
private bool _visible;
/// <summary><see cref="IRetainedPanelController"/> 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 <see cref="Tick"/> naturally picks up any revision bump that
/// accumulated while hidden.</summary>
public void OnShown() => _visible = true;
/// <summary><see cref="IRetainedPanelController"/> hook, fired on every
/// shown-to-hidden transition (fix-round blast SF-2).</summary>
public void OnHidden() => _visible = false;
/// <summary> /// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
/// data-driven <see cref="UiElement.Visible"/> provider exists) and the /// data-driven <see cref="UiElement.Visible"/> provider exists) and the
/// Friends/Squelch read-only list rebuilds (revision-gated — cheap when /// Friends/Squelch read-only list rebuilds (revision-gated — cheap when
/// nothing changed). Called from <see cref="RetailUiRuntime.Tick"/> /// nothing changed). Called from <see cref="RetailUiRuntime.Tick"/>
/// alongside every other retained panel's own <c>Tick()</c>. /// alongside every other retained panel's own <c>Tick()</c>.
///
/// <para>
/// <b>Fix-round blast SF-2.</b> The Friends/Squelch rebuild
/// (<see cref="SocialFriendsPageController.Tick"/>/
/// <see cref="SocialSquelchPageController.Tick"/>) takes the shared DAT
/// lock per row template resolve — it only runs while <see cref="_visible"/>
/// is true. Fellowship/Allegiance stay unconditional: their own
/// <c>Tick()</c> 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.
/// </para>
/// </summary> /// </summary>
public void Tick() public void Tick()
{ {
if (_disposed) return;
_fellowship?.Tick(); _fellowship?.Tick();
_allegiance?.Tick(); _allegiance?.Tick();
_friends?.Tick(); if (_visible)
_squelch?.Tick(); {
_friends?.Tick();
_squelch?.Tick();
}
} }
public void Dispose() public void Dispose()

View file

@ -12,14 +12,16 @@ namespace AcDream.App.UI.Layout;
/// character's own display name) and squelched accounts /// character's own display name) and squelched accounts
/// (<see cref="SquelchDatabase.Accounts"/>, whose dictionary KEY is the /// (<see cref="SquelchDatabase.Accounts"/>, whose dictionary KEY is the
/// account name itself). Rebuilds on <see cref="SquelchState.Revision"/> /// 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> /// <para>
/// <b>D1 — inert actions.</b> Same disposition as /// <b>D1 — inert actions.</b> Same disposition as
/// <see cref="SocialFriendsPageController"/>: the three buttons /// <see cref="SocialFriendsPageController"/>: the three buttons
/// (<c>0x10000547</c>/<c>0x1000054B</c>/<c>0x1000054C</c>) are left /// (<c>0x10000547</c>/<c>0x1000054B</c>/<c>0x1000054C</c>) are left
/// AUTHORED and CLICKABLE with no handler. See this commit's single /// AUTHORED and CLICKABLE with no handler. See register row AD-79
/// register row covering the whole D1 Friends/Squelch inert-actions scope. /// (<c>docs/architecture/retail-divergence-register.md</c>), which covers
/// the whole D1 Friends/Squelch inert-actions scope.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -27,11 +29,32 @@ namespace AcDream.App.UI.Layout;
/// see <see cref="SocialPanelRowText"/> for the name-text resolution note /// see <see cref="SocialPanelRowText"/> for the name-text resolution note
/// (<c>gmSquelchUI</c> is outside this campaign's decompiled scope). /// (<c>gmSquelchUI</c> is outside this campaign's decompiled scope).
/// </para> /// </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> /// </summary>
public sealed class SocialSquelchPageController public sealed class SocialSquelchPageController
{ {
private const uint ListBoxId = 0x1000053Eu; 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 UiTemplateListBox _listBox;
private readonly SquelchState _squelch; private readonly SquelchState _squelch;
private long _lastRevision = long.MinValue; private long _lastRevision = long.MinValue;
@ -60,6 +83,13 @@ public sealed class SocialSquelchPageController
} }
listBox.TemplateResolver = templateResolver; 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); var controller = new SocialSquelchPageController(listBox, squelch);
controller.Refresh(); controller.Refresh();
return controller; return controller;
@ -74,21 +104,27 @@ public sealed class SocialSquelchPageController
private void Refresh() private void Refresh()
{ {
_lastRevision = _squelch.Revision; long revision = _squelch.Revision;
_listBox.Flush(); _listBox.Flush();
SquelchDatabase database = _squelch.Snapshot(); SquelchDatabase database = _squelch.Snapshot();
bool allRowsResolved = true;
foreach (SquelchInfo character in database.Characters.Values) foreach (SquelchInfo character in database.Characters.Values)
AddRow(character.Name); allRowsResolved &= AddRow(character.Name);
foreach (string accountName in database.Accounts.Keys) 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); UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) return; if (row is null) return false;
if (SocialPanelRowText.FindDeepest(row) is { } text) if (SocialPanelRowText.FindDeepest(row) is { } text)
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
return true;
} }
} }

View file

@ -2618,26 +2618,50 @@ public sealed class RetailUiRuntime : IDisposable
return; return;
} }
// The Friends/Squelch row templates (0x2100005D/0x21000060) are // The Friends/Squelch row templates (0x2100005D/0x21000060) differ
// resolved lazily, per row — the SAME "second dat-lock scope per // from the Character/Chat/Config tab row templates: those build a
// resolve" shape the Character/Chat/Config tab row templates use // FIXED row set exactly once at Bind (MountOptionsPanel, above) and
// (MountOptionsPanel, above), since each row's own template is a // never revisit their resolver again. Friends/Squelch instead poll
// live DAT read. // 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) UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
{ {
lock (_bindings.Assets.DatLock) 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); var strings = new DatStringResolver(_bindings.Assets.Dats);
ElementInfo? info = LayoutImporter.ImportInfos( return LayoutImporter.Build(
_bindings.Assets.Dats, templateLayoutId, templateElementId); info,
return info is null _bindings.Assets.ResolveSprite,
? null _bindings.Assets.DefaultFont,
: LayoutImporter.Build( _bindings.Assets.ResolveFont,
info, strings.Resolve).Root;
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
} }
} }

View file

@ -34,13 +34,16 @@ public sealed class SocialPanelControllerTests
} }
/// <summary>Mirrors the real Friends/Squelch row templates' own two-deep /// <summary>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
/// <see cref="SocialPanelRowText.FindDeepest"/> has something realistic /// <see cref="SocialPanelRowText.FindDeepest"/> has something realistic
/// to resolve, without needing the templates' own (uncommitted, separate /// to resolve and <see cref="UiTemplateListBox.ContentHeight"/> grows
/// realistically per row (blast MF-1's long-roster scroll tests),
/// without needing the templates' own (uncommitted, separate
/// LayoutDesc) fixtures.</summary> /// LayoutDesc) fixtures.</summary>
private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId) private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId)
{ {
var outer = new UiText(); var outer = new UiText { Width = 270f, Height = 24f };
var inner = new UiText(); var inner = new UiText();
outer.AddChild(inner); outer.AddChild(inner);
return outer; return outer;
@ -228,7 +231,13 @@ public sealed class SocialPanelControllerTests
layout, MakeCallbacks(friends: friends)); layout, MakeCallbacks(friends: friends));
Assert.NotNull(controller); 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<UiTemplateListBox>( var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u)); UiElement.FindDescendant(friendsPage, 0x10000517u));
Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0); Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
@ -269,6 +278,14 @@ public sealed class SocialPanelControllerTests
// ── D1: Friends/Squelch action buttons are honest INERT ──────────────── // ── D1: Friends/Squelch action buttons are honest INERT ────────────────
/// <summary>
/// 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 —
/// <c>0x1000052C</c> builds through <see cref="DatWidgetFactory.BuildCheckbox"/>
/// as a <see cref="UiButton"/> too, so it slots into the same loop.
/// </summary>
[Fact] [Fact]
public void FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler() public void FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler()
{ {
@ -276,7 +293,7 @@ public sealed class SocialPanelControllerTests
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks()); SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller); Assert.NotNull(controller);
foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u }) foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u, 0x1000052Cu })
{ {
var button = Assert.IsType<UiButton>( var button = Assert.IsType<UiButton>(
UiElement.FindDescendant(controller!.TabPanel, buttonId)); UiElement.FindDescendant(controller!.TabPanel, buttonId));
@ -289,4 +306,155 @@ public sealed class SocialPanelControllerTests
Assert.Null(button.OnClick); 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<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
var scrollbar = Assert.IsType<UiScrollbar>(
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<UiTemplateListBox>(
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
var scrollbar = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(squelchPage, 0x10000543u));
Assert.Same(listBox.Scroll, scrollbar.Model);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Friends_LongRoster_IsReachableViaScrollbar()
{
var friends = new FriendsState();
var entries = new List<FriendEntry>();
for (uint i = 0; i < 40; i++)
entries.Add(new(i, $"Friend{i}", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()));
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<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
var scrollbar = Assert.IsType<UiScrollbar>(
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);
}
/// <summary>Squelch counterpart of <see cref="Friends_LongRoster_IsReachableViaScrollbar"/>
/// (270x430 authored extent).</summary>
[Fact]
public void Squelch_LongRoster_IsReachableViaScrollbar()
{
var characters = new Dictionary<uint, SquelchInfo>();
for (uint i = 0; i < 40; i++)
characters[i] = new SquelchInfo($"Grief{i}", false, new HashSet<uint>());
var squelch = new SquelchState();
squelch.Replace(new SquelchDatabase(
new Dictionary<string, uint>(System.StringComparer.OrdinalIgnoreCase),
characters,
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
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<UiTemplateListBox>(
UiElement.FindDescendant(squelchPage, 0x1000053Eu));
var scrollbar = Assert.IsType<UiScrollbar>(
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) ────────────────────────────────
/// <summary>
/// Blast SF-2: the panel's Friends/Squelch rebuild must not run while
/// the window is closed. <see cref="SocialPanelController"/> starts
/// with no visibility notification (matches production: the window
/// mounts <c>Visible = false</c> and nothing calls <c>OnShown</c> until
/// F3/F4), so a revision bump with no <c>OnShown</c> call must NOT
/// rebuild the list; the SAME bump picked up on the next <c>Tick()</c>
/// after <c>OnShown()</c> proves the rebuild is deferred, not lost.
/// </summary>
[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<UiTemplateListBox>(
UiElement.FindDescendant(friendsPage, 0x10000517u));
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Add,
new List<FriendEntry> { new(1u, "Alice", true, false, System.Array.Empty<uint>(), System.Array.Empty<uint>()) }));
// 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);
}
} }