feat(ui): FA3 -- mount the social panel shell (Friends/Allegiance/Fellowship/Squelch)

Campaign FA slice FA3: retail's four-tab social panel (LayoutDesc
0x2100006E slot 0x1000018F, RetailPanelCatalog id 12), built on the OP3
OptionsPanelController recipe -- Type-8 tab host, ActivateTabBehavior,
per-page scoped controllers.

- SocialPanelController mounts the tab host and wires the close button;
  F3 (ToggleAllegiancePanel) and F4 (ToggleFellowshipPanel) open the
  panel and switch to their own tab, sharing the same gmPanelUI
  one-active-panel exclusivity every sibling main panel already has.
- The live-DAT tab table CORRECTS the coordinator addendum's x-order
  guess: button 0x1000028C ("Allegiance") pairs with page 0x10000291 and
  is the authored DEFAULT entry, not Friends -- each button's own page
  id and its own P0x57 (matching the F3/F4 ActionMap ids on the
  Allegiance/Fellowship pages specifically) both corroborate the real
  pairing. See SocialPanelController's class doc for the full table.
- SocialFellowshipPageController swaps the two authored empty/full
  frames (0x1000026B/0x10000275) on RuntimeFellowshipState's
  IsInFellowship -- both frames' full containment (name box, create
  button, checkboxes vs. roster list, six buttons) was confirmed by the
  live-mount probe, so a single Visible toggle per frame is the whole
  swap (closes lane-A unknown U6).
- SocialAllegiancePageController hides the monarch/patron blocks and
  blanks their name text to a literal space when
  RuntimeAllegianceState.Snapshot.HasProfile is false, using SCOPED
  FindDescendant lookups (the panel authors 0x10000492 twice, once per
  block).
- SocialFriendsPageController/SocialSquelchPageController bind their
  ListBoxes read-only to RuntimeCommunicationState's existing J4.1
  Friends/Squelch owners (names only), rebuilding on revision change.
  Their action buttons are honest INERT (D1) -- register row AD-79.
- UiTemplateListBox gains Flush() (lane A/D's "Gap found" prerequisite)
  so a poll-and-rebuild list can shrink between refreshes.
- RetailPanelCatalog.SocialPanel = 12, byte-verified from the live slot's
  own P0x10000029; listed in Mounted only (no toolbar button -- lane A
  §6.1: the open path is keyboard-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 02:58:09 +02:00
parent b560f415cd
commit 0a9ca2f1f9
12 changed files with 854 additions and 1 deletions

View file

@ -870,6 +870,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
SaveDisplay: d.Settings.SaveDisplay,
LoadAudio: () => d.Settings.Audio,
SaveAudio: d.Settings.SaveAudio),
// Campaign FA slice FA3: the social panel's own bindings —
// FA2's typed Fellowship/Allegiance snapshot readers off the
// GameRuntime views, plus J4.1's Friends/Squelch owners
// directly (the panel's read-only rows need full-collection
// enumeration, not the bot-facing IRuntimeSocialView's
// per-id lookup).
Social: new SocialRuntimeBindings(
() => d.Runtime.Fellowship.Snapshot,
() => d.Runtime.Allegiance.Snapshot,
d.Communication.Friends,
d.Communication.Squelch),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3: the Allegiance page's empty-state presentation —
/// the ONLY behavior this SHELL slice owns for this page (live monarch/
/// patron/vassal population, swear/break/kick + confirmations, and the
/// per-member online-state dimming are FA5 scope).
///
/// <para>
/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it
/// hides the monarch/patron blocks and blanks their name text to a literal
/// space per-block, gated on whether each relationship exists
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract
/// simplifies this to the single gate this campaign's Runtime owner
/// actually exposes today: <see cref="RuntimeAllegianceSnapshot.HasProfile"/>
/// — no allegiance push has landed this generation. When a profile HAS
/// arrived, this controller leaves both blocks visible with their
/// build-time (empty) text rather than fabricate monarch/patron content —
/// that population is FA5's job.
/// </para>
///
/// <para>
/// Element ids confirmed by the FA3 live-mount probe:
/// <c>0x10000255</c> monarch-field container, <c>0x1000025A</c>
/// patron-field container, <c>0x10000257</c> monarch-name text (inside the
/// monarch field), <c>0x1000025C</c> patron-name text (inside the patron
/// field). Both lookups are SCOPED to the allegiance page root — the panel
/// also authors <c>0x10000492</c> (the XP-passed-up text) TWICE, once under
/// each block, so a flat lookup anywhere in this subsystem would risk
/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP
/// <c>0x10000211</c>-in-two-layouts lesson).
/// </para>
/// </summary>
public sealed class SocialAllegiancePageController
{
private static readonly IReadOnlyList<UiText.Line> BlankLine =
[new UiText.Line(" ", System.Numerics.Vector4.One)];
private static readonly IReadOnlyList<UiText.Line> NoLines = [];
private readonly UiElement _monarchField;
private readonly UiElement _patronField;
private readonly UiText? _monarchName;
private readonly UiText? _patronName;
private readonly Func<RuntimeAllegianceSnapshot> _snapshot;
private SocialAllegiancePageController(
UiElement monarchField,
UiElement patronField,
UiText? monarchName,
UiText? patronName,
Func<RuntimeAllegianceSnapshot> snapshot)
{
_monarchField = monarchField;
_patronField = patronField;
_monarchName = monarchName;
_patronName = patronName;
_snapshot = snapshot;
}
public static SocialAllegiancePageController? Bind(
UiElement pageRoot,
Func<RuntimeAllegianceSnapshot> snapshot)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(snapshot);
if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField
|| UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField)
{
Console.WriteLine(
"[D.2b] SocialAllegiancePageController: monarch/patron field "
+ "containers (0x10000255/0x1000025A) not found — allegiance page "
+ "will not present its empty state.");
return null;
}
// Scoped to each block — 0x10000257/0x1000025C are unique per-block,
// but the lookup discipline matters equally here (see class doc).
UiText? monarchName = UiElement.FindDescendant(monarchField, 0x10000257u) as UiText;
UiText? patronName = UiElement.FindDescendant(patronField, 0x1000025Cu) as UiText;
if (monarchName is null)
Console.WriteLine("[D.2b] SocialAllegiancePageController: monarch name text 0x10000257 not found.");
if (patronName is null)
Console.WriteLine("[D.2b] SocialAllegiancePageController: patron name text 0x1000025C not found.");
var controller = new SocialAllegiancePageController(
monarchField, patronField, monarchName, patronName, snapshot);
controller.Tick();
return controller;
}
/// <summary>
/// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap
/// — called every frame from <see cref="SocialPanelController.Tick"/> (same
/// reasoning as <see cref="SocialFellowshipPageController.Tick"/>).
/// </summary>
public void Tick()
{
bool hasProfile = _snapshot().HasProfile;
_monarchField.Visible = hasProfile;
_patronField.Visible = hasProfile;
IReadOnlyList<UiText.Line> lines = hasProfile ? NoLines : BlankLine;
if (_monarchName is not null) _monarchName.LinesProvider = () => lines;
if (_patronName is not null) _patronName.LinesProvider = () => lines;
}
}

View file

@ -0,0 +1,78 @@
using System;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3: the Fellowship page's empty-state frame swap —
/// the ONLY behavior this SHELL slice owns for this page (roster rows,
/// vitals, create dialog, and the option row un-dims are FA4 scope).
///
/// <para>
/// Retail <c>gmFellowshipUI::Update @0x0048F440</c>
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
/// <c>m_pNotInAFellowshipFrame</c> (<c>0x1000026B</c>) shows and
/// <c>m_pInAFellowshipFrame</c> (<c>0x10000275</c>) hides when
/// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling
/// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the
/// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create
/// button, and all three visible option checkboxes; <c>0x10000275</c> holds
/// the roster list and all six member-management buttons. A single
/// <see cref="UiElement.Visible"/> toggle on each container is therefore the
/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
/// unknown U6).
/// </para>
/// </summary>
public sealed class SocialFellowshipPageController
{
private readonly UiElement _notInFellowshipFrame;
private readonly UiElement _inFellowshipFrame;
private readonly Func<RuntimeFellowshipSnapshot> _snapshot;
private SocialFellowshipPageController(
UiElement notInFellowshipFrame,
UiElement inFellowshipFrame,
Func<RuntimeFellowshipSnapshot> snapshot)
{
_notInFellowshipFrame = notInFellowshipFrame;
_inFellowshipFrame = inFellowshipFrame;
_snapshot = snapshot;
}
public static SocialFellowshipPageController? Bind(
UiElement pageRoot,
Func<RuntimeFellowshipSnapshot> snapshot)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(snapshot);
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
{
Console.WriteLine(
"[D.2b] SocialFellowshipPageController: empty/full frame pair "
+ "(0x1000026B/0x10000275) not found — fellowship page will not "
+ "swap its empty state.");
return null;
}
var controller = new SocialFellowshipPageController(notIn, inFellowship, snapshot);
controller.Tick();
return controller;
}
/// <summary>
/// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
/// bool writes) — called every frame from
/// <see cref="SocialPanelController.Tick"/> rather than gated on
/// page-shown/revision, since <see cref="UiElement.Visible"/> has no
/// data-driven provider mechanism of its own (unlike <see cref="UiText.LinesProvider"/>,
/// which is polled by the render loop already).
/// </summary>
public void Tick()
{
bool inFellowship = _snapshot().IsInFellowship;
_notInFellowshipFrame.Visible = !inFellowship;
_inFellowshipFrame.Visible = inFellowship;
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.Numerics;
using AcDream.Core.Social;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3, D1: the Friends page's read-only roster — names
/// only, bound directly to the J4.1 <see cref="FriendsState"/> owner
/// (<c>RuntimeCommunicationState.Friends</c>). Rebuilds on
/// <see cref="FriendsState.Revision"/> change; polled every frame from
/// <see cref="SocialPanelController.Tick"/> (cheap — a single
/// <see langword="long"/> comparison when nothing changed).
///
/// <para>
/// <b>D1 — inert actions.</b> This campaign implements no Friends
/// add/remove/appear-offline wire (the trivially-pinnable bar D1 sets was
/// not met for this family — see the plan's D1 decision). The three
/// buttons (<c>0x10000514</c>/<c>0x10000515</c>/<c>0x10000516</c>) and the
/// "Appear Offline" checkbox (<c>0x1000052C</c>) are left AUTHORED and
/// CLICKABLE with no handler — <see cref="DatWidgetFactory"/> already
/// 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
/// single register row covering the whole D1 Friends/Squelch inert-actions
/// scope (one row, not one per button).
/// </para>
///
/// <para>
/// Row template <c>0x2100005D</c>/<c>0x10000519</c> (FA3 live-mount probe);
/// <see cref="SocialPanelRowText.FindDeepest"/> resolves the row's own
/// name-text leaf — see that class's doc for why the deepest match is used
/// (gmFriendsUI is outside this campaign's decompiled scope).
/// </para>
/// </summary>
public sealed class SocialFriendsPageController
{
private const uint ListBoxId = 0x10000517u;
private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends;
private long _lastRevision = long.MinValue;
private SocialFriendsPageController(UiTemplateListBox listBox, FriendsState friends)
{
_listBox = listBox;
_friends = friends;
}
public static SocialFriendsPageController? Bind(
UiElement pageRoot,
FriendsState friends,
Func<uint, uint, UiElement?> templateResolver)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(friends);
ArgumentNullException.ThrowIfNull(templateResolver);
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] SocialFriendsPageController: ListBox 0x{ListBoxId:X8} not "
+ "found — Friends page will not populate.");
return null;
}
listBox.TemplateResolver = templateResolver;
var controller = new SocialFriendsPageController(listBox, friends);
controller.Refresh();
return controller;
}
public void Tick()
{
long revision = _friends.Revision;
if (revision == _lastRevision) return;
Refresh();
}
private void Refresh()
{
_lastRevision = _friends.Revision;
_listBox.Flush();
foreach (FriendEntry friend in _friends.Snapshot())
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) continue;
if (SocialPanelRowText.FindDeepest(row) is { } text)
{
string name = friend.Name;
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
}
}
}
}

View file

