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"; }