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

@ -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-4s 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 retails 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-4s 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 retails 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 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 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-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-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-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 |
--- ---

View file

@ -870,6 +870,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
SaveDisplay: d.Settings.SaveDisplay, SaveDisplay: d.Settings.SaveDisplay,
LoadAudio: () => d.Settings.Audio, LoadAudio: () => d.Settings.Audio,
SaveAudio: d.Settings.SaveAudio), 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, StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry, Plugins: d.UiRegistry,
Persistence: persistence, 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> /// </summary>
public const uint Options = 10u; 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 = private static readonly (uint PanelId, string WindowName)[] Mounted =
{ {
(CharacterInformation, WindowNames.CharacterInformation), (CharacterInformation, WindowNames.CharacterInformation),
@ -40,6 +54,7 @@ public static class RetailPanelCatalog
(Magic, WindowNames.Spellbook), (Magic, WindowNames.Spellbook),
(Vitae, WindowNames.Vitae), (Vitae, WindowNames.Vitae),
(Options, WindowNames.Options), (Options, WindowNames.Options),
(SocialPanel, WindowNames.SocialPanel),
}; };
private static readonly (uint PanelId, string WindowName)[] Toolbar = private static readonly (uint PanelId, string WindowName)[] Toolbar =

View file

@ -216,6 +216,22 @@ public sealed record OptionsRuntimeBindings(
Func<AudioSettings> LoadAudio, Func<AudioSettings> LoadAudio,
Action<AudioSettings> SaveAudio); 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( public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects, ClientObjectTable Objects,
Func<uint> PlayerGuid, Func<uint> PlayerGuid,
@ -318,6 +334,7 @@ public sealed record RetailUiRuntimeBindings(
ConfirmationRuntimeBindings Confirmations, ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal, AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options, OptionsRuntimeBindings Options,
SocialRuntimeBindings Social,
StackSplitQuantityState StackSplitQuantity, StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins, BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence, RetailUiPersistenceBindings? Persistence,
@ -402,6 +419,7 @@ public sealed class RetailUiRuntime : IDisposable
MountIndicators(); MountIndicators();
MountJumpPowerbar(); MountJumpPowerbar();
MountDialogFactory(); MountDialogFactory();
MountSocialPanel();
MountCharacter(); MountCharacter();
MountPlugins(); MountPlugins();
MountInventory(); MountInventory();
@ -495,6 +513,7 @@ public sealed class RetailUiRuntime : IDisposable
public ExternalContainerController? ExternalContainerController { get; private set; } public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; } public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{ {
@ -537,6 +556,7 @@ public sealed class RetailUiRuntime : IDisposable
JumpPowerbarController?.Tick(); JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds); SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick(); ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick(); _itemCooldownController?.Tick();
DialogFactory?.Tick(); DialogFactory?.Tick();
Host.Tick(deltaSeconds); Host.Tick(deltaSeconds);
@ -559,9 +579,46 @@ public sealed class RetailUiRuntime : IDisposable
OpenSpellbook(SpellbookWindowPage.Components); OpenSpellbook(SpellbookWindowPage.Components);
return true; 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; 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) private void OpenSpellbook(SpellbookWindowPage page)
{ {
bool visible = Host.IsWindowVisible(WindowNames.Spellbook); 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."); 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() private void MountDialogFactory()
{ {
uint layoutId; uint layoutId;

View file

@ -235,4 +235,18 @@ public sealed class UiTemplateListBox : UiDatElement
viewport.AddChild(row); viewport.AddChild(row);
return 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 Vendor = "vendor";
public const string Options = "options"; public const string Options = "options";
public const string KeyboardConfig = "keyboard-config"; 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";
} }