@ -0,0 +1,229 @@
using System;
using AcDream.Core.Social;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts retail's four-tab social panel — Friends / Allegiance /
/// Fellowship / Squelch — LayoutDesc <c>0x2100006E</c> slot
/// <c>0x1000018F</c>, <see cref="AcDream.App.UI.RetailPanelCatalog"/> id
/// <b>12</b>. Campaign FA slice FA3 (2026-08-12), built on the OP3
/// <see cref="OptionsPanelController"/> recipe (Type-8 tab host,
/// <see cref="UiTabPanel.ActivateTabBehavior"/>, per-page scoped
/// controllers).
///
/// <para>
/// <b>Coordinator addendum</b>
/// (<c>docs/research/2026-08-11-fa-panel-structure.md</c> §10) closed lane-A
/// unknown U2: Fellowship and Allegiance are NOT two separate
/// <c>gmPanelUI</c> siblings — they are two of FOUR pages of ONE tabbed
/// panel, the same authored Type-8 tab-host class OP2/OP3 already ported.
/// </para>
///
/// <para>
/// <b>Tab-table pairing — CORRECTS the coordinator addendum's guess.</b> The
/// addendum inferred the button→page pairing from authored x-order (assuming
/// button/page ids increase in Friends/Allegiance/Fellowship/Squelch order).
/// The FA3 fixture dump (<c>social_panel_2100006E_1000018F.json</c>) and the
/// live-mount probe (<see cref="SocialFriendsPageController"/> et al.'s
/// sibling test, <c>SocialPanelLiveMountProbeTests</c>) read the real
/// authored <c>0x2E</c> tab table and found a DIFFERENT pairing — each
/// button's own caption confirms its real page:
/// <code>
/// button 0x1000028C ("Allegiance") -&gt; page 0x10000291 (Allegiance) DEFAULT
/// button 0x1000028E ("Fellowship") -&gt; page 0x10000292 (Fellowship)
/// button 0x10000512 ("Friends") -&gt; page 0x10000513 (Friends)
/// button 0x1000053B ("Squelch") -&gt; page 0x1000054A (Squelch)
/// </code>
/// The panel's authored DEFAULT tab is therefore <b>Allegiance</b>, not
/// Friends — consistent with each page's own <c>P0x57</c> property, which
/// (unlike the panel root's own unconsumed <c>P0x57</c>, see below) DOES
/// line up with real retail ActionMap ids: the Allegiance page carries
/// <c>P0x57 = 0x1000000E</c> (== <c>ToggleAllegiancePanel</c>, F3) and the
/// Fellowship page carries <c>P0x57 = 0x1000000F</c> (==
/// <c>ToggleFellowshipPanel</c>, F4) — an independent, DAT-authored
/// confirmation of which page each keybind should land on. (The Friends and
/// Squelch pages ALSO carry their own dedicated action ids, <c>0x10000118</c>
/// and <c>0x10000124</c> — neither has a retail-default keybind per lane A's
/// keymap sweep, and this campaign does not add one.)
/// </para>
///
/// <para>
/// <b>Close button.</b> <c>0x10000290</c> (Type 1, top-right at
/// <c>(276,0) 24x25</c>) — its <c>P0x12 = 0x1000000D</c> matches the panel
/// root's own <c>P0x57 = 0x1000000D</c>. Neither value corresponds to a
/// per-tab open action (retail's <c>ID_InputMap_ToggleChatEntry</c> uses
/// <c>0x1000000D</c> as an unrelated input-map CONTEXT id, not an action —
/// <c>RetailActionMap.cs:97,161</c>); no
/// <c>GetAttribute_Enum(this, 0x57, …)</c> read site exists for either panel
/// in the binary (lane A §6.1), so this property is authored but UNCONSUMED
/// by retail code, same UNVERIFIED status the Options-panel research
/// recorded. This controller does not interpret the value — it simply wires
/// the close button by its confirmed element id, exactly like
/// <see cref="OptionsPanelController"/>'s own <c>CloseButtonId</c>.
/// </para>
/// </summary>
public sealed class SocialPanelController : IRetainedPanelController
{
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
public const uint HostLayoutId = 0x2100006Eu;
/// <summary>The social panel's slot within <see cref="HostLayoutId"/>'s
/// shared <c>gmPanelUI</c> page stack — also its
/// <see cref="AcDream.App.UI.RetailPanelCatalog"/> panel id (12)'s element
/// identity. Byte-verified against the live installed DATs (coordinator
/// addendum §10, <c>FaPanelSlotProbeTests</c>).</summary>
public const uint SlotElementId = 0x1000018Fu;
// Page slot ids (FA3 live-mount probe — coordinator addendum §10 table).
private const uint FriendsPageId = 0x10000513u;
private const uint AllegiancePageId = 0x10000291u;
private const uint FellowshipPageId = 0x10000292u;
private const uint SquelchPageId = 0x1000054Au;
/// <summary>Close (X) button — see class doc.</summary>
private const uint CloseButtonId = 0x10000290u;
/// <summary>Callback delegates + live-state accessors this controller
/// wires the four pages and the close button to.</summary>
public sealed record Callbacks(
Action Toggle,
Func<RuntimeFellowshipSnapshot> FellowshipSnapshot,
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot,
FriendsState Friends,
SquelchState Squelch,
Func<uint, uint, UiElement?> TemplateResolver);
private readonly UiTabPanel _tabPanel;
private readonly SocialFellowshipPageController? _fellowship;
private readonly SocialAllegiancePageController? _allegiance;
private readonly SocialFriendsPageController? _friends;
private readonly SocialSquelchPageController? _squelch;
private bool _disposed;
/// <summary>Root element of the imported panel (the tab host itself —
/// this widget IS a <see cref="UiTabPanel"/>).</summary>
public UiElement Root => _tabPanel;
/// <summary>The underlying tab-control widget, for callers that need
/// direct tab-switch access (e.g. tests).</summary>
public UiTabPanel TabPanel => _tabPanel;
private SocialPanelController(
UiTabPanel tabPanel,
SocialFellowshipPageController? fellowship,
SocialAllegiancePageController? allegiance,
SocialFriendsPageController? friends,
SocialSquelchPageController? squelch)
{
_tabPanel = tabPanel;
_fellowship = fellowship;
_allegiance = allegiance;
_friends = friends;
_squelch = squelch;
}
/// <summary>
/// Bind an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
/// layout to live behavior — the SAME "import via the host slot, then
/// Build+Bind" shape <see cref="OptionsPanelController.Bind"/> uses.
/// </summary>
public static SocialPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(callbacks);
if (layout.Root is not UiTabPanel tabPanel)
{
Console.WriteLine(
"[D.2b] SocialPanelController.Bind: root did not build as UiTabPanel "
+ $"(actual type {layout.Root.GetType().Name}) — social panel will not open.");
return null;
}
if (layout.FindElement(CloseButtonId) is UiButton close)
close.OnClick = callbacks.Toggle;
else
Console.WriteLine(
$"[D.2b] SocialPanelController: close button 0x{CloseButtonId:X8} "
+ "not found in the built layout — its handler was not wired.");
UiElement? fellowshipPage = UiElement.FindDescendant(tabPanel, FellowshipPageId);
UiElement? allegiancePage = UiElement.FindDescendant(tabPanel, AllegiancePageId);
UiElement? friendsPage = UiElement.FindDescendant(tabPanel, FriendsPageId);
UiElement? squelchPage = UiElement.FindDescendant(tabPanel, SquelchPageId);
SocialFellowshipPageController? fellowship = fellowshipPage is null
? null
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.FellowshipSnapshot);
SocialAllegiancePageController? allegiance = allegiancePage is null
? null
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
SocialFriendsPageController? friends = friendsPage is null
? null
: SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver);
SocialSquelchPageController? squelch = squelchPage is null
? null
: SocialSquelchPageController.Bind(squelchPage, callbacks.Squelch, callbacks.TemplateResolver);
if (fellowshipPage is null)
Console.WriteLine($"[D.2b] SocialPanelController: Fellowship page 0x{FellowshipPageId:X8} not found.");
if (allegiancePage is null)
Console.WriteLine($"[D.2b] SocialPanelController: Allegiance page 0x{AllegiancePageId:X8} not found.");
if (friendsPage is null)
Console.WriteLine($"[D.2b] SocialPanelController: Friends page 0x{FriendsPageId:X8} not found.");
if (squelchPage is null)
Console.WriteLine($"[D.2b] SocialPanelController: Squelch page 0x{SquelchPageId:X8} not found.");
return new SocialPanelController(tabPanel, fellowship, allegiance, friends, squelch);
}
/// <summary>
/// Activates the tab-switching behavior (idempotent). Must run AFTER
/// <see cref="Bind"/> so the page-shown wiring above is already in
/// place — same ordering constraint as
/// <see cref="OptionsPanelController.ActivateTabs"/>. The authored
/// default entry (Allegiance — see class doc) activates here.
/// </summary>
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
/// <summary>F3 <c>ToggleAllegiancePanel</c>'s tab-switch half — the
/// panel-visibility half is <see cref="RetailUiRuntime"/>'s job (the
/// gmPanelUI one-active-panel exclusivity, shared with every sibling
/// panel via <see cref="RetailPanelUiController.RegisterMainPanel"/>).</summary>
public void ShowAllegiance() => _tabPanel.SwitchTo(AllegiancePageId);
/// <summary>F4 <c>ToggleFellowshipPanel</c>'s tab-switch half.</summary>
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
/// <summary>True when the Allegiance tab is the active page — lets
/// <see cref="RetailUiRuntime.HandleInputAction"/> implement retail's
/// Toggle-action close-on-second-press semantics (same shape as
/// <c>OpenSpellbook</c>'s own page-aware toggle).</summary>
public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId;
/// <summary>True when the Fellowship tab is the active page.</summary>
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
/// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
/// data-driven <see cref="UiElement.Visible"/> provider exists) and the
/// Friends/Squelch read-only list rebuilds (revision-gated — cheap when
/// nothing changed). Called from <see cref="RetailUiRuntime.Tick"/>
/// alongside every other retained panel's own <c>Tick()</c>.
/// </summary>
public void Tick()
{
_fellowship?.Tick();
_allegiance?.Tick();
_friends?.Tick();
_squelch?.Tick();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}

