diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4604f683..80348f0b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -174,10 +174,10 @@ 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. **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `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` (**34 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. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `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). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. 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 | +| AD-79 | **MOSTLY RETIRED 2026-08-13 (user-ordered social completion batch):** Friends Add/Remove/Appear-Offline and Squelch add-character/add-account/remove are LIVE (the wire beneath had existed end-to-end since J4.1/FA1 — docs/research/2026-08-13-social-wire-completion.md §4; the panel now publishes the same Runtime commands). REMAINING scope: the Friends "Send Tell" button (`0x10000516`) only, which needs the chat-tell seam. **Original filing — 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 | | AD-80 | **Filed 2026-08-12 at Campaign FA slice FA4, D5.** The fellowship page's per-fellow percentage text renders retail's own byte-decoded XP-share table verbatim (1.0/.75/.6/.55/.5/.45/.4/.35/.3111111/.28, default 0.0 — `docs/research/2026-08-11-fa-fellowship-wire.md` §7.2, byte-decoded from the PDB-paired binary because both available decompilers folded the function to a constant). The currently-targeted ACE server computes the ACTUAL distributed XP from a DIFFERENT table (`.3` at 9 fellows instead of `.3111111`, no explicit 10-fellow row, and a wrong out-of-range default of `1.0` instead of `0.0` — `Fellowship.cs:604-632`, lane B §4.3). So a full (9-member) or over-full-in-retail's-table (10-member) fellowship's displayed percentage will not exactly match the XP ACE actually grants. This is a divergence between ACE and RETAIL, not between acdream and retail — acdream's client-side display is retail-faithful — but it is filed here because it is directly user-visible through this panel and a tester comparing "panel says 31.1%" against "server granted 30%" is measuring ACE's bug, not acdream's port. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`EvenSplitPercentTable`, `FormatStatsText`) | The client-side table is byte-verified against the retail binary; re-deriving it to match ACE's (wrong) numbers would make acdream disagree with a REAL retail client observing the same fellowship, which is the opposite of this project's goal. | A tester with a 9- or 10-member fellowship on ACE sees a panel percentage that does not exactly match the XP bonus they actually receive; below 9 members the two agree exactly. The proportional (non-even-split) branch has a SEPARATE, narrower gap: acdream has not ported an `ExperienceToRaiseLevel`-equivalent table, so that branch omits the percentage entirely (level only) rather than computing a wrong number — see AD-81's citation of the same method. | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (lane B §7.2); ACE `Fellowship.cs:604-632`; `docs/research/2026-08-11-fa-fellowship-wire.md` §4.3 | | AD-81 | **Filed 2026-08-12 at Campaign FA slice FA4.** Two retail text-composition primitives the fellowship page's mechanism needs are not ported, so this controller renders their CONTENT as plain numeric composites instead of retail's exact resolved sentence, never invented English: (1) **`StringInfo` variable substitution** — every row field beyond the bare name is a retail `StringInfo` template with embedded variables (`ID_Fellowship_FellowStats` + `ID_Level`/`ID_Experience`; the three `…Status` fields + `ID_Cur`/`ID_Max` — `docs/research/2026-08-11-fa-panel-structure.md` §3.1/§4.1), resolved at runtime through `StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`, a cross-cutting UI-string engine acdream has never ported (the SAME gap the pre-Campaign-OP Character window recorded, `docs/research/2026-06-25-character-window-faithful-spec.md`: "NOT yet ported — current controller uses canonical AC labels"); this controller instead renders `"{level} {pct}%"` and `"{cur}/{max}"` — the retail-authored NUMBERS, without retail's surrounding words. (2) **`ACCharGenData::FormatName`** — retail's Create flow canonicalizes the typed fellowship name and writes the formatted text back into the entry box before sending (lane B §2.2/§6.2); acdream sends the raw typed text verbatim. Neither gap affects the WIRE — the `0x00A2` builder's `str16L` field is unaffected either way; only the client-side PRESENTATION differs. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`UpdateRow`, `FormatStatsText`, `SetVitals`, the create-button `OnClick`) | Porting `StringTableMetaLanguage` is a cross-cutting UI-string-engine prerequisite, not a fellowship-specific task, and guessing its token syntax without decoding `StringInfo::InqString` would risk silently-wrong substitution rather than an honestly-numeric fallback — exactly the guessing CLAUDE.md's workflow forbids. `FormatName`'s capitalization/character rules are a separate chargen algorithm with no fellowship-specific anchor read yet. | A user sees "12 31%" / "140/140" instead of retail's full sentence, and a typed fellowship name keeps whatever casing/spacing the player typed instead of retail's canonicalized form. The underlying DATA (level, percentage, cur/max, the name itself) is correct in every case — only the surrounding words/formatting are absent. | `StringInfo::InqString @0x0042e490` → `StringTableMetaLanguage::UnescapeString` (unresolved — not yet decoded); `gmFellowshipUI::CreateFellowship @0x0048F730` (the `ACCharGenData::FormatName` call, lane B §2.2); `docs/research/2026-06-25-character-window-faithful-spec.md` (the identical prior finding for the Character window) | -| AD-82 | **Filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5).** Three fellowship-panel selection/presentation primitives with no decompiled anchor for the SPECIFIC mechanism, plus a deliberately page-local reimplementation of a retail generic: (1) **the leader-name gold tint** (`SocialFellowshipPageController.LeaderNameColor`, `(1, 0.84, 0, 1)`) — lane A's row-template inventory names no dedicated "this fellow is the leader" element, so this is an invented, clearly-adaptive visual cue, not a ported DAT mechanism. (2) **The panel-local "selected row" tint** (`SelectedNameColor`, `(0.45, 0.85, 1, 1)`) — same disposition, invented for the SAME reason: no decompiled per-row selection marker exists. (3) **Row selection is restricted to the row's name-text click target** — retail's list selection message (`3`/`0x42`, `ListenToElementMessage @0x004901C0`) fires on the WHOLE row; acdream has no generic per-row-element click primitive on an imported template subtree, so only the name text (always present) is clickable — clicking the stats text, a meter, or row whitespace does nothing. (4) **The world→panel selection sync is page-local, not a generic `UiTemplateListBox` primitive** — retail's `gmFellowshipUI::UpdateFellowSelection @0x0048F0F0` keys row identity via `SetAttribute_InstanceID(row, 0x1000000D, fellowIid)` + `UIElement_ListBox::SetSelectedItem`, a mechanism `UiTemplateListBox` does not port (`docs/research/2026-08-11-fa-panel-structure.md` §6.6: "no Flush, no selection model, no per-row instance-id" — `Flush`/`FlushPreservingScroll` shipped at FA3/FA4; the selection half did not). `SocialFellowshipPageController.SyncSelectionFromWorld`/`SetSelectedFellow` reproduce the OBSERVABLE behavior (Dismiss/Leader enable + a row highlight) against this controller's own guid-keyed row dictionary instead. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`LeaderNameColor`, `SelectedNameColor`, `SelectFellow`, `SyncSelectionFromWorld`, `SetSelectedFellow`, `_rows`) | (1)/(2): a minimal, clearly-adaptive visual cue is preferable to inventing a DAT mechanism that was never found — same reasoning the class doc already applied to the leader tint before this row existed. (3): acdream's widget layer has no generic "whole imported subtree is one click target" primitive; the name text is retail's own always-present anchor. (4): the OBSERVABLE contract (button-enable + highlight on world selection) is met without porting the generic `UiTemplateListBox`/`SetAttribute_InstanceID` selection model, which would need a broader ListBox API change touching every ListBox consumer (Options/Config/Chat/Friends/Squelch), not just Fellowship — scoped here as a deliberate, page-local minimum rather than an unscoped widget-layer redesign. | A reviewer comparing a retail screenshot sees two colors retail never paints (gold leader tint, blue selection tint). A user clicking a row's stats text, a meter, or blank row space gets no selection feedback (must click the name specifically). If a future slice (Options/Config/Chat row selection) needs the SAME generic mechanism, this page-local implementation will not serve it — a real `UiTemplateListBox` selection-model port remains owed. | `gmFellowshipUI::UpdateFellowSelection @0x0048F0F0`; `RecvNotice_SelectionChanged @0x0048F1C0`; `ListenToElementMessage @0x004901C0` (message `3`/`0x42`); `docs/research/2026-08-11-fa-panel-structure.md` §6.2/§6.6/§7.3 | **[FA5 addendum, 2026-08-12:** `SocialAllegiancePageController`'s vassal-row click target shares point (3)'s IDENTICAL limitation — only the row's name text (`0x10000268`) is clickable, for the same "no generic per-row click primitive" reason. UNLIKE Fellowship's row click, Allegiance's does NOT sync to the world selection (lane A §6.2: `gmAllegianceUI::ListenToElementMessage`'s list-selection arm reads the row's `0x10000001` into `m_iidSelectedVassal` only — no `ACCWeenieObject::SetSelectedObject` call), so point (4)'s world→panel sync does not apply to Allegiance at all; only points (1)-(3)'s class of limitation recurs, and point (1)/(2)'s invented tint colors are NOT reused. **[FA5 mechanism-review SF-1, 2026-08-12: the interim offline-grey (`OfflineNameColor`) this addendum first cited was ITSELF an invented visual — retail's `UpdateVassalsData @004924c3` writes the vassal name with no colour change; the offline cue is EXCLUSIVELY the authored `0x100004AA` marker (`SetVisible` per online state, already wired). `OfflineNameColor` is removed; the vassal name always renders in the normal white, pinned by `SocialPanelControllerTests.Allegiance_OfflineCue_IsTheMarkerOnly_NameStaysWhite`. The Allegiance page now carries NO invented tint at all.]**]** | +| AD-82 | **NARROWED 2026-08-13 (user-directed):** the invented leader-gold and selection-blue name tints are DELETED — fellow names render white always, selection feedback is the in-game selection ring, and the row-click target widened to the name AND stats texts. Remaining scope below. **Original filing — 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5).** Three fellowship-panel selection/presentation primitives with no decompiled anchor for the SPECIFIC mechanism, plus a deliberately page-local reimplementation of a retail generic: (1) **the leader-name gold tint** (`SocialFellowshipPageController.LeaderNameColor`, `(1, 0.84, 0, 1)`) — lane A's row-template inventory names no dedicated "this fellow is the leader" element, so this is an invented, clearly-adaptive visual cue, not a ported DAT mechanism. (2) **The panel-local "selected row" tint** (`SelectedNameColor`, `(0.45, 0.85, 1, 1)`) — same disposition, invented for the SAME reason: no decompiled per-row selection marker exists. (3) **Row selection is restricted to the row's name-text click target** — retail's list selection message (`3`/`0x42`, `ListenToElementMessage @0x004901C0`) fires on the WHOLE row; acdream has no generic per-row-element click primitive on an imported template subtree, so only the name text (always present) is clickable — clicking the stats text, a meter, or row whitespace does nothing. (4) **The world→panel selection sync is page-local, not a generic `UiTemplateListBox` primitive** — retail's `gmFellowshipUI::UpdateFellowSelection @0x0048F0F0` keys row identity via `SetAttribute_InstanceID(row, 0x1000000D, fellowIid)` + `UIElement_ListBox::SetSelectedItem`, a mechanism `UiTemplateListBox` does not port (`docs/research/2026-08-11-fa-panel-structure.md` §6.6: "no Flush, no selection model, no per-row instance-id" — `Flush`/`FlushPreservingScroll` shipped at FA3/FA4; the selection half did not). `SocialFellowshipPageController.SyncSelectionFromWorld`/`SetSelectedFellow` reproduce the OBSERVABLE behavior (Dismiss/Leader enable + a row highlight) against this controller's own guid-keyed row dictionary instead. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`LeaderNameColor`, `SelectedNameColor`, `SelectFellow`, `SyncSelectionFromWorld`, `SetSelectedFellow`, `_rows`) | (1)/(2): a minimal, clearly-adaptive visual cue is preferable to inventing a DAT mechanism that was never found — same reasoning the class doc already applied to the leader tint before this row existed. (3): acdream's widget layer has no generic "whole imported subtree is one click target" primitive; the name text is retail's own always-present anchor. (4): the OBSERVABLE contract (button-enable + highlight on world selection) is met without porting the generic `UiTemplateListBox`/`SetAttribute_InstanceID` selection model, which would need a broader ListBox API change touching every ListBox consumer (Options/Config/Chat/Friends/Squelch), not just Fellowship — scoped here as a deliberate, page-local minimum rather than an unscoped widget-layer redesign. | A reviewer comparing a retail screenshot sees two colors retail never paints (gold leader tint, blue selection tint). A user clicking a row's stats text, a meter, or blank row space gets no selection feedback (must click the name specifically). If a future slice (Options/Config/Chat row selection) needs the SAME generic mechanism, this page-local implementation will not serve it — a real `UiTemplateListBox` selection-model port remains owed. | `gmFellowshipUI::UpdateFellowSelection @0x0048F0F0`; `RecvNotice_SelectionChanged @0x0048F1C0`; `ListenToElementMessage @0x004901C0` (message `3`/`0x42`); `docs/research/2026-08-11-fa-panel-structure.md` §6.2/§6.6/§7.3 | **[FA5 addendum, 2026-08-12:** `SocialAllegiancePageController`'s vassal-row click target shares point (3)'s IDENTICAL limitation — only the row's name text (`0x10000268`) is clickable, for the same "no generic per-row click primitive" reason. UNLIKE Fellowship's row click, Allegiance's does NOT sync to the world selection (lane A §6.2: `gmAllegianceUI::ListenToElementMessage`'s list-selection arm reads the row's `0x10000001` into `m_iidSelectedVassal` only — no `ACCWeenieObject::SetSelectedObject` call), so point (4)'s world→panel sync does not apply to Allegiance at all; only points (1)-(3)'s class of limitation recurs, and point (1)/(2)'s invented tint colors are NOT reused. **[FA5 mechanism-review SF-1, 2026-08-12: the interim offline-grey (`OfflineNameColor`) this addendum first cited was ITSELF an invented visual — retail's `UpdateVassalsData @004924c3` writes the vassal name with no colour change; the offline cue is EXCLUSIVELY the authored `0x100004AA` marker (`SetVisible` per online state, already wired). `OfflineNameColor` is removed; the vassal name always renders in the normal white, pinned by `SocialPanelControllerTests.Allegiance_OfflineCue_IsTheMarkerOnly_NameStaysWhite`. The Allegiance page now carries NO invented tint at all.]**]** | | AD-83 | **Filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5).** The Recruit button's enable rule does not gate on "target is a player" — retail disables Recruit unless the currently-selected world object IS a player (`ACCWeenieObject::IsPlayer`, `UpdateButtons`, lane B §2.8); acdream's UI layer has no cheap player-vs-non-player classification at this seam, so `RefreshButtonStates` enables Recruit for ANY selected, non-full-fellowship, not-already-a-member target regardless of type. This was previously an inline code comment, not a register row — the wrong call under the register rule (a divergence found without a row is a bug twice over), corrected here. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`RefreshButtonStates`) | acdream's `SelectionState`/world-object model does not carry a player-vs-non-player classification cheaply reachable from the UI layer today; building one for this single enable-rule would be a disproportionate addition for a superset-of-retail rule whose actual SEND is still refused correctly. | A lit, clickable Recruit button when a chest, corpse, or monster is selected instead of a player — clicking it sends a Recruit request the SERVER refuses (the same silent no-op retail's own disabled button would have produced, but reachable in acdream where retail's click handler is unreachable because the button itself is disabled). Not a wire-behavior gap — the recruited/target end state is identical — but a UI-affordance divergence a screenshot comparison would catch. | `gmFellowshipUI::UpdateButtons` (lane B §2.8, the Recruit enable rule); `ACCWeenieObject::IsPlayer` (unlocated exact VA — cited via lane B's UpdateButtons trace) | | AD-84 | **Filed 2026-08-12 at Campaign FA slice FA5.** The Allegiance page's Swear button enable rule does not gate on "target is a player" — retail's `gmAllegianceUI::UpdateSwearButton @0x004908E0` enables Swear only when the current world selection `ACCWeenieObject::IsPlayer()` (lane C §1.3 step 1); acdream's UI layer has the same missing player-vs-non-player classification AD-83 already named for the Fellowship page's Recruit button, so `RefreshButtonStates` enables Swear for any selected, not-already-a-member, not-self target regardless of type. Same root cause and same disposition as AD-83, filed separately because it lives in a different controller/page. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshButtonStates`) | Identical to AD-83's argument: acdream's `SelectionState`/world-object model has no cheap player classification at this UI seam; building one for two single enable-rules (Recruit, Swear) is a disproportionate addition, and the server still refuses a non-player Swear target the same way retail's own disabled button would have silently no-op'd. | A lit, clickable Swear button when a non-player object is selected — clicking it sends a Swear request the SERVER refuses. Not a wire-behavior gap (the swear/target end state is identical to retail's disabled-button no-op) — a UI-affordance divergence a screenshot comparison would catch. | `gmAllegianceUI::UpdateSwearButton @0x004908E0` (lane C §1.3 step 1); `ACCWeenieObject::IsPlayer` (unlocated exact VA, same as AD-83) | | AD-85 | **Filed 2026-08-12 at Campaign FA slice FA5.** The SAME unported `StringInfo` variable-substitution engine AD-81 filed for the Fellowship page's row/stats text (`StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`) also affects the Allegiance page's numeric fields and its three LOCAL confirmation dialogs: (1) **Numeric fields** — self/monarch followers (`0x10000252`/`0x10000258`), self rank (`0x10000253`), and the "experience passed up" text (`0x10000492` ×2, the vassal row's `0x10000269`) render as bare numbers with no surrounding retail words, same disposition as AD-81's `"{level} {pct}%"`. (2) **Swear/Break/Kick confirmation dialogs** — `MakeSwearConfirmationDialog`/`MakeBreakConfirmationDialog`/`MakeKickConfirmationDialog` bind the target's name into a `StringInfo` template (`ID_Allegiance_SwearConfirmation`/`BreakConfirmation`/`KickConfirmation`, lane C §5.1); this controller shows the RESOLVED retail template text VERBATIM (unsubstituted — the raw DAT string, whatever it says) when resolution succeeds, and falls back to the bare target name (also non-invented) when it does not. Neither path ever composes new English. (3) **The server-driven "accept incoming swear" dialog** (`ConfirmationType` 1, handled generically by `GameplayConfirmationController` — no allegiance-specific code) inherits the SAME gap from the wire side: ACE sends the target's bare `Name` as the ENTIRE confirmation message (lane C §6.4: `Player_Allegiance.cs:91`/`ConfirmationManager.cs:38`), not a formatted sentence — retail's own client wraps it via the identical `StringInfo` mechanism this row already covers. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshSelfBlock`, `RefreshMonarchBlock`, `RefreshPatronBlock`, `UpdateRow`, `OnSwearClick`, `OnBreakClick`, `OnKickClick`) | Same argument as AD-81: porting `StringTableMetaLanguage` is a cross-cutting prerequisite, not allegiance-specific, and guessing its token/placeholder syntax to blend a name into an unverified sentence shape would risk silently-wrong substitution — exactly the guessing CLAUDE.md's workflow forbids. Using retail's own unsubstituted text verbatim (or the bare name) is the non-inventing alternative. | A user sees bare numbers instead of retail's full sentences for followers/rank/XP-passed-up, and the three local confirmation dialogs (plus the server-driven accept-swear dialog) show either an unsubstituted retail template or a bare name instead of "Swear allegiance to PlayerName?"-style prose. The underlying DATA (the numbers, the target's real name) is correct in every case — only the surrounding words are absent. | `gmAllegianceUI::UpdatePlayerData @0x00491330`, `UpdateMonarchData @0x00491B40`, `UpdatePatronData @0x004917C0`, `UpdateVassalsData @0x00492340` (lane C/A field sources); `MakeSwearConfirmationDialog @0x004927B0` family (lane A §5.1); `StringInfo::InqString @0x0042e490` (unresolved, same citation as AD-81) | diff --git a/docs/research/2026-08-13-social-wire-completion.md b/docs/research/2026-08-13-social-wire-completion.md new file mode 100644 index 00000000..cd7151cb --- /dev/null +++ b/docs/research/2026-08-13-social-wire-completion.md @@ -0,0 +1,452 @@ +# Social-panel completion batch — exact wire mechanics (2026-08-13) + +Read-only research for the user-ordered social-panel completion features: +friends add/remove, appear-offline, squelch add, fellowship option +operations, the three fellowship/allegiance character-option bits, and +world self-selection. Every claim below is cited to primary source; the +usual three-way agreement rule (retail decomp + ACE + a third reference) +is applied per message. `str16L` = `[u16 byteLen][Windows-1252 bytes][pad +to 4-byte multiple counting the 2 length bytes]` throughout (see +`2026-08-11-fa-fellowship-wire.md` §3 for the envelope conventions — +C→S GameAction body = `[u32 0xF7B1][u32 seq][u32 subOpcode][payload]`, +S→C GameEvent body = `[u32 0xF7B0][u32 targetGuid][u32 eventSeq][u32 +eventType][payload]`; both already shipped in acdream). + +**Headline finding:** the entire C→S sender layer, the inbound S→C +parser/state layer, AND the generation-gated Runtime command surface for +features 1–3 **already exist in acdream** (built for the CH command +registry and Campaign FA). The implementation gap is almost purely +*panel wiring*: the Friends/Squelch pages are deliberately read-only +(FA slice FA3 scope) and no UI affordance drives the existing commands. +Feature 4 (self-selection) is the one place acdream *diverges* from +retail behavior today. + +--- + +## 1. Add friend / remove friend / appear offline / squelch add + +### 1.1 AddFriend — C→S `0x0018` + +| | | +|---|---| +| Body | `[u32 0x0018][str16L characterName]` | +| Retail sender | `CM_Social::Event_AddFriend @0x006A5C10` (buffer `strPackSize + 0xC`; opcode u32, then `PStringBase::Pack`) | +| Retail UI caller | `gmFriendsUI::Request_AddFriend @0x0048D240` — Add button (`idElement 0x10000514`) reads the name edit box in `gmFriendsUI::ListenToElementMessage @0x0048D520`, clears it, dims the Add button (`SetState(0xD)`). Chat-command route: `gmFriendsUI::RecvNotice_ChatCommand_AddFriend @0x0048D470` | +| ACE handler | `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionAddFriend.cs:7-12` (`GameActionType.AddFriend = 0x0018`, `GameActionType.cs:12`) → `Player.HandleActionAddFriend`, `ACE.Server/WorldObjects/Player_Character.cs:141-172` | +| ACE validation | rejects self-add ("can't be friends with yourself"), unknown name ("That character does not exist"), duplicate ("already in your friends list") — each as a `ChatPacket.SendServerMessage` broadcast text, NOT a WeenieError | +| ACE success flow | DB add → **incremental** `GameEventFriendsListUpdate` with `FriendsUpdateTypeFlag.FriendAdded (0x0001)` (`Player_Character.cs:169`) + a "`{name} has been added to your friends list.`" text | +| Cross-check | Chorizite `Messages/C2S/Actions/Social_AddFriend.generated.cs:24-26` (str16L only). holtburger: opcode known but **commented out / no sender** (`crates/holtburger-protocol/src/opcodes.rs:427`) | +| acdream builder | `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs:132-133` (`BuildAddFriend`, retail address cited in-file) | + +### 1.2 RemoveFriend — C→S `0x0017`, RemoveAllFriends — C→S `0x0025` + +| | | +|---|---| +| RemoveFriend body | `[u32 0x0017][u32 friendGuid]` — **guid, not name.** The retail Remove button resolves the selected list row's InstanceID attribute (`GetAttribute_InstanceID(…, 0x10000085, …)` at `@0x0048D67D`) and sends `CM_Social::Event_RemoveFriend @0x006A5650` (buffer 0x10). The `/friends remove ` chat route (`RecvNotice_ChatCommand_RemoveFriend @0x0048DAC0`) resolves name→guid client-side first (`@0x0048dbde`). | +| RemoveAllFriends body | `[u32 0x0025]` — parameterless. `CM_Social::Event_ClearFriends @0x006A55C0` (buffer 0xC), chat route `RecvNotice_ChatCommand_RemoveAllFriends @0x0048E020`. | +| ACE handlers | `GameActionRemoveFriend.cs:6-11` (`ReadUInt32` guid) → `HandleActionRemoveFriend` (`Player_Character.cs:178-198`): not-found → text error; success → incremental `0x0021` with `FriendRemoved (0x0002)` + text. `GameActionRemoveAllFriends.cs` → `HandleActionRemoveAllFriends` (`Player_Character.cs:203-211`): DB clear, **NO S→C list update at all** (the client is expected to clear its own list — retail does, in the `/friends removeall` handler). | +| Cross-check | Chorizite `Social_RemoveFriend.generated.cs:18-25` (`ObjectId` u32). | +| acdream builders | `ClientCommandRequests.cs:135-139` (`BuildRemoveFriend`, `BuildClearFriends`). | + +### 1.3 S→C FriendsListUpdate — `0x0021` (verified; it IS the 0x0021 family) + +`GameEventType.FriendsListUpdate = 0x0021` +(`ACE.Server/Network/GameEvent/GameEventType.cs:9`; Chorizite +`Social_FriendsUpdate.generated.cs`). Writer: +`GameEventFriendsListUpdate.cs:56-99`: + +``` +u32 count +count × FriendData: + u32 friendId + u32 online (0/1; forced 0 when the friend has AppearOffline set — :74-79) + u32 appearOffline (ACE always writes 0 — :84) + str16L friendName + u32 numFriendsOfFriend (ACE: always 0 / TODO) [u32 ids…] + u32 numFriendOf (ACE: always 0 / TODO) [u32 ids…] +u32 updateType — LAST field, after the list: + 0=FullList 1=FriendAdded 2=FriendRemoved 4=FriendStatusChanged +``` + +Retail parser: `CM_Social::DispatchUI_FriendsUpdate @0x006A5DD0` → +`FriendData::UnPack @0x005B9D20`; UI application: +`gmFriendsUI::ServerSays_AddFriend @0x0048DEF0` / +`ServerSays_RemoveFriend @0x0048DFB0` / `ServerSays_UpdateFriend +@0x0048DCD0`. The full list arrives unprompted at login +(`Player_Networking.cs:117` sends it with the login bundle). + +**acdream already parses and owns this**: +`src/AcDream.Core.Net/Messages/SocialStateMessages.cs:13-43` +(`ParseFriendsUpdate`, retail addresses cited in-file) → registered at +`GameEventWiring.cs:341-346` → `AcDream.Core/Social/FriendsState.cs` +(`Apply` handles Full/Add/Remove/OnlineStatus), owned by J4.1's +`RuntimeCommunicationState`. Nothing to build inbound. + +### 1.4 Appear offline — a character option, NOT a dedicated GameAction + +There is **no** standalone appear-offline opcode. `AppearOffline` is +`PlayerOption 0x27`, stored in `options2_` bit 12 (mask `0x00001000` — +retail `PlayerModule::GetOption` switch `@0x005D3D56-67`; ACE +`CharacterOptions2.cs:25`). It is in retail's 21-entry auto-save table +(`CPlayerModule::IsAutoSaveOption @0x0059A600`, byte-verified list in +`2026-08-10-character-options-map.md` §1.3 — id 0x27 IS auto-save), so +setting it sends **`SetSingleCharacterOption (0x0005)` +`[u32 0x27][u32 value]` immediately** via +`CM_Character::Event_PlayerOptionChangedEvent @0x006A1510`, dispatched +from `CPlayerModule::OnChanged @0x0059A8E0` (`@0x0059a99d-9b6`). + +ACE side (`GameActionSetSingleCharacterOption.cs:16-18`): the 0x0005 +handler special-cases `AppearOffline` → `Player.SetAppearOffline` +(`Player_Character.cs:221-226`), which flips the option and calls +`SendFriendStatusUpdates` (`Player_Networking.cs:274-294`): every online +player who has YOU friended receives an incremental **`0x0021` with +`FriendStatusChanged (0x0004)` and the forced online flag** plus a +"`{Name} has gone offline.`" text — i.e., other players genuinely see +you go offline. Login/logout honor it too (`PlayerManager.cs:430,464`), +and the full-list writer masks you out of other players' lists +(`GameEventFriendsListUpdate.cs:76-79`). ACE deliberately does **not** +persist it (`Player_Character.cs:219` comment). + +**Open retail question (carried, not new):** the 2013 client has NO UI +row and no `SetAppearOffline` caller outside the generic `SetOption` +switch — how retail's own client ever set 0x27 is **UNKNOWN** (options +map §3 row `0x27` + §8 item U4). Any acdream checkbox for it is an +acdream-authored affordance driving a fully retail wire path. +`HeadlessConfigurationLoader.cs:45` already declares it by name for bots. + +### 1.5 Squelch add/remove (by name) — C→S `0x0058` / `0x0059` / `0x005B` + +| Opcode | Body | Retail sender | +|---|---|---| +| `0x0058 ModifyCharacterSquelch` | `[u32 add(0/1)][u32 characterGuid][str16L characterName][u32 chatMessageType]` | `CM_Communication::Event_ModifyCharacterSquelch @0x006A42D0` (buffer `strPackSize + 0x18`; field order byte-read from the body: opcode, arg1=add, arg2=guid, Pack(name), arg4=type) | +| `0x0059 ModifyAccountSquelch` | `[u32 add][str16L characterName]` | `Event_ModifyAccountSquelch @0x006A41E0` | +| `0x005B ModifyGlobalSquelch` | `[u32 add][u32 chatMessageType]` | `Event_ModifyGlobalSquelch @0x006A3D00` | + +**Add-by-name is native**: retail's squelch panel sends `guid = 0` with +the typed name (`gmSquelchUI::ListenToElementMessage @0x0048C860`, add +call `@0x0048CA0D`: `Event_ModifyCharacterSquelch(1, 0, &name, 1)` — +`chatMessageType 1 = AllChannels`, ACE `ChatMessageType.cs:47`). The +account-checkbox variant sends `0x0059` instead (`@0x0048C99C`). The +chat-window speaker toggle goes through +`gmMainChatUI::ToggleSquelchOnCurrentSpeakableTarget @0x004CD230` +(`@0x004CD2F6`). + +ACE (`GameActionModifyCharacterSquelch.cs:12-17` → +`SquelchManager.HandleActionModifyCharacterSquelch`, +`ACE.Server/WorldObjects/Managers/SquelchManager.cs:74-124`): +- channel legality check (`IsLegalChannel`, :78) → text error if bad; +- **guid≠0 looks up by guid; guid==0 falls back to name lookup** + (:86-107) — so name-only add works; +- self-squelch rejected (:109-113); +- on success `UpdateSquelchDB()` + `SendSquelchDB()` (:121-123) — the + S→C answer is always a **full `0x01F4 SetSquelchDB` replacement**, + never an incremental. Account (`:195-250`) and global (`:255-279`) + do the same. + +S→C `0x01F4 SetSquelchDB` (`GameEventType.cs:51`; writer +`GameEventCommunicationSetSquelch.cs` + `Network/Structure/SquelchDB.cs:158-165` ++ `SquelchInfo.cs:50-65`): + +``` +PackableHashTable accounts — ALWAYS EMPTY in retail pcaps (ACE writes header 0/0) +PackableHashTable characters — account squelches folded in with Account=true +SquelchInfo globals +SquelchInfo = [u32 filterWordCount][u32 mask × count][str16L playerName][u32 isAccount] +``` + +ACE writes each character's filter list as the same mask **4×** +(`SquelchInfo.cs:21-27` — "if not sent 4x, the checkbox in the chat menu +doesn't toggle"; why retail wants 4 words is an ACE-acknowledged +UNKNOWN). Chorizite `Communication_SetSquelchDB.generated.cs` agrees on +the envelope. acdream already parses it — +`SocialStateMessages.ParseSquelchDatabase` (`:45-61`, retail +`SquelchDB::UnPack @0x006B1900` cited) → `GameEventWiring.cs:350-355` → +`AcDream.Core/Social/SquelchState.cs` (full `Replace` semantics, matching +the wire). + +### 1.6 acdream seam — feature 1 is wiring-only + +Already shipped (verify, don't rebuild): +- Builders: `ClientCommandRequests.cs:132-188` (all six, retail + addresses cited). +- Session sends: `WorldSession.cs:2235-2274` (`SendAddFriend`, + `SendRemoveFriend`, `SendClearFriends`, `SendModifyCharacterSquelch`, + `SendModifyAccountSquelch`, `SendModifyGlobalSquelch`) and `:2202` + (`SendSetSingleCharacterOption` for appear-offline). +- Runtime commands (generation-gated, host-shared): + `src/AcDream.Runtime/GameRuntimeCommands.cs:264-300` + (`RuntimeFriendCommand` Add/Remove/Clear/RequestLegacyList, + `RuntimeSquelchCommand` Character/Account/Global scopes, + `IRuntimeSocialCommands`), executed at + `DirectGameRuntimeCommandAdapter.cs:722-800`; graphical route bound at + `LiveSessionRuntimeFactory.cs:520-525/595-599`. +- Option path: `RuntimeCharacterOptionsState.TrySetOption` + (`RuntimeCharacterState.cs:806-838`) auto-save dispatch → + `SendSetSingleCharacterOption` (adapter `:677`). + +To build: Friends-page Add (edit box + button → `RuntimeFriendCommand +Add` with the typed name), Remove (selected row's guid → `Remove`), +appear-offline checkbox (option id `0x27` through the existing option +seam — note U4: no retail UI row to copy), Squelch-page add-by-name +(`RuntimeSquelchCommand` Character scope, guid 0, type `1 = AllChannels`, +account checkbox → Account scope). The pages +(`SocialFriendsPageController.cs`, `SocialSquelchPageController.cs`) are +currently read-only by FA-D1 design. + +--- + +## 2. Fellowship option operations + +Full derivation in `2026-08-11-fa-fellowship-wire.md` (lane B); this +section resolves the specific "change after creation" questions. + +### 2.1 What CAN change after creation — openness only: `0x0291` + +`FellowshipChangeOpenness` `[u32 isOpen]` — +`CM_Fellowship::Event_ChangeFellowOpeness @0x006A6040`, Open button +case 9 in `gmFellowshipUI` (client pre-toggles its local `_open_fellow` +optimistically `@0x0049038A`). ACE: +`GameActionFellowshipChangeOpenness.cs:7-12` → +`Player_Fellowship.cs:33-48` — **leader-only** +(`WeenieError.YouMustBeLeaderOfFellowship 0x050F`-family refusal) and +refused when locked (`WeenieError.FellowshipIsLocked`). Success → +`Fellowship.UpdateOpenness` (`Fellowship.cs:410-415`) → +`SendWeenieErrorWithStringAndUpdate(_IsNowOpenFellowship / +_IsNowClosedFellowship, name)` — i.e. every member gets the +WeenieErrorWithString **plus a full `0x02BE +FellowshipFullUpdate`**. acdream: builder +`SocialActions.BuildFellowshipChangeOpenness` (`SocialActions.cs:223-231`), +Runtime command `IRuntimeFellowshipCommands.SetOpen` +(`GameRuntimeCommands.cs:339-341`) — **both already shipped (FA1/FA2)**. + +### 2.2 What CANNOT change after creation — ShareXP and ShareLoot + +- **ShareXP** (retail option `FellowshipShareXP 0x0F`, named + `ShareFellowshipExpAndLuminance` in ACE — luminance rides the same + flag) is a **create-time-only wire field**: `0x00A2 FellowshipCreate` + = `[str16L name][u32 shareXP]` (retail `Event_Create @0x006A67A0` + reads the option at create; ACE `Fellowship.cs:51-54` latches + `DesiredShareXP`/`ShareXP` in the constructor). There is **no + GameAction to change it later** — the seven C→S fellowship opcodes are + `0x00A2–0x00A6`, `0x0290`, `0x0291`, period (lane B §3; ACE's + `GameActionType.cs` has nothing else fellowship-shaped). Toggling the + 0x0F option after creation changes only what your NEXT create sends. + Level-spread proportional-vs-even demotion is server-side + (`Fellowship.CalculateXPSharing`), reflected to clients as + `_share_xp = 0` inside `0x02BE`. +- **ShareLoot** is not on the create message at all: ACE reads the + LEADER's `ShareFellowshipLoot (0x11)` character option **once, in the + Fellowship constructor** (`Fellowship.cs:56-57`), enforces it at + `Corpse.cs:192-198`, and echoes it per-fellow as the `shareLoot` u32 + in the `Fellow` record. No post-create wire op exists in either ACE + or the retail client (lane B master-table row 31). The only lever is + flipping option 0x11 (auto-save → immediate `0x0005`) **before** + creating. + +### 2.3 Panel visibility — `0x00A6 FellowshipUpdateRequest` (not an option, but required) + +`[u32 panelOpen]` from `gmFellowshipUI::OnVisibilityChanged @0x0048E460`. +ACE `GameActionFellowshipUpdateRequest.cs:8-14` → +`HandleFellowshipUpdateRequest` (`Player_Fellowship.cs:142-148`): sets +`FellowshipPanelOpen`, replies with a full `0x02BE` when open, and the +flag **gates the whole `0x02C0` member-vitals stream** +(`Fellowship.cs:723`). Already shipped: +`BuildFellowshipUpdateRequest` (`SocialActions.cs:190-198`) and +`IRuntimeFellowshipCommands.SetPanelOpen` — the fellowship page's +show/hide seam must keep calling it. + +### 2.4 Auto-accept — server-side, driven by the option bits (see §3) + +`Fellowship.AddFellowshipMember` (`Fellowship.cs:76-132`): inviter-side +recruit → busy check → **`AutomaticallyAcceptFellowshipRequests` on the +TARGET short-circuits the confirmation dialog** +(`Fellowship.cs:120-123` → `AddConfirmedMember` directly); otherwise a +`Confirmation_Fellowship` popup is enqueued. The +`IgnoreFellowshipRequests` filter fires earlier, on the inviter's +`FellowshipRecruit` (`Player_Fellowship.cs:98-102` — +`WeenieError.FellowshipIgnoringRequests` + "not accepting fellowship +requests" text). Neither bit generates any wire traffic of its own at +recruit time — they are read from the server's stored option words. + +--- + +## 3. The three character-option bits — values, wire class, ACE reads + +| Option | `PlayerOption` id (0x0005 payload) | Word | Mask | Retail accessor | Auto-save? | Client default | +|---|---|---|---|---|---|---| +| `IgnoreAllegianceRequests` | `0x01` | options1 | `0x00000004` | (options map §3) | **yes** | false | +| `IgnoreFellowshipRequests` | `0x02` | options1 | `0x00000008` | `@0x005D2B30` / `@0x005D2B40` | **yes** | **true** | +| `FellowshipAutoAcceptRequests` (ACE: `AutomaticallyAcceptFellowshipRequests`) | `0x12` | options1 | `0x20000000` | `@0x005D3020` / `@0x005D3030` | **yes** | false | +| (context) `AppearOffline` | `0x27` | options2 | `0x00001000` | `@0x005D3740` | **yes** | false | + +Sources: retail bit map `PlayerModule::GetOption @0x005D3AA0` switch; +ACE `CharacterOptions1.cs:16-17`, `CharacterOption.cs:18-22,70`, +`CharacterOptions2.cs:25`; auto-save membership from the byte-verified +`IsAutoSaveOption @0x0059A600` table +(`2026-08-10-character-options-map.md` §1.3 — all four ids are in the +21-entry auto-save set). + +**Wire class:** all three (plus AppearOffline) ride +**`0x0005 SetSingleCharacterOption` immediately** — +`[u32 optionId][u32 value]` — and never *trigger* the batched `0x01A1` +PlayerModule blob. (Their bits still appear inside the blob's +options1/options2 words whenever some *other* dirty option flushes it — +that is correct and harmless; ACE stores the whole word.) + +**Mutual exclusion** (`CPlayerModule::OnChanged @0x0059A8E0`, case 2 / +case 0x12, decomp lines `@0x0059a971-98d`): setting +`IgnoreFellowshipRequests` clears `FellowshipAutoAcceptRequests` and +vice versa, each clear going through the real accessor so the CLEARED +option's own `0x0005` is emitted first. acdream models this exactly — +`RuntimeCharacterState.cs:821-834` (MF-2), and +`HeadlessConfigurationLoader.cs:254-266` rejects both-true bot configs. + +**acdream option table** (`src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs`): +- `:112` `IgnoreAllegianceRequests` — options1, `0x00000004`, autoSave, default false +- `:113` `IgnoreFellowshipRequests` — options1, `0x00000008`, autoSave, **default true** +- `:129` `FellowshipAutoAcceptRequests` — options1, `0x20000000`, autoSave, default false +- `:150` `AppearOffline` — options2, `0x00001000`, autoSave, default false + +Id enum: `CharacterOptionId` (`SocialActions.cs:425-480`). Send seam: +`RuntimeCharacterOptionsState.TrySetOption` (`RuntimeCharacterState.cs:806-838`, +`entry.IsAutoSave → sendAutoSave(id, value)`) wired to +`WorldSession.SendSetSingleCharacterOption` (`WorldSession.cs:2202`) at +`DirectGameRuntimeCommandAdapter.cs:677`. Headless bots declare all of +them by name (`HeadlessConfigurationLoader.cs:30-45`). + +**ACE READS each bit server-side (the D6 finding, confirmed with code):** +- `IgnoreFellowshipRequests` — + `ACE.Server/WorldObjects/Player_Fellowship.cs:98` + (`FellowshipRecruit`: `newPlayer.GetCharacterOption(...)` → inviter + gets `WeenieError.FellowshipIgnoringRequests`). Pure server-side + filter; the target's client never sees the attempt. +- `AutomaticallyAcceptFellowshipRequests` — + `ACE.Server/Entity/Fellowship.cs:120` + (`AddFellowshipMember`: bypasses the `Confirmation_Fellowship` popup, + joins immediately). Also settable via ACE's own `/option` player + command alias `AutoAcceptFellowRequest` (`PlayerCommands.cs:221`). +- `IgnoreAllegianceRequests` — + `ACE.Server/WorldObjects/Player_Allegiance.cs:317` + (`IsPledgable`, checked during SwearAllegiance: swearer gets + `WeenieError.YourOfferOfAllegianceWasIgnored` + "Your offer of + allegiance was ignored." text). +- `AppearOffline` — `Player_Character.cs:213-226`, + `GameEventFriendsListUpdate.cs:76-79`, `PlayerManager.cs:430,464` + (see §1.4). + +The client-side consumer status in the FA fix round stands: these three +are **store-only** on the acdream client +(`CharacterOptionsPageController.cs:244-249` — D6 correction comments); +the server does all the filtering. + +--- + +## 4. World self-selection — retail allows it; acdream blocks it + +### 4.1 Retail: no self-exclusion anywhere in the selection pipeline + +The retail pick is render-driven: `SmartBox::find_object @0x00451C60` +arms `Render::set_selection_cursor` + `lookingForObject = 1`; during the +next frame every drawn part runs the ray test; `SmartBox::DrawNoBlit +@0x00454C20` (`@0x00454C4E-70`) reads the raw winner +(`Render::GetMouseSelectionObjectID`) and publishes it via +`ECM_UI::SendNotice_SmartBoxObjectFound`. The eligibility chain: + +1. `CPhysicsPart::Draw @0x0050D7A0` (`@0x0050D823-35`): sets + `Render::check_curr_object = 1` for **any part whose `physobj->id` + is nonzero** (or `creature_mode`). No player-id comparison exists. +2. `Render::GfxObjUnderSelectionRay @0x0054C740`: sphere test + (`CSphere::sphere_intersects_ray`) then per-polygon + (`CPolygon::polygon_hits_ray`), keeping the closest winner in + `Render::m_MouseSelectData`. No identity filtering. +3. `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound + @0x004E5AD0`: for the ordinary reasons — + `sr_Select` (left click, set `@0x004E5805`) and `sr_Examine` + (right click, `@0x004E58B8`) — it pulses lighting and calls + `ACCWeenieObject::SetSelectedObject(foundId, 0)` **unconditionally** + (`@0x004E5BAE`); `sr_Examine` additionally calls + `ClientUISystem::ExamineObject` with the raw found id (`@0x004E5C0A`). +4. `ACCWeenieObject::SetSelectedObject @0x0058C2E0`: no filter of any + kind — it swaps `selectedID`, fires `SendNotice_SelectionChanged`, + and notifies plugins. + +**The only self-related gate in the whole flow** is the double-click-use +branch (search reason 4): `if (found->pwd._wielderID != +SmartBox::player_id)` before `ItemHolder::UseObject` (`@0x004E5BE9`) — +clicking your own **wielded item** selects/flashes but does not Use. +acdream already ports that exact gate +(`SelectionInteractionController.cs:169-180` comment + `IsWieldedByPlayer`). + +Conclusion: **retail lets you left-click-select and right-click-examine +your OWN character** whenever your model is drawn (third person). In +first person the model simply isn't rendered, so it can't win the ray — +an emergent property, not a filter. + +### 4.2 acdream: the exclusion is ours, and it is deliberate but retail-divergent + +- Skip mechanism: `src/AcDream.Core/Selection/RetailWorldPicker.cs:29` + — `if (part.ServerGuid == 0u || part.ServerGuid == skipServerGuid) + continue;` +- Fed from `src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs:220-233` + (`Pick(..., uint skipServerGuid)`) ← + `src/AcDream.App/Interaction/WorldSelectionQuery.cs:145-154` + (`PickAt(..., bool includeSelf)` passes + `includeSelf ? 0u : _playerGuid()`). +- Callers (`src/AcDream.App/Interaction/SelectionInteractionController.cs`): + - `:151` `PickAndStoreSelection` — ordinary left click passes + `_items.IsAnyTargetModeActive`, i.e. **self is excluded except in + spell/item target mode**; + - `:199` `PickSelectAndExamine` — right-click examine passes + `includeSelf: false` — **self-examine impossible**; + - `:101` `PlaceDraggedItem` and + `InteractionRetainedUiComposition.cs:497` pass `true`. + +To match retail, the select (`:151`) and examine (`:199`) paths should +pass `includeSelf: true` (target-mode already does). If the exclusion is +retained instead, it needs a +`retail-divergence-register.md` row — none exists today (the register +has no row for self-picking; this is an unregistered deviation). +Downstream note for the implementer: `WorldSelectionQuery.PickAt` +verifies the winner through +`LiveEntityRuntime.TryGetPickEligibleRecord`; confirm the local player's +record passes that eligibility check before assuming the one-line flag +flip is sufficient. + +--- + +## 5. UNKNOWNs (explicit) + +1. **U4 (carried):** how retail's 2013 client sets `AppearOffline 0x27` + — no UI row, no non-generic setter call site + (`2026-08-10-character-options-map.md` §3 row 0x27, §8 U4). Any + acdream checkbox is a new affordance on a retail wire path. +2. **SquelchInfo 4× filter list:** why retail wants the mask repeated + four times (`SquelchInfo.cs:21-27` — ACE's own "not sure"). Parse + side already tolerant (acdream reads ≤4 words). +3. **Account-squelch key semantics:** ACE is unsure whether the (always + empty) top-level accounts table keyed on account name or character + name (`SquelchDB.cs:20-28`). Moot for a client — never populated. +4. **Per-channel character squelch:** no retail pcaps exist + (`SquelchInfo.cs:24-25`); ACE accepts a `chatMessageType` mask but + the panel only ever sends `AllChannels (1)`. +5. **`RemoveAllFriends` S→C silence:** ACE sends no `0x0021` after + `0x0025`; retail clears its own list. acdream's `FriendsState` has no + "clear on 0x0025 ack" hook — the panel action should clear the local + state the way retail's chat handler does, or simply re-request. + (Behavior choice for the implementer, flagged, not researched deeper.) +6. **Self-pick eligibility depth (§4.2):** whether + `TryGetPickEligibleRecord` admits the local player's record today was + not verified — check before shipping the includeSelf flip. + +## 6. Ten-line summary + +1. AddFriend `0x0018 [str16L name]`, RemoveFriend `0x0017 [u32 guid]`, RemoveAllFriends `0x0025` []; ACE answers with incremental `0x0021 FriendsListUpdate` (updateType LAST: 0/1/2/4) — acdream builders, WorldSession sends, `RuntimeFriendCommand`, and the `0x0021` parser ALL exist; only panel wiring is missing. +2. Appear-offline is CharacterOption `0x27` (options2 `0x1000`), auto-save → immediate `0x0005`; ACE's handler routes it to `SetAppearOffline`, which pushes `0x0021 FriendStatusChanged` to everyone who friended you. No retail UI row exists (U4) — our checkbox is a new affordance on a retail wire path. +3. Squelch add-by-name is native: `0x0058 [u32 add][u32 guid=0][str16L name][u32 type=1 AllChannels]`; ACE falls back to name lookup and always answers with a FULL `0x01F4 SetSquelchDB` replace — sender, Runtime command, and parser all shipped. +4. Fellowship post-create ops: openness is the ONLY one — `0x0291 [u32 isOpen]`, leader-only, refused when locked, answered by WeenieErrorWithString + full `0x02BE`. Builder + `IRuntimeFellowshipCommands.SetOpen` already exist (FA1/FA2). +5. ShareXP is a create-time field of `0x00A2` and ShareLoot is the leader's option `0x11` read once in ACE's Fellowship constructor — NO post-create wire op exists for either; the panel must dim them for an existing fellowship. +6. `0x00A6` is panel VISIBILITY (gates ACE's `0x02C0` vitals stream), not openness — keep `SetPanelOpen` wired to page show/hide. +7. The three bits: IgnoreAllegianceRequests `0x01`/options1 `0x04`, IgnoreFellowshipRequests `0x02`/options1 `0x08` (default TRUE), FellowshipAutoAcceptRequests `0x12`/options1 `0x20000000`; all auto-save → `0x0005` immediate, never triggering `0x01A1`; the 0x02↔0x12 mutual exclusion emits the cleared option's `0x0005` first (already modeled, MF-2). +8. ACE reads all three server-side: recruit filter `Player_Fellowship.cs:98`, auto-accept `Fellowship.cs:120`, swear filter `Player_Allegiance.cs:317` — client stays store-only (D6 stands). +9. Retail has NO self-selection exclusion: any drawn physobj is ray-eligible (`CPhysicsPart::Draw @0x0050D823`), and found ids are selected/examined unconditionally (`@0x004E5BAE/0x004E5C0A`); the only self gate is the wielded-item double-click Use check (`@0x004E5BE9`, already ported). +10. acdream's self-exclusion is an unregistered divergence: `RetailWorldPicker.cs:29` skip fed by `SelectionInteractionController.cs:151/:199` passing includeSelf=false — flip to true (after checking pick-eligibility admits the local player) or add a register row. diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 0cd6e04e..930bac2b 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -148,7 +148,14 @@ internal sealed class SelectionInteractionController public void PickAndStoreSelection(bool useImmediately) { - uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive); + // 2026-08-13 gate fix ("when I click on my own char it should select + // myself"): retail has NO self-exclusion anywhere in this chain — + // CPhysicsPart::Draw @0x0050D823 arms every physobj for the pick and + // RecvNotice_SmartBoxObjectFound @0x004E5BAE selects unconditionally + // (docs/research/2026-08-13-social-wire-completion.md §9/§10). The + // old includeSelf gate (target-mode-only) was an unregistered + // divergence, now removed. + uint? picked = _query.PickAtCursor(includeSelf: true); if (picked is not uint guid) { if (!_items.IsAnyTargetModeActive) @@ -196,7 +203,9 @@ internal sealed class SelectionInteractionController /// public void PickSelectAndExamine() { - uint? picked = _query.PickAtCursor(includeSelf: false); + // Same self-inclusion as PickAndStoreSelection above — retail + // right-click examines yourself too. + uint? picked = _query.PickAtCursor(includeSelf: true); if (picked is not uint guid) return; diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 008cef17..ec053cee 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -702,7 +702,19 @@ public static class DatWidgetFactory t.OutlineColor = info.OutlineColor.Value; if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored) - t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)]; + { + // 2026-08-13 social gate: authored strings can carry embedded + // newlines (the fellowship empty-state is three sentences over + // '\n's). A single Line renders them as one clipped run — split + // into one Line per authored line, exactly as retail's multiline + // UIElement_Text draws them. The provider re-reads DefaultColor + // per call (NOT captured eagerly) so state-driven font-color + // changes keep tracking, the same live-color contract the + // single-line provider always had. + string[] parts = [.. authored.Split('\n').Select(static p => p.TrimEnd('\r'))]; + t.LinesProvider = () => + [.. parts.Select(p => new UiText.Line(p, t.DefaultColor))]; + } return t; } diff --git a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs index 49c21c18..22fd2d99 100644 --- a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs +++ b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs @@ -421,10 +421,16 @@ public sealed class SocialAllegiancePageController if (tooltip is not null) _ignoreRequestsCheckbox.TooltipText = tooltip; + // 2026-08-13 gate fix ("can't press Ignore Allegiance requests — + // always checked"): identical double-toggle to the fellowship page's + // checkboxes — the authored ToggleBehavior self-flip made the old + // `!Selected` read the flipped value and write the ORIGINAL back. + // Same CH6a/b mirror discipline: suppress the self-flip, derive the + // next value from the STORE, per-tick seeding mirrors the outcome. + _ignoreRequestsCheckbox.SuppressSelfToggle = true; _ignoreRequestsCheckbox.OnClick = () => { - bool next = !_ignoreRequestsCheckbox.Selected; - _ignoreRequestsCheckbox.Selected = next; + bool next = !_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests); _bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next); }; } @@ -518,8 +524,12 @@ public sealed class SocialAllegiancePageController private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot) { SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor); - SetLine(_selfFollowers, ref _lastSelfFollowers, snapshot.TotalVassals.ToString(), TextColor); - SetLine(_selfRank, ref _lastSelfRank, snapshot.Rank.ToString(), TextColor); + // 2026-08-13 gate ("On top it just says 0 then 0"): the bare numbers + // gain their labels in the user-specified format — the full retail + // StringInfo composition for these fields remains AD-85's gap; this + // is its user-directed partial fill. + SetLine(_selfFollowers, ref _lastSelfFollowers, $"Followers: {snapshot.TotalVassals}", TextColor); + SetLine(_selfRank, ref _lastSelfRank, $"Rank: [{snapshot.Rank}]", TextColor); } // gmAllegianceUI::UpdateMonarchData @0x00491B40 — see class doc. @@ -541,10 +551,11 @@ public sealed class SocialAllegiancePageController RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value; SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor); + // 2026-08-13 gate: same label treatment as the self block's followers. SetLine( _monarchFollowers, ref _lastMonarchFollowers, - (snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u).ToString(), + $"Followers: {(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u)}", TextColor); _monarchField.Enabled = monarchData.IsLoggedIn; diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs index f91aba45..14fb4ea7 100644 --- a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs @@ -58,11 +58,11 @@ namespace AcDream.App.UI.Layout; /// Leader marker. Lane A's row-template inventory names no dedicated /// "this fellow is the leader" element (the 8-child row is name / stats / /// three meter+text pairs, nothing else) — retail may simply not mark -/// leadership in the row at all. Lacking a decompiled anchor either way, -/// this controller tints the leader's name text a distinct gold -/// () as a minimal, clearly-adaptive visual -/// cue rather than inventing a DAT mechanism that was not found — register -/// row AD-82. +/// leadership in the row at all. The earlier gold leader tint and blue +/// selection tint this controller invented for that gap were RETIRED at the +/// 2026-08-13 gate by user direction: names render white, always +/// (), and selection feedback is the in-game +/// selection itself — register row AD-82 updated accordingly. /// /// /// @@ -126,18 +126,12 @@ public sealed class SocialFellowshipPageController /// _currNum >= 9 (lane B §7.1). private const int MaxFellowshipSize = 9; - private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f); + /// 2026-08-13 gate (user-directed): fellow names are WHITE, + /// always — the earlier AD-82 leader-gold and selection-blue tints are + /// retired (no decompiled anchor ever existed for either); the in-game + /// selection ring is the selection feedback. private static readonly Vector4 MemberNameColor = Vector4.One; - /// Fix-round MUST-FIX 4 — the panel-local "this row is selected" - /// visual cue (see the class doc's world→panel selection note). - /// Same disposition as : no decompiled - /// anchor for a per-row selection sprite/marker was found, so this is a - /// minimal, clearly-adaptive tint rather than an invented DAT mechanism - /// — register row AD-82. Takes priority over - /// when a row is both the leader and the current selection. - private static readonly Vector4 SelectedNameColor = new(0.45f, 0.85f, 1f, 1f); - /// Cached per — only /// reassigned when the fellowship name actually changes (fix-round SF-1, /// the same zero-allocation-while-idle discipline @@ -455,10 +449,19 @@ public sealed class SocialFellowshipPageController if (tooltip is not null) checkbox.TooltipText = tooltip; + // 2026-08-13 gate fix ("I can't change any options"): these authored + // checkboxes carry DAT ToggleBehavior, so the button SELF-FLIPS + // Selected at MouseUp — the old `!checkbox.Selected` here then read + // the ALREADY-FLIPPED value and wrote the ORIGINAL back, visually + // snapping every click to where it started (probe-proven: + // ProbeSocialClickRouting recorded (id, oldValue)). Same CH6a/b + // mirror discipline as the chat indicators: suppress the blind + // self-flip, derive the next value from the STORE, and let the + // per-tick seeding mirror the outcome back onto Selected. + checkbox.SuppressSelfToggle = true; checkbox.OnClick = () => { - bool next = !checkbox.Selected; - checkbox.Selected = next; + bool next = !_bindings.CurrentCharacterOption(id); _bindings.SetCharacterOption(id, next); }; } @@ -725,6 +728,15 @@ public sealed class SocialFellowshipPageController uint guid = member.Guid; nameText.OnClick = () => SelectFellow(guid); } + // 2026-08-13 gate fix ("I only get the move window cursor"): + // give the STATS text the same click target so most of the row's + // width selects the fellow (UiText.OnClick now clears the + // display-text ClickThrough default — see UiText.OnClick's doc). + if (widgets.Stats is { } statsText) + { + uint guid = member.Guid; + statsText.OnClick = () => SelectFellow(guid); + } } foreach (RuntimeFellowMemberSnapshot member in members) @@ -738,13 +750,11 @@ public sealed class SocialFellowshipPageController if (widgets.Name is { } nameText) { string name = member.Name; - // MUST-FIX 4 (fix round): selection takes priority over the - // leader tint when a row is both — it's the more immediate, - // user-driven state (see SelectedNameColor's own doc). - Vector4 color = _selectedFellowGuid == member.Guid - ? SelectedNameColor - : snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor; - nameText.LinesProvider = () => [new UiText.Line(name, color)]; + // 2026-08-13 gate (user-directed): fellow names render WHITE, + // always — the AD-82 invented leader/selection tints are retired; + // selection feedback is the in-game selection itself + // (SelectFellow drives the world selection ring). + nameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)]; } if (widgets.Stats is { } statsText) diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs index 0ec734bb..38de1fb6 100644 --- a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs @@ -15,17 +15,15 @@ namespace AcDream.App.UI.Layout; /// fix-round visibility gate). /// /// -/// 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 register row -/// AD-79 (docs/architecture/retail-divergence-register.md), which -/// covers the whole D1 Friends/Squelch inert-actions scope (one row, not -/// one per button). +/// Actions LIVE as of 2026-08-13 (AD-79 retired for this page). +/// Add (name box → 0x0018), Remove (selected row → 0x0017), +/// and Appear Offline (CharacterOption 0x27 via the immediate +/// 0x0005 auto-save) are wired through — the +/// wire beneath existed end-to-end all along +/// (docs/research/2026-08-13-social-wire-completion.md §4). Send Tell +/// (0x10000516) remains the one inert button — not in the +/// 2026-08-13 order and needing the chat-tell seam; tracked in AD-79's +/// remainder. /// /// /// @@ -68,21 +66,56 @@ namespace AcDream.App.UI.Layout; public sealed class SocialFriendsPageController { private const uint ListBoxId = 0x10000517u; + // 2026-08-13 gate (AD-79 retirement): the authored action widgets, roles + // probe-verified against the live DAT (ProbeSocialClickRouting label + // dump): Add / Remove / Send Tell buttons, the Appear Offline checkbox, + // and the name edit box gmFriendsUI's own Add path reads + // (Request_AddFriend @0x0048D240 reads the box, sends 0x0018, clears it). + private const uint AddButtonId = 0x10000514u; + private const uint RemoveButtonId = 0x10000515u; + private const uint AppearOfflineCheckboxId = 0x1000052Cu; + private const uint NameFieldId = 0x1000051Bu; + + /// The live wire seams (2026-08-13, AD-79 retired): every + /// command below already existed end-to-end (builders, WorldSession + /// sends, Runtime commands, inbound parsers — + /// docs/research/2026-08-13-social-wire-completion.md §4); this page was + /// the only missing link. AppearOffline is CharacterOption 0x27 riding + /// the immediate 0x0005 auto-save — ACE pushes FriendStatusChanged to + /// everyone who friended you (its §1.4). + public sealed record Actions( + Action AddFriend, + Action RemoveFriend, + Func CurrentAppearOffline, + Action SetAppearOffline); private readonly UiTemplateListBox _listBox; private readonly FriendsState _friends; + private readonly Actions? _actions; + private readonly UiField? _nameField; + private readonly UiButton? _appearOfflineCheckbox; private long _lastRevision = long.MinValue; + private uint _selectedFriendGuid; - private SocialFriendsPageController(UiTemplateListBox listBox, FriendsState friends) + private SocialFriendsPageController( + UiTemplateListBox listBox, + FriendsState friends, + Actions? actions, + UiField? nameField, + UiButton? appearOfflineCheckbox) { _listBox = listBox; _friends = friends; + _actions = actions; + _nameField = nameField; + _appearOfflineCheckbox = appearOfflineCheckbox; } public static SocialFriendsPageController? Bind( UiElement pageRoot, FriendsState friends, - Func templateResolver) + Func templateResolver, + Actions? actions = null) { ArgumentNullException.ThrowIfNull(pageRoot); ArgumentNullException.ThrowIfNull(friends); @@ -108,13 +141,52 @@ public sealed class SocialFriendsPageController $"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} " + "not found — the Friends list will not scroll."); - var controller = new SocialFriendsPageController(listBox, friends); + var nameField = UiElement.FindDescendant(pageRoot, NameFieldId) as UiField; + var appearOffline = UiElement.FindDescendant(pageRoot, AppearOfflineCheckboxId) as UiButton; + var controller = new SocialFriendsPageController( + listBox, friends, actions, nameField, appearOffline); + controller.WireActions(pageRoot); controller.Refresh(); return controller; } + private void WireActions(UiElement pageRoot) + { + if (_actions is not { } actions) return; // fixture callers stay inert + + if (UiElement.FindDescendant(pageRoot, AddButtonId) is UiButton add) + add.OnClick = () => + { + string name = _nameField?.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) return; + actions.AddFriend(name); + // Retail clears the edit box on send (Request_AddFriend + // @0x0048D240's own post-send clear). + _nameField?.SetText(string.Empty); + }; + + if (UiElement.FindDescendant(pageRoot, RemoveButtonId) is UiButton remove) + remove.OnClick = () => + { + if (_selectedFriendGuid != 0u) + actions.RemoveFriend(_selectedFriendGuid); + }; + + if (_appearOfflineCheckbox is { } checkbox) + { + // The same ToggleBehavior mirror discipline as the fellowship/ + // allegiance checkboxes (the 2026-08-13 double-toggle fix). + checkbox.SuppressSelfToggle = true; + checkbox.OnClick = () => + actions.SetAppearOffline(!actions.CurrentAppearOffline()); + } + } + public void Tick() { + if (_actions is { } actions && _appearOfflineCheckbox is { } checkbox) + checkbox.Selected = actions.CurrentAppearOffline(); + long revision = _friends.Revision; if (revision == _lastRevision) return; Refresh(); @@ -125,16 +197,24 @@ public sealed class SocialFriendsPageController long revision = _friends.Revision; _listBox.Flush(); bool allRowsResolved = true; + bool selectedStillPresent = false; foreach (FriendEntry friend in _friends.Snapshot()) { UiElement? row = _listBox.AddItemFromTemplateList(0); if (row is null) { allRowsResolved = false; continue; } + if (friend.Id == _selectedFriendGuid) selectedStillPresent = true; if (SocialPanelRowText.FindDeepest(row) is { } text) { string name = friend.Name; + uint guid = friend.Id; text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; + // 2026-08-13 gate: row click selects the friend the Remove + // button acts on (UiText.OnClick clears the display-text + // ClickThrough default — see its doc). + text.OnClick = () => _selectedFriendGuid = guid; } } + if (!selectedStillPresent) _selectedFriendGuid = 0u; // SF-3: only latch the revision once the rebuild actually reflects it — // a resolver miss must not silently swallow a future revision bump. if (allRowsResolved) _lastRevision = revision; diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index 8713e362..2ae0d719 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -102,7 +102,11 @@ public sealed class SocialPanelController : IRetainedPanelController SocialAllegiancePageController.Bindings Allegiance, FriendsState Friends, SquelchState Squelch, - Func TemplateResolver); + Func TemplateResolver, + // 2026-08-13 (AD-79 retirement): the live Friends/Squelch action + // seams — null (fixture callers) keeps those pages read-only. + SocialFriendsPageController.Actions? FriendsActions = null, + SocialSquelchPageController.Actions? SquelchActions = null); private readonly UiTabPanel _tabPanel; private readonly SocialFellowshipPageController? _fellowship; @@ -202,10 +206,12 @@ public sealed class SocialPanelController : IRetainedPanelController : SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance); SocialFriendsPageController? friends = friendsPage is null ? null - : SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver); + : SocialFriendsPageController.Bind( + friendsPage, callbacks.Friends, callbacks.TemplateResolver, callbacks.FriendsActions); SocialSquelchPageController? squelch = squelchPage is null ? null - : SocialSquelchPageController.Bind(squelchPage, callbacks.Squelch, callbacks.TemplateResolver); + : SocialSquelchPageController.Bind( + squelchPage, callbacks.Squelch, callbacks.TemplateResolver, callbacks.SquelchActions); if (fellowshipPage is null) Console.WriteLine($"[D.2b] SocialPanelController: Fellowship page 0x{FellowshipPageId:X8} not found."); diff --git a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs index 5f47f73a..50fa88fd 100644 --- a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs @@ -53,21 +53,49 @@ namespace AcDream.App.UI.Layout; public sealed class SocialSquelchPageController { private const uint ListBoxId = 0x1000053Eu; + // 2026-08-13 gate (AD-79 retirement): probe-verified roles + // (ProbeSocialClickRouting label dump) — the name box, the Remove button, + // and the two add buttons ("Squelch Character" / "Squelch Account"). + private const uint NameFieldId = 0x10000540u; + private const uint RemoveButtonId = 0x10000547u; + private const uint SquelchCharacterButtonId = 0x1000054Bu; + private const uint SquelchAccountButtonId = 0x1000054Cu; + + /// 2026-08-13 (AD-79 retired for this page): add-by-name rides + /// 0x0058 (character scope, guid 0, type AllChannels — ACE name- + /// looks-up) / 0x0059 (account scope); remove is 0x0058/0x0059 + /// with add=0 for the selected row. Wire details: + /// docs/research/2026-08-13-social-wire-completion.md §1.5. + public sealed record Actions( + Action SquelchCharacter, + Action SquelchAccount, + Action RemoveCharacterSquelch, + Action RemoveAccountSquelch); private readonly UiTemplateListBox _listBox; private readonly SquelchState _squelch; + private readonly Actions? _actions; + private readonly UiField? _nameField; private long _lastRevision = long.MinValue; + private (uint Guid, string Name, bool IsAccount)? _selected; - private SocialSquelchPageController(UiTemplateListBox listBox, SquelchState squelch) + private SocialSquelchPageController( + UiTemplateListBox listBox, + SquelchState squelch, + Actions? actions, + UiField? nameField) { _listBox = listBox; _squelch = squelch; + _actions = actions; + _nameField = nameField; } public static SocialSquelchPageController? Bind( UiElement pageRoot, SquelchState squelch, - Func templateResolver) + Func templateResolver, + Actions? actions = null) { ArgumentNullException.ThrowIfNull(pageRoot); ArgumentNullException.ThrowIfNull(squelch); @@ -93,11 +121,46 @@ public sealed class SocialSquelchPageController $"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} " + "not found — the Squelch list will not scroll."); - var controller = new SocialSquelchPageController(listBox, squelch); + var nameField = UiElement.FindDescendant(pageRoot, NameFieldId) as UiField; + var controller = new SocialSquelchPageController(listBox, squelch, actions, nameField); + controller.WireActions(pageRoot); controller.Refresh(); return controller; } + private void WireActions(UiElement pageRoot) + { + if (_actions is not { } actions) return; // fixture callers stay inert + + if (UiElement.FindDescendant(pageRoot, SquelchCharacterButtonId) is UiButton addCharacter) + addCharacter.OnClick = () => + { + string name = _nameField?.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) return; + actions.SquelchCharacter(name); + _nameField?.SetText(string.Empty); + }; + + if (UiElement.FindDescendant(pageRoot, SquelchAccountButtonId) is UiButton addAccount) + addAccount.OnClick = () => + { + string name = _nameField?.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) return; + actions.SquelchAccount(name); + _nameField?.SetText(string.Empty); + }; + + if (UiElement.FindDescendant(pageRoot, RemoveButtonId) is UiButton remove) + remove.OnClick = () => + { + if (_selected is not { } selected) return; + if (selected.IsAccount) + actions.RemoveAccountSquelch(selected.Name); + else + actions.RemoveCharacterSquelch(selected.Guid, selected.Name); + }; + } + public void Tick() { long revision = _squelch.Revision; @@ -112,22 +175,38 @@ public sealed class SocialSquelchPageController SquelchDatabase database = _squelch.Snapshot(); bool allRowsResolved = true; - foreach (SquelchInfo character in database.Characters.Values) - allRowsResolved &= AddRow(character.Name); + bool selectedStillPresent = false; + foreach ((uint guid, SquelchInfo character) in database.Characters) + { + allRowsResolved &= AddRow(character.Name, guid, isAccount: false); + if (_selected is { IsAccount: false } s && s.Guid == guid) + selectedStillPresent = true; + } foreach (string accountName in database.Accounts.Keys) - allRowsResolved &= AddRow(accountName); + { + allRowsResolved &= AddRow(accountName, 0u, isAccount: true); + if (_selected is { IsAccount: true } s && s.Name == accountName) + selectedStillPresent = true; + } + if (!selectedStillPresent) _selected = null; // SF-3: only latch the revision once the rebuild actually reflects it — // a resolver miss must not silently swallow a future revision bump. if (allRowsResolved) _lastRevision = revision; } - private bool AddRow(string name) + private bool AddRow(string name, uint guid, bool isAccount) { UiElement? row = _listBox.AddItemFromTemplateList(0); if (row is null) return false; if (SocialPanelRowText.FindDeepest(row) is { } text) + { text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; + // 2026-08-13 gate: row click selects the entry the Remove button + // acts on (UiText.OnClick clears the display-text ClickThrough + // default). + text.OnClick = () => _selected = (guid, name, isAccount); + } return true; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 07fa2f2e..a048405f 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2861,7 +2861,33 @@ public sealed class RetailUiRuntime : IDisposable ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)), Friends: _bindings.Social.Friends, Squelch: _bindings.Social.Squelch, - TemplateResolver: TemplateResolver); + TemplateResolver: TemplateResolver, + // 2026-08-13 (AD-79 retirement): the wire beneath every one of + // these commands shipped with J4.1/FA1 — builders, WorldSession + // sends, router registrations, inbound parsers (see + // docs/research/2026-08-13-social-wire-completion.md §4); the + // panel publishes onto the SAME bus every other surface uses. + FriendsActions: new Layout.SocialFriendsPageController.Actions( + AddFriend: name => _bindings.Options.CommandBus().Publish( + new AddFriendRuntimeCmd(name)), + RemoveFriend: guid => _bindings.Options.CommandBus().Publish( + new RemoveFriendRuntimeCmd(guid)), + CurrentAppearOffline: () => _bindings.Options.CurrentCharacterOption( + (uint)CharacterOptionId.AppearOffline), + SetAppearOffline: value => _bindings.Options.CommandBus().Publish( + new SetSingleCharacterOptionRuntimeCmd( + (uint)CharacterOptionId.AppearOffline, value))), + SquelchActions: new Layout.SocialSquelchPageController.Actions( + // Character scope, guid 0 (ACE name-looks-up), AllChannels — + // research §1.5's exact retail body. + SquelchCharacter: name => _bindings.Options.CommandBus().Publish( + new ModifyCharacterSquelchRuntimeCmd(true, 0u, name, 1u)), + SquelchAccount: name => _bindings.Options.CommandBus().Publish( + new ModifyAccountSquelchRuntimeCmd(true, name)), + RemoveCharacterSquelch: (guid, name) => _bindings.Options.CommandBus().Publish( + new ModifyCharacterSquelchRuntimeCmd(false, guid, name, 1u)), + RemoveAccountSquelch: name => _bindings.Options.CommandBus().Publish( + new ModifyAccountSquelchRuntimeCmd(false, name)))); // A second DatLock scope (MountOptionsPanel's own precedent, above): // SocialFellowshipPageController.Bind resolves the Open/Close diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index bb07e0b5..f2d2d897 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -22,8 +22,25 @@ namespace AcDream.App.UI; /// public sealed class UiText : UiElement, IUiDatStateful { - /// Optional base-element click notice used by authored text tabs. - public Action? OnClick { get; set; } + /// Optional base-element click notice used by authored text tabs. + /// Assigning a handler also clears — + /// display text is click-through by default (the class doc's contract), + /// which otherwise makes the handler unreachable: the hit-test walk skips + /// click-through elements no matter what says + /// (the 2026-08-13 social gate's unclickable fellowship roster rows, + /// probe-proven in ProbeSocialClickRouting). + public Action? OnClick + { + get => _onClick; + set + { + _onClick = value; + if (value is not null) + ClickThrough = false; + } + } + + private Action? _onClick; public override bool HandlesClick => OnClick is not null || WheelScrollEnabled || base.HandlesClick; /// Dat element id for imported UIElement_Text widgets. 0 for synthesized text. diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 5e66d0cb..0e28b3a9 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -257,7 +257,10 @@ public sealed class SelectionInteractionControllerTests Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); - Assert.False(h.Query.LastIncludeSelf); + // 2026-08-13 gate: retail has NO self-exclusion on this path + // (RecvNotice_SmartBoxObjectFound @0x004E5BAE selects/examines + // unconditionally) — includeSelf is now always true. + Assert.True(h.Query.LastIncludeSelf); Assert.Equal(Target, h.Selection.SelectedObjectId); Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); Assert.Equal(new[] { Target }, h.Examines); diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs index 1dcc60ad..3d3e7365 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs @@ -716,16 +716,28 @@ public sealed class SocialFellowshipPageControllerTests [Fact] public void Checkbox_Click_TogglesAndWritesTheCharacterOption() { + // 2026-08-13 gate fix: these authored checkboxes carry ToggleBehavior + // (the button SELF-FLIPS Selected at MouseUp), so the handler derives + // the next value from the STORE and the per-tick seeding mirrors it + // back — the old handler read the already-flipped widget state and + // wrote the ORIGINAL value forever (the stuck-checkbox gate report). UiElement root = BuildPageRoot(out _, out _); var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } }; - SocialFellowshipPageController.Bind(root, b.Build()); + SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!; var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!; + Assert.True(shareXp.SuppressSelfToggle); Assert.False(shareXp.Selected); shareXp.OnClick!(); - Assert.True(shareXp.Selected); Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]); + controller.Tick(); // the seeding mirrors the store + Assert.True(shareXp.Selected); + + shareXp.OnClick!(); // and the toggle works BOTH ways + Assert.False(b.Options[CharacterOptionId.FellowshipShareXP]); + controller.Tick(); + Assert.False(shareXp.Selected); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs index 8b3cc249..42fedb8e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs @@ -306,7 +306,9 @@ public sealed class SocialPanelControllerTests var monarchName = Assert.IsType(UiElement.FindDescendant(monarchField, 0x10000257u)); Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text); var monarchFollowers = Assert.IsType(UiElement.FindDescendant(monarchField, 0x10000258u)); - Assert.Equal("2", Assert.Single(monarchFollowers.LinesProvider()).Text); + // 2026-08-13 gate: the bare count gained its label (user-directed + // format; the full retail StringInfo composition stays AD-85's gap). + Assert.Equal("Followers: 2", Assert.Single(monarchFollowers.LinesProvider()).Text); } /// SF-7's own additional test — the monarch IS the viewer: diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs index c62fcf16..620055a3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs @@ -475,6 +475,240 @@ public sealed class SocialPanelLiveMountProbeTests DumpInfoTree(c, depth + 1, maxDepth); } + /// User gate 2026-08-13 ("I can't change any options", "I only + /// get the move window cursor" over roster rows): reproduce the click + /// ROUTING in-process — mount the panel through the PRODUCTION window + /// frame, build a live roster, then hit-test each interactive widget's + /// center through the real UiRoot walk and print WHAT claims the point + /// plus every ancestor's Visible/Enabled/ClickThrough. Also dumps the + /// no-fellowship frame's text children for the truncated empty-state + /// string. + [Fact] + public void ProbeSocialClickRouting() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") + return; + + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var strings = new DatStringResolver(dats); + + ElementInfo? rootInfo = LayoutImporter.ImportInfos( + dats, SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId); + Assert.NotNull(rootInfo); + ImportedLayout layout = LayoutImporter.Build( + rootInfo!, _ => (1u, 8, 8), null, null, strings.Resolve); + UiTabPanel tabs = Assert.IsType(layout.Root); + tabs.ActivateTabBehavior(); + + var rowTemplates = new RowTemplateResolver( + (layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId), + info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root); + + // Mutable live state the controller reads each Tick. + RuntimeFellowshipSnapshot snapshot = default(RuntimeFellowshipSnapshot) with + { + Revision = 1, + IsInFellowship = true, + Name = "Probe", + LeaderGuid = 0x50000001u, + }; + var members = new List + { + new(0x50000001u, "Leader", 10, 100, 100, 100, 100, 100, 100, false), + new(0x50000002u, "Fellow", 12, 120, 120, 120, 120, 120, 120, false), + }; + var optionWrites = new List<(uint Id, bool Value)>(); + + UiElement? fellowshipPage = UiElement.FindDescendant(tabs, 0x10000292u); + Assert.NotNull(fellowshipPage); + SocialFellowshipPageController? controller = SocialFellowshipPageController.Bind( + fellowshipPage!, + new SocialFellowshipPageController.Bindings( + Snapshot: () => snapshot, + Members: () => members, + TemplateResolver: rowTemplates.Resolve, + Create: (_, _) => default, + Recruit: _ => default, + Dismiss: _ => default, + Quit: _ => default, + AssignLeader: _ => default, + SetOpen: _ => default, + SetPanelOpen: _ => default, + Selection: new AcDream.Core.Selection.SelectionState(), + LocalPlayerGuid: () => 0x50000001u, + CurrentCharacterOption: _ => false, + SetCharacterOption: (id, value) => optionWrites.Add(((uint)id, value)), + ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId))); + Assert.NotNull(controller); + + // The PRODUCTION window frame (RetailUiRuntime.MountSocialPanel's + // exact options minus the born-hidden flag) inside a real root. + var uiRoot = new UiRoot { Width = 1280, Height = 720 }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + uiRoot, tabs, _ => (1u, 8, 8), + new RetailWindowFrame.Options + { + WindowName = WindowNames.SocialPanel, + Chrome = RetailWindowChrome.NineSlice, + Left = 200f, + Top = 140f, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ConstrainDragToParent = true, + ConstrainResizeToParent = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom, + ContentClickThrough = false, + }); + handle.Show(); + + tabs.SwitchTo(0x10000292u); // the Fellowship page + controller!.Tick(); // builds the two roster rows + + foreach ((uint id, string name) in new (uint, string)[] + { + (0x10000270u, "IgnoreRequestsCheckbox"), + (0x10000271u, "AutoAcceptCheckbox"), + (0x10000272u, "ShareXpCheckbox"), + (0x10000273u, "ShareLootCheckbox"), + (0x1000027Fu, "DismissButton"), + (0x10000283u, "RowNameText(first)"), + }) + { + UiElement? el = UiElement.FindDescendant(uiRoot, id); + if (el is null) + { + Console.WriteLine($"[clickprobe] {name} 0x{id:X8}: MISSING under the mounted root"); + continue; + } + (float ax, float ay) = Absolute(el); + float cx = ax + el.Width / 2f, cy = ay + el.Height / 2f; + UiElement? winner = uiRoot.HitTest(cx, cy); + Console.WriteLine( + $"[clickprobe] {name} 0x{id:X8}: abs=({ax},{ay} {el.Width}x{el.Height}) " + + $"hit@({cx},{cy}) -> {(winner is null ? "NULL" : $"{winner.GetType().Name} 0x{winner.DatElementId:X8}")} " + + $"{(ReferenceEquals(winner, el) ? "SELF" : "NOT-SELF")}"); + for (UiElement? a = el; a is not null; a = a.Parent) + Console.WriteLine( + $"[clickprobe] ancestor {a.GetType().Name} 0x{a.DatElementId:X8} " + + $"({a.Left},{a.Top} {a.Width}x{a.Height}) " + + $"Visible={a.Visible} Enabled={a.Enabled} ClickThrough={a.ClickThrough}"); + } + + // The checkbox CLICK itself, end-to-end: synthesize a click on the + // first checkbox through the root's own pointer pipeline. + if (UiElement.FindDescendant(uiRoot, 0x10000270u) is { } cb) + { + (float ax, float ay) = Absolute(cb); + int px = (int)(ax + cb.Width / 2f), py = (int)(ay + cb.Height / 2f); + uiRoot.OnMouseDown(UiMouseButton.Left, px, py); + uiRoot.OnMouseUp(UiMouseButton.Left, px, py); + Console.WriteLine( + $"[clickprobe] synthetic click on IgnoreRequestsCheckbox -> optionWrites=[{string.Join(",", optionWrites)}]"); + } + + // The truncated empty-state: flip to no-fellowship and dump the + // frame's text children. + snapshot = snapshot with { IsInFellowship = false, Revision = 2 }; + controller.Tick(); + if (UiElement.FindDescendant(uiRoot, 0x1000026Bu) is { } emptyFrame) + DumpTexts(emptyFrame, 0); + + // Round 2: with the no-fellowship frame now VISIBLE, does a real + // click toggle the checkbox? + optionWrites.Clear(); + if (UiElement.FindDescendant(uiRoot, 0x10000270u) is { } cb2) + { + (float ax, float ay) = Absolute(cb2); + int px = (int)(ax + cb2.Width / 2f), py = (int)(ay + cb2.Height / 2f); + UiElement? winner = uiRoot.HitTest(px, py); + Console.WriteLine( + $"[clickprobe] NOT-in-fellowship hit@({px},{py}) -> " + + $"{(winner is null ? "NULL" : $"{winner.GetType().Name} 0x{winner.DatElementId:X8}")}"); + uiRoot.OnMouseDown(UiMouseButton.Left, px, py); + uiRoot.OnMouseUp(UiMouseButton.Left, px, py); + Console.WriteLine( + $"[clickprobe] NOT-in-fellowship synthetic click -> optionWrites=[{string.Join(",", optionWrites)}]"); + } + + // Friends + Squelch action widgets: authored labels → button roles + // (never guess an id's role). + foreach (uint pageId in new[] { 0x10000513u, 0x1000054Au }) + { + if (UiElement.FindDescendant(uiRoot, pageId) is not { } page) continue; + DumpActionWidgets(page, 0); + } + + // The allegiance page's own checkbox (the user's "always checked, + // can't press"): what is it, where does it live, is it reachable? + tabs.SwitchTo(0x10000291u); + foreach (uint id in new[] { 0x10000266u, 0x10000267u, 0x10000268u, 0x10000269u, 0x1000026Au }) + { + UiElement? el = UiElement.FindDescendant(uiRoot, id); + if (el is null) continue; + (float ax, float ay) = Absolute(el); + UiElement? winner = uiRoot.HitTest(ax + el.Width / 2f, ay + el.Height / 2f); + Console.WriteLine( + $"[clickprobe] allegiance 0x{id:X8} {el.GetType().Name} abs=({ax},{ay} {el.Width}x{el.Height}) " + + $"Visible={el.Visible} Enabled={el.Enabled} ClickThrough={el.ClickThrough} " + + $"hit -> {(winner is null ? "NULL" : $"{winner.GetType().Name} 0x{winner.DatElementId:X8}")}"); + } + } + + private static (float X, float Y) Absolute(UiElement el) + { + float x = 0, y = 0; + for (UiElement? a = el; a is not null; a = a.Parent) + { + x += a.Left; + y += a.Top; + } + return (x, y); + } + + private static void DumpActionWidgets(UiElement el, int depth) + { + string extra = el switch + { + UiButton b => $" label='{b.Label}'", + UiField => " FIELD", + _ => "", + }; + if (el is UiButton or UiField || depth == 0) + Console.WriteLine( + $"[clickprobe] {new string(' ', depth * 2)}{el.GetType().Name} " + + $"0x{el.DatElementId:X8} ({el.Left},{el.Top} {el.Width}x{el.Height}){extra}"); + foreach (UiElement c in el.Children) + DumpActionWidgets(c, depth + 1); + } + + private static void DumpTexts(UiElement el, int depth) + { + if (el is UiText text) + { + string content = string.Join( + " \\n ", + (text.LinesProvider?.Invoke() ?? []).Select(l => l.Text)); + Console.WriteLine( + $"[clickprobe] {new string(' ', depth * 2)}TEXT 0x{el.DatElementId:X8} " + + $"({el.Left},{el.Top} {el.Width}x{el.Height}) '{content}'"); + } + else + { + Console.WriteLine( + $"[clickprobe] {new string(' ', depth * 2)}{el.GetType().Name} 0x{el.DatElementId:X8} " + + $"({el.Left},{el.Top} {el.Width}x{el.Height})"); + } + foreach (UiElement c in el.Children) + DumpTexts(c, depth + 1); + } + private static int CountDescendants(UiElement root, uint id) { int count = root.DatElementId == id ? 1 : 0;