diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index f56625ee..ea583539 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 2. Adaptation (AD) — 58 active rows (AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
+## 2. Adaptation (AD) — 59 active rows (AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate
visible-cell availability, full-catalog containment-root validation, and the
@@ -173,6 +173,7 @@ readiness/requeue adaptation. See
| AD-76 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** In-Game Help Files (`0x10000205`) is authored and clickable but has no handler — clicking it does nothing visible. | `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring — no callback bound) | Retail's own `KeyStone::OpenHelp` loads a third-party embedded help viewer (`plugins\ACHelpPlugin.dll` via `keystone.dll`) that acdream does not have and cannot port (no DAT-resident help content, no source). Retail ITSELF fails silently with the plugin absent (`KeyStone::m_fnAC2HelpPluginExecute` unresolved) — mirroring that as an inert button is the faithful behavior for "the asset is missing", not an invented stub screen. | A user clicking In-Game Help Files gets no feedback at all, same as retail with the plugin missing — indistinguishable from a dead button unless they already expect the asset-missing case. | `KeyStone::OpenHelp @0x00557010`; `KeyStone::Init @0x00556CF0` (the unresolved plugin function pointer); research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.5 |
| AD-77 | **Filed 2026-08-11 at the Campaign OP OP3 review-fix round (dual-review S4/MUST-FIX 2 — the plan's §5 "out of scope" list explicitly delegated this ruling to the OP3 review).** Retail exposes TWO `gmPanelUI` host variants for the same panel stack — a floating host (`0x2100006E`, `gmFloatyPanelUI`) and a docked host (`0x21000017`) — so a retail user can dock the Options panel (and every other `gmPanelUI` sibling) into a fixed screen position instead of leaving it freely floating. acdream mounts every main panel through `RetailWindowFrame.Mount` + `RetailPanelUiController.RegisterMainPanel` against the floating host ONLY; no code path resolves or mounts `0x21000017` at all. | `src/AcDream.App/UI/RetailUiRuntime.cs` (every `Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling — Character/Inventory/Spellbook/Effects/the four indicator-detail panels/Options); `src/AcDream.App/UI/Layout/RetailWindowFrame.cs` | This predates OP3 — every `gmPanelUI` sibling has shipped floating-only since its own slice landed; OP3 did not introduce the gap, it just added a tenth panel to an already-floating-only cohort. The plan explicitly scoped filing the row to "whichever slice's review deems it a divergence" rather than blocking any one panel's slice on building a docked-host variant no prior panel has either. | A user who expects to dock the Options panel (or any other main panel) the way retail allows cannot — every `gmPanelUI` sibling is floating-only in acdream, client-wide, not an Options-specific gap. | research doc `2026-08-10-options-panel-structure.md` §10.1 (docked/floating host pair); `docs/plans/2026-08-10-options-panel-campaign.md` §5 |
| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (21 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (35 of 50 rows dimmed — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) |
+| AD-79 | **Filed 2026-08-12 at Campaign FA slice FA3, D1 (the plan's "Friends + Squelch pages bind READ-ONLY... their mutation actions are wired only if their wire is already served by ACE and trivially pinnable in-slice — otherwise the action buttons are honest INERT" decision).** The social panel's Friends page authors three buttons (Add/Remove Friend-shaped, `0x10000514`/`0x10000515`/`0x10000516`) plus an "Appear Offline"-shaped checkbox (`0x1000052C`); the Squelch page authors three buttons (`0x10000547`/`0x1000054B`/`0x1000054C`). All seven are built, laid out, and clickable exactly as authored, but carry no click handler — no Friends add/remove/appear-offline wire and no Squelch add/remove/clear wire is implemented this campaign. `gmFriendsUI`/`gmSquelchUI` were also outside lane A/B/C/D's own decompiled scope (only Fellowship/Allegiance were researched), so their real button semantics and wire opcodes are not yet established either — this row covers BOTH "not wired" and "not yet researched." | `src/AcDream.App/UI/Layout/SocialFriendsPageController.cs`; `src/AcDream.App/UI/Layout/SocialSquelchPageController.cs` (both classes' own doc comments cite this row) | FA3 is the panel SHELL slice; D1 sets the bar for which Friends/Squelch actions get wired in-slice at "trivially pinnable," which none of these seven meet without their own wire research. `SocialPanelControllerTests.FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler` pins the INERT contract so a future consumer landing without also removing this row's citation fails nothing silently — the row is the only signal until a follow-up slice wires real handlers. | A user clicking Add/Remove Friend, Appear Offline, or any Squelch button in acdream sees no effect and no feedback — indistinguishable from a dead control unless they already expect the gap. The Friends/Squelch LISTS themselves are live (bound read-only to `RuntimeCommunicationState.Friends`/`.Squelch`) — only the mutation controls are inert. | None (no retail decomp anchor — `gmFriendsUI`/`gmSquelchUI` are outside this campaign's researched scope); `docs/research/2026-08-11-fa-panel-structure.md` §10 (coordinator addendum, the panel discovery that first surfaced these two pages); `docs/plans/2026-08-11-fellowship-allegiance-campaign.md` D1 |
---
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index 415374bf..a9512376 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -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,
diff --git a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
new file mode 100644
index 00000000..c784e32e
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using AcDream.Runtime;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// 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).
+///
+///
+/// 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:
+/// — 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.
+///
+///
+///
+/// Element ids confirmed by the FA3 live-mount probe:
+/// 0x10000255 monarch-field container, 0x1000025A
+/// patron-field container, 0x10000257 monarch-name text (inside the
+/// monarch field), 0x1000025C patron-name text (inside the patron
+/// field). Both lookups are SCOPED to the allegiance page root — the panel
+/// also authors 0x10000492 (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
+/// 0x10000211-in-two-layouts lesson).
+///
+///
+public sealed class SocialAllegiancePageController
+{
+ private static readonly IReadOnlyList BlankLine =
+ [new UiText.Line(" ", System.Numerics.Vector4.One)];
+
+ private static readonly IReadOnlyList NoLines = [];
+
+ private readonly UiElement _monarchField;
+ private readonly UiElement _patronField;
+ private readonly UiText? _monarchName;
+ private readonly UiText? _patronName;
+ private readonly Func _snapshot;
+
+ private SocialAllegiancePageController(
+ UiElement monarchField,
+ UiElement patronField,
+ UiText? monarchName,
+ UiText? patronName,
+ Func snapshot)
+ {
+ _monarchField = monarchField;
+ _patronField = patronField;
+ _monarchName = monarchName;
+ _patronName = patronName;
+ _snapshot = snapshot;
+ }
+
+ public static SocialAllegiancePageController? Bind(
+ UiElement pageRoot,
+ Func 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;
+ }
+
+ ///
+ /// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap
+ /// — called every frame from (same
+ /// reasoning as ).
+ ///
+ public void Tick()
+ {
+ bool hasProfile = _snapshot().HasProfile;
+ _monarchField.Visible = hasProfile;
+ _patronField.Visible = hasProfile;
+
+ IReadOnlyList lines = hasProfile ? NoLines : BlankLine;
+ if (_monarchName is not null) _monarchName.LinesProvider = () => lines;
+ if (_patronName is not null) _patronName.LinesProvider = () => lines;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
new file mode 100644
index 00000000..d652fe8c
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
@@ -0,0 +1,78 @@
+using System;
+using AcDream.Runtime;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// 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).
+///
+///
+/// Retail gmFellowshipUI::Update @0x0048F440
+/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
+/// m_pNotInAFellowshipFrame (0x1000026B) shows and
+/// m_pInAFellowshipFrame (0x10000275) hides when
+/// m_pFellowship == null, and vice-versa. Both are AUTHORED sibling
+/// containers of the fellowship page (0x10000292) — confirmed by the
+/// FA3 live-mount probe: 0x1000026B holds the name-entry box, Create
+/// button, and all three visible option checkboxes; 0x10000275 holds
+/// the roster list and all six member-management buttons. A single
+/// toggle on each container is therefore the
+/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
+/// unknown U6).
+///
+///
+public sealed class SocialFellowshipPageController
+{
+ private readonly UiElement _notInFellowshipFrame;
+ private readonly UiElement _inFellowshipFrame;
+ private readonly Func _snapshot;
+
+ private SocialFellowshipPageController(
+ UiElement notInFellowshipFrame,
+ UiElement inFellowshipFrame,
+ Func snapshot)
+ {
+ _notInFellowshipFrame = notInFellowshipFrame;
+ _inFellowshipFrame = inFellowshipFrame;
+ _snapshot = snapshot;
+ }
+
+ public static SocialFellowshipPageController? Bind(
+ UiElement pageRoot,
+ Func 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;
+ }
+
+ ///
+ /// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
+ /// bool writes) — called every frame from
+ /// rather than gated on
+ /// page-shown/revision, since has no
+ /// data-driven provider mechanism of its own (unlike ,
+ /// which is polled by the render loop already).
+ ///
+ public void Tick()
+ {
+ bool inFellowship = _snapshot().IsInFellowship;
+ _notInFellowshipFrame.Visible = !inFellowship;
+ _inFellowshipFrame.Visible = inFellowship;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
new file mode 100644
index 00000000..86a18ae3
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Social;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Campaign FA slice FA3, D1: the Friends page's read-only roster — names
+/// only, bound directly to the J4.1 owner
+/// (RuntimeCommunicationState.Friends). Rebuilds on
+/// change; polled every frame from
+/// (cheap — a single
+/// comparison when nothing changed).
+///
+///
+/// D1 — inert actions. 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 (0x10000514/0x10000515/0x10000516) and the
+/// "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).
+///
+///
+///
+/// Row template 0x2100005D/0x10000519 (FA3 live-mount probe);
+/// 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).
+///
+///
+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 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)];
+ }
+ }
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs
new file mode 100644
index 00000000..41326f65
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs
@@ -0,0 +1,229 @@
+using System;
+using AcDream.Core.Social;
+using AcDream.Runtime;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Mounts retail's four-tab social panel — Friends / Allegiance /
+/// Fellowship / Squelch — LayoutDesc 0x2100006E slot
+/// 0x1000018F, id
+/// 12. Campaign FA slice FA3 (2026-08-12), built on the OP3
+/// recipe (Type-8 tab host,
+/// , per-page scoped
+/// controllers).
+///
+///
+/// Coordinator addendum
+/// (docs/research/2026-08-11-fa-panel-structure.md §10) closed lane-A
+/// unknown U2: Fellowship and Allegiance are NOT two separate
+/// gmPanelUI siblings — they are two of FOUR pages of ONE tabbed
+/// panel, the same authored Type-8 tab-host class OP2/OP3 already ported.
+///
+///
+///
+/// Tab-table pairing — CORRECTS the coordinator addendum's guess. 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 (social_panel_2100006E_1000018F.json) and the
+/// live-mount probe ( et al.'s
+/// sibling test, SocialPanelLiveMountProbeTests) read the real
+/// authored 0x2E tab table and found a DIFFERENT pairing — each
+/// button's own caption confirms its real page:
+///
+/// button 0x1000028C ("Allegiance") -> page 0x10000291 (Allegiance) DEFAULT
+/// button 0x1000028E ("Fellowship") -> page 0x10000292 (Fellowship)
+/// button 0x10000512 ("Friends") -> page 0x10000513 (Friends)
+/// button 0x1000053B ("Squelch") -> page 0x1000054A (Squelch)
+///
+/// The panel's authored DEFAULT tab is therefore Allegiance, not
+/// Friends — consistent with each page's own P0x57 property, which
+/// (unlike the panel root's own unconsumed P0x57, see below) DOES
+/// line up with real retail ActionMap ids: the Allegiance page carries
+/// P0x57 = 0x1000000E (== ToggleAllegiancePanel, F3) and the
+/// Fellowship page carries P0x57 = 0x1000000F (==
+/// ToggleFellowshipPanel, 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, 0x10000118
+/// and 0x10000124 — neither has a retail-default keybind per lane A's
+/// keymap sweep, and this campaign does not add one.)
+///
+///
+///
+/// Close button. 0x10000290 (Type 1, top-right at
+/// (276,0) 24x25) — its P0x12 = 0x1000000D matches the panel
+/// root's own P0x57 = 0x1000000D. Neither value corresponds to a
+/// per-tab open action (retail's ID_InputMap_ToggleChatEntry uses
+/// 0x1000000D as an unrelated input-map CONTEXT id, not an action —
+/// RetailActionMap.cs:97,161); no
+/// GetAttribute_Enum(this, 0x57, …) 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
+/// 's own CloseButtonId.
+///
+///
+public sealed class SocialPanelController : IRetainedPanelController
+{
+ /// The floating host LayoutDesc the tab panel is resolved through.
+ public const uint HostLayoutId = 0x2100006Eu;
+
+ /// The social panel's slot within 's
+ /// shared gmPanelUI page stack — also its
+ /// panel id (12)'s element
+ /// identity. Byte-verified against the live installed DATs (coordinator
+ /// addendum §10, FaPanelSlotProbeTests).
+ 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;
+
+ /// Close (X) button — see class doc.
+ private const uint CloseButtonId = 0x10000290u;
+
+ /// Callback delegates + live-state accessors this controller
+ /// wires the four pages and the close button to.
+ public sealed record Callbacks(
+ Action Toggle,
+ Func FellowshipSnapshot,
+ Func AllegianceSnapshot,
+ FriendsState Friends,
+ SquelchState Squelch,
+ Func TemplateResolver);
+
+ private readonly UiTabPanel _tabPanel;
+ private readonly SocialFellowshipPageController? _fellowship;
+ private readonly SocialAllegiancePageController? _allegiance;
+ private readonly SocialFriendsPageController? _friends;
+ private readonly SocialSquelchPageController? _squelch;
+ private bool _disposed;
+
+ /// Root element of the imported panel (the tab host itself —
+ /// this widget IS a ).
+ public UiElement Root => _tabPanel;
+
+ /// The underlying tab-control widget, for callers that need
+ /// direct tab-switch access (e.g. tests).
+ 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;
+ }
+
+ ///
+ /// Bind an imported /
+ /// layout to live behavior — the SAME "import via the host slot, then
+ /// Build+Bind" shape uses.
+ ///
+ 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);
+ }
+
+ ///
+ /// Activates the tab-switching behavior (idempotent). Must run AFTER
+ /// so the page-shown wiring above is already in
+ /// place — same ordering constraint as
+ /// . The authored
+ /// default entry (Allegiance — see class doc) activates here.
+ ///
+ public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
+
+ /// F3 ToggleAllegiancePanel's tab-switch half — the
+ /// panel-visibility half is 's job (the
+ /// gmPanelUI one-active-panel exclusivity, shared with every sibling
+ /// panel via ).
+ public void ShowAllegiance() => _tabPanel.SwitchTo(AllegiancePageId);
+
+ /// F4 ToggleFellowshipPanel's tab-switch half.
+ 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).
+ public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId;
+
+ /// True when the Fellowship tab is the active page.
+ public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
+
+ ///
+ /// 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().
+ ///
+ public void Tick()
+ {
+ _fellowship?.Tick();
+ _allegiance?.Tick();
+ _friends?.Tick();
+ _squelch?.Tick();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SocialPanelRowText.cs b/src/AcDream.App/UI/Layout/SocialPanelRowText.cs
new file mode 100644
index 00000000..85e824af
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SocialPanelRowText.cs
@@ -0,0 +1,32 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Campaign FA slice FA3: shared row-text lookup for the social panel's
+/// Friends/Squelch read-only list rows. Both row templates
+/// (0x2100005D/0x10000519 for Friends,
+/// 0x21000060/0x10000541 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 gmFriendsUI nor gmSquelchUI 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).
+///
+internal static class SocialPanelRowText
+{
+ /// Returns the deepest descendant of
+ /// (pre-order, last match wins), or null if the
+ /// built row contains no text element at all.
+ 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;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
new file mode 100644
index 00000000..6bbc98ad
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Social;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Campaign FA slice FA3, D1: the Squelch page's read-only roster — names
+/// only, bound directly to the J4.1 owner
+/// (RuntimeCommunicationState.Squelch). Lists both squelched
+/// characters (, keyed by the
+/// character's own display name) and squelched accounts
+/// (, whose dictionary KEY is the
+/// account name itself). Rebuilds on
+/// change; polled every frame from .
+///
+///
+/// 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.
+///
+///
+///
+/// Row template 0x21000060/0x10000541 (FA3 live-mount probe);
+/// see for the name-text resolution note
+/// (gmSquelchUI is outside this campaign's decompiled scope).
+///
+///
+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 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)];
+ }
+}
diff --git a/src/AcDream.App/UI/RetailPanelCatalog.cs b/src/AcDream.App/UI/RetailPanelCatalog.cs
index c056cdb0..4b8eee25 100644
--- a/src/AcDream.App/UI/RetailPanelCatalog.cs
+++ b/src/AcDream.App/UI/RetailPanelCatalog.cs
@@ -28,6 +28,20 @@ public static class RetailPanelCatalog
///
public const uint Options = 10u;
+ ///
+ /// Campaign FA slice FA3: the four-tab Friends/Allegiance/Fellowship/
+ /// Squelch panel's gmPanelUI slot key — byte-verified from the
+ /// live installed DATs (host 0x2100006E slot 0x1000018F's
+ /// own authored 0x10000029 = 12, FaPanelSlotProbeTests).
+ /// No toolbar button authors this id (lane A §6.1: the open path is
+ /// keyboard-only, F3/F4) — is therefore in
+ /// only, not , the same
+ /// convention //
+ /// //
+ /// already use for their own non-toolbar open paths.
+ ///
+ 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 =
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 031d3797..dc491450 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -216,6 +216,22 @@ public sealed record OptionsRuntimeBindings(
Func LoadAudio,
Action SaveAudio);
+///
+/// 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
+/// 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
+///
+/// already exposes for every other Communication-owned consumer).
+///
+public sealed record SocialRuntimeBindings(
+ Func FellowshipSnapshot,
+ Func AllegianceSnapshot,
+ AcDream.Core.Social.FriendsState Friends,
+ AcDream.Core.Social.SquelchState Squelch);
+
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func 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;
}
+ /// Shared F3/F4 handler — same "toggle closes on a repeat press
+ /// of the SAME tab, otherwise show + switch" shape as .
+ 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.");
}
+ ///
+ /// Campaign FA slice FA3: retail's four-tab social panel — Friends /
+ /// Allegiance / Fellowship / Squelch, host 0x2100006E slot
+ /// 0x1000018F, (12).
+ /// Same five-step shape as : Import →
+ /// build callbacks → →
+ /// →
+ /// → .
+ ///
+ 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;
diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs
index 5968899d..7da7f4de 100644
--- a/src/AcDream.App/UI/UiTemplateListBox.cs
+++ b/src/AcDream.App/UI/UiTemplateListBox.cs
@@ -235,4 +235,18 @@ public sealed class UiTemplateListBox : UiDatElement
viewport.AddChild(row);
return row;
}
+
+ ///
+ /// Campaign FA slice FA3: removes every row previously added via
+ /// /, resetting
+ /// 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 — 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).
+ ///
+ public void Flush() => _viewport?.ClearContent();
}
diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs
index 7fe44778..7af84385 100644
--- a/src/AcDream.App/UI/WindowNames.cs
+++ b/src/AcDream.App/UI/WindowNames.cs
@@ -29,4 +29,8 @@ public static class WindowNames
public const string Vendor = "vendor";
public const string Options = "options";
public const string KeyboardConfig = "keyboard-config";
+
+ /// Campaign FA slice FA3: the four-tab Friends/Allegiance/
+ /// Fellowship/Squelch panel ().
+ public const string SocialPanel = "social-panel";
}