View file

@ -0,0 +1,32 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3: shared row-text lookup for the social panel's
/// Friends/Squelch read-only list rows. Both row templates
/// (<c>0x2100005D</c>/<c>0x10000519</c> for Friends,
/// <c>0x21000060</c>/<c>0x10000541</c> for Squelch — FA3 live-mount probe)
/// author the SAME two-deep nested-text shape: a Type-3 row root, one
/// Type-0xC text child, and ONE MORE Type-0xC text grandchild at the
/// identical rect. Neither <c>gmFriendsUI</c> nor <c>gmSquelchUI</c> is in
/// this campaign's decompiled scope (lane A/B/C/D cover only Fellowship/
/// Allegiance), so which of the two carries the actual glyph flow is not
/// established from retail decomp — the innermost leaf is used here
/// (deepest-first is the common "structural wrapper, then label leaf"
/// shape elsewhere in this dat format).
/// </summary>
internal static class SocialPanelRowText
{
/// <summary>Returns the deepest <see cref="UiText"/> descendant of
/// <paramref name="row"/> (pre-order, last match wins), or null if the
/// built row contains no text element at all.</summary>
public static UiText? FindDeepest(UiElement row)
{
UiText? found = row as UiText;
foreach (UiElement child in row.Children)
{
if (FindDeepest(child) is { } deeper)
found = deeper;
}
return found;
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.Numerics;
using AcDream.Core.Social;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign FA slice FA3, D1: the Squelch page's read-only roster — names
/// only, bound directly to the J4.1 <see cref="SquelchState"/> owner
/// (<c>RuntimeCommunicationState.Squelch</c>). Lists both squelched
/// characters (<see cref="SquelchDatabase.Characters"/>, keyed by the
/// 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"/>.
///
/// <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.
/// </para>
///
/// <para>
/// Row template <c>0x21000060</c>/<c>0x10000541</c> (FA3 live-mount probe);
/// see <see cref="SocialPanelRowText"/> for the name-text resolution note
/// (<c>gmSquelchUI</c> is outside this campaign's decompiled scope).
/// </para>
/// </summary>
public sealed class SocialSquelchPageController
{
private const uint ListBoxId = 0x1000053Eu;
private readonly UiTemplateListBox _listBox;
private readonly SquelchState _squelch;
private long _lastRevision = long.MinValue;
private SocialSquelchPageController(UiTemplateListBox listBox, SquelchState squelch)
{
_listBox = listBox;
_squelch = squelch;
}
public static SocialSquelchPageController? Bind(
UiElement pageRoot,
SquelchState squelch,
Func<uint, uint, UiElement?> templateResolver)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(squelch);
ArgumentNullException.ThrowIfNull(templateResolver);
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] SocialSquelchPageController: ListBox 0x{ListBoxId:X8} not "
+ "found — Squelch page will not populate.");
return null;
}
listBox.TemplateResolver = templateResolver;
var controller = new SocialSquelchPageController(listBox, squelch);
controller.Refresh();
return controller;
}
public void Tick()
{
long revision = _squelch.Revision;
if (revision == _lastRevision) return;
Refresh();
}
private void Refresh()
{
_lastRevision = _squelch.Revision;
_listBox.Flush();
SquelchDatabase database = _squelch.Snapshot();
foreach (SquelchInfo character in database.Characters.Values)
AddRow(character.Name);
foreach (string accountName in database.Accounts.Keys)
AddRow(accountName);
}
private void AddRow(string name)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) return;
if (SocialPanelRowText.FindDeepest(row) is { } text)
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
}
}

View file

@ -28,6 +28,20 @@ public static class RetailPanelCatalog
/// </summary>
public const uint Options = 10u;
/// <summary>
/// Campaign FA slice FA3: the four-tab Friends/Allegiance/Fellowship/
/// Squelch panel's <c>gmPanelUI</c> slot key — byte-verified from the
/// live installed DATs (host <c>0x2100006E</c> slot <c>0x1000018F</c>'s
/// own authored <c>0x10000029 = 12</c>, <c>FaPanelSlotProbeTests</c>).
/// No toolbar button authors this id (lane A §6.1: the open path is
/// keyboard-only, F3/F4) — <see cref="SocialPanel"/> is therefore in
/// <see cref="Mounted"/> only, not <see cref="Toolbar"/>, the same
/// convention <see cref="PositiveEffects"/>/<see cref="NegativeEffects"/>/
/// <see cref="LinkStatus"/>/<see cref="MiniGame"/>/<see cref="Vitae"/>
/// already use for their own non-toolbar open paths.
/// </summary>
public const uint SocialPanel = 12u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(CharacterInformation, WindowNames.CharacterInformation),
@ -40,6 +54,7 @@ public static class RetailPanelCatalog
(Magic, WindowNames.Spellbook),
(Vitae, WindowNames.Vitae),
(Options, WindowNames.Options),
(SocialPanel, WindowNames.SocialPanel),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =

View file

@ -216,6 +216,22 @@ public sealed record OptionsRuntimeBindings(
Func<AudioSettings> LoadAudio,
Action<AudioSettings> SaveAudio);
/// <summary>
/// Campaign FA slice FA3: the social panel's (Friends/Allegiance/
/// Fellowship/Squelch) own bindings — the FA2 owners' typed snapshot
/// readers plus the two J4.1 social-list owners, direct instances (not the
/// <see cref="AcDream.Runtime.IRuntimeSocialView"/> view seam bots use — that
/// view has no full-collection enumeration, only per-id lookup + counts;
/// the panel's read-only rows need the actual entries, same access shape
/// <see cref="AcDream.App.Composition.InteractionRetainedUiDependencies.Communication"/>
/// already exposes for every other Communication-owned consumer).
/// </summary>
public sealed record SocialRuntimeBindings(
Func<AcDream.Runtime.RuntimeFellowshipSnapshot> FellowshipSnapshot,
Func<AcDream.Runtime.RuntimeAllegianceSnapshot> AllegianceSnapshot,
AcDream.Core.Social.FriendsState Friends,
AcDream.Core.Social.SquelchState Squelch);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
@ -318,6 +334,7 @@ public sealed record RetailUiRuntimeBindings(
ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options,
SocialRuntimeBindings Social,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@ -402,6 +419,7 @@ public sealed class RetailUiRuntime : IDisposable
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountSocialPanel();
MountCharacter();
MountPlugins();
MountInventory();
@ -495,6 +513,7 @@ public sealed class RetailUiRuntime : IDisposable
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -537,6 +556,7 @@ public sealed class RetailUiRuntime : IDisposable
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
@ -559,9 +579,46 @@ public sealed class RetailUiRuntime : IDisposable
OpenSpellbook(SpellbookWindowPage.Components);
return true;
}
// Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A
// §6.1: neither action authors a toolbar button). Both share the
// one social panel (RetailPanelCatalog.SocialPanel) and switch to
// their own tab; the panel participates in the SAME gmPanelUI
// one-active-panel exclusivity every sibling panel gets from
// RetailPanelUiController.RegisterMainPanel.
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel)
{
OpenSocialPanel(showAllegiance: true);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel)
{
OpenSocialPanel(showAllegiance: false);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
/// <summary>Shared F3/F4 handler — same "toggle closes on a repeat press
/// of the SAME tab, otherwise show + switch" shape as <see cref="OpenSpellbook"/>.</summary>
private void OpenSocialPanel(bool showAllegiance)
{
bool visible = Host.IsWindowVisible(WindowNames.SocialPanel);
bool onTargetTab = showAllegiance
? SocialPanelController?.IsShowingAllegiance == true
: SocialPanelController?.IsShowingFellowship == true;
if (visible && onTargetTab)
{
CloseWindow(WindowNames.SocialPanel);
return;
}
if (showAllegiance)
SocialPanelController?.ShowAllegiance();
else
SocialPanelController?.ShowFellowship();
_panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: true);
}
private void OpenSpellbook(SpellbookWindowPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
@ -2526,6 +2583,118 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
}
/// <summary>
/// Campaign FA slice FA3: retail's four-tab social panel — Friends /
/// Allegiance / Fellowship / Squelch, host <c>0x2100006E</c> slot
/// <c>0x1000018F</c>, <see cref="RetailPanelCatalog.SocialPanel"/> (12).
/// Same five-step shape as <see cref="MountOptionsPanel"/>: Import →
/// build callbacks → <see cref="Layout.SocialPanelController.Bind"/> →
/// <see cref="Layout.SocialPanelController.ActivateTabs"/> →
/// <see cref="RetailWindowFrame.Mount"/> → <see cref="RetailPanelUiController.RegisterMainPanel"/>.
/// </summary>
private void MountSocialPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.SocialPanelController.HostLayoutId,
Layout.SocialPanelController.SlotElementId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine("[UI] social panel: LayoutDesc 0x2100006E slot 0x1000018F not found.");
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.
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
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;
}
}
var callbacks = new Layout.SocialPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.SocialPanel),
FellowshipSnapshot: _bindings.Social.FellowshipSnapshot,
AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver);
Layout.SocialPanelController? controller =
Layout.SocialPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] social panel: required root did not build as UiTabPanel.");
return;
}
controller.ActivateTabs();
SocialPanelController = controller;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.SocialPanel,
Chrome = RetailWindowChrome.NineSlice,
Left = 200f,
Top = 140f,
Visible = false,
// Same shared main-panel geometry policy every gmPanelUI
// sibling uses (Options' own MUST-FIX 2 correction, above).
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.SocialPanel,
WindowNames.SocialPanel,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
}
private void MountDialogFactory()
{
uint layoutId;

View file

@ -235,4 +235,18 @@ public sealed class UiTemplateListBox : UiDatElement
viewport.AddChild(row);
return row;
}
/// <summary>
/// Campaign FA slice FA3: removes every row previously added via
/// <see cref="AddItemFromTemplateList"/>/<see cref="AddPrebuiltRow"/>, resetting
/// <see cref="ContentHeight"/> to 0 — the "Gap found" prerequisite lane A/D
/// flagged (docs/research/2026-08-11-fa-panel-structure.md §6.6: "no Flush, no
/// selection model, no per-row instance-id"). Needed for a poll-and-rebuild
/// binding (Friends/Squelch read-only lists) where the row COUNT can shrink
/// between refreshes — <see cref="AddItemFromTemplateList"/> alone only ever
/// grows the stack. A no-op, never allocating the viewport, when the box is
/// still dormant (no row has ever been added) — mirrors every other dormancy
/// guard on this class (see class doc).
/// </summary>
public void Flush() => _viewport?.ClearContent();
}

View file

@ -29,4 +29,8 @@ public static class WindowNames
public const string Vendor = "vendor";
public const string Options = "options";
public const string KeyboardConfig = "keyboard-config";
/// <summary>Campaign FA slice FA3: the four-tab Friends/Allegiance/
/// Fellowship/Squelch panel (<see cref="RetailPanelCatalog.SocialPanel"/>).</summary>
public const string SocialPanel = "social-panel";
}