fix: social panel completion batch (user gate 2026-08-13, "fix all")

One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):

1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
   can't change any options"): the authored checkboxes carry DAT
   ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
   handlers read the flipped value and wrote the ORIGINAL back, snapping
   every click to where it started (the probe recorded (id, oldValue)).
   Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
   next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
   name text is display-text ClickThrough=true, which the hit-test walk
   skips regardless of HandlesClick - the wired OnClick was unreachable.
   Fix: UiText.OnClick assignment now clears ClickThrough (central,
   documented); the stats text gains the same select handler so most of
   the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
   the authored string resolves COMPLETE (three sentences) but embedded
   '\n's rendered as one clipped line. DatWidgetFactory now splits
   authored strings into one Line per newline, with the provider still
   re-reading DefaultColor live (the state-color contract - caught by
   BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
   selection-blue tints are deleted; names always white (register row
   narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
   (user-specified format; the full retail StringInfo composition stays
   AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
   0x0018, retail clears the box - Request_AddFriend @0x0048D240),
   Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
   0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
   to your friend-of list), Squelch Character/Account add-by-name
   (0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
   row. The wire beneath (builders, WorldSession sends, Runtime commands,
   parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
   (docs/research/2026-08-13-social-wire-completion.md, committed here).
   Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
   retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
   every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
   unconditionally) - the includeSelf gate was an unregistered
   divergence, now removed on both the left-click and right-click paths.

Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).

Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).

Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 19:30:51 +02:00
parent ec2a7b0cce
commit 72ceddce2e
15 changed files with 1018 additions and 65 deletions

File diff suppressed because one or more lines are too long

View file

@ -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 13 **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<char>::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 <name>` 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<string,u32> accounts — ALWAYS EMPTY in retail pcaps (ACE writes header 0/0)
PackableHashTable<u32, SquelchInfo> 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
`0x00A20x00A6`, `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.

View file

@ -148,7 +148,14 @@ internal sealed class SelectionInteractionController
public void PickAndStoreSelection(bool useImmediately) 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 (picked is not uint guid)
{ {
if (!_items.IsAnyTargetModeActive) if (!_items.IsAnyTargetModeActive)
@ -196,7 +203,9 @@ internal sealed class SelectionInteractionController
/// </summary> /// </summary>
public void PickSelectAndExamine() 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) if (picked is not uint guid)
return; return;

View file

@ -702,7 +702,19 @@ public static class DatWidgetFactory
t.OutlineColor = info.OutlineColor.Value; t.OutlineColor = info.OutlineColor.Value;
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored) 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; return t;
} }

View file

@ -421,10 +421,16 @@ public sealed class SocialAllegiancePageController
if (tooltip is not null) if (tooltip is not null)
_ignoreRequestsCheckbox.TooltipText = tooltip; _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 = () => _ignoreRequestsCheckbox.OnClick = () =>
{ {
bool next = !_ignoreRequestsCheckbox.Selected; bool next = !_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests);
_ignoreRequestsCheckbox.Selected = next;
_bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next); _bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next);
}; };
} }
@ -518,8 +524,12 @@ public sealed class SocialAllegiancePageController
private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot) private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot)
{ {
SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor); SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor);
SetLine(_selfFollowers, ref _lastSelfFollowers, snapshot.TotalVassals.ToString(), TextColor); // 2026-08-13 gate ("On top it just says 0 then 0"): the bare numbers
SetLine(_selfRank, ref _lastSelfRank, snapshot.Rank.ToString(), TextColor); // 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. // gmAllegianceUI::UpdateMonarchData @0x00491B40 — see class doc.
@ -541,10 +551,11 @@ public sealed class SocialAllegiancePageController
RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value; RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value;
SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor); SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor);
// 2026-08-13 gate: same label treatment as the self block's followers.
SetLine( SetLine(
_monarchFollowers, _monarchFollowers,
ref _lastMonarchFollowers, ref _lastMonarchFollowers,
(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u).ToString(), $"Followers: {(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u)}",
TextColor); TextColor);
_monarchField.Enabled = monarchData.IsLoggedIn; _monarchField.Enabled = monarchData.IsLoggedIn;

View file

@ -58,11 +58,11 @@ namespace AcDream.App.UI.Layout;
/// <b>Leader marker.</b> Lane A's row-template inventory names no dedicated /// <b>Leader marker.</b> Lane A's row-template inventory names no dedicated
/// "this fellow is the leader" element (the 8-child row is name / stats / /// "this fellow is the leader" element (the 8-child row is name / stats /
/// three meter+text pairs, nothing else) — retail may simply not mark /// three meter+text pairs, nothing else) — retail may simply not mark
/// leadership in the row at all. Lacking a decompiled anchor either way, /// leadership in the row at all. The earlier gold leader tint and blue
/// this controller tints the leader's name text a distinct gold /// selection tint this controller invented for that gap were RETIRED at the
/// (<see cref="LeaderNameColor"/>) as a minimal, clearly-adaptive visual /// 2026-08-13 gate by user direction: names render white, always
/// cue rather than inventing a DAT mechanism that was not found — register /// (<see cref="MemberNameColor"/>), and selection feedback is the in-game
/// row AD-82. /// selection itself — register row AD-82 updated accordingly.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -126,18 +126,12 @@ public sealed class SocialFellowshipPageController
/// <c>_currNum &gt;= 9</c> (lane B §7.1).</summary> /// <c>_currNum &gt;= 9</c> (lane B §7.1).</summary>
private const int MaxFellowshipSize = 9; private const int MaxFellowshipSize = 9;
private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f); /// <summary>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.</summary>
private static readonly Vector4 MemberNameColor = Vector4.One; private static readonly Vector4 MemberNameColor = Vector4.One;
/// <summary>Fix-round MUST-FIX 4 — the panel-local "this row is selected"
/// visual cue (see the class doc's world&#8594;panel selection note).
/// Same disposition as <see cref="LeaderNameColor"/>: 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 <see cref="LeaderNameColor"/>
/// when a row is both the leader and the current selection.</summary>
private static readonly Vector4 SelectedNameColor = new(0.45f, 0.85f, 1f, 1f);
/// <summary>Cached per <see cref="RefreshFellowshipName"/> — only /// <summary>Cached per <see cref="RefreshFellowshipName"/> — only
/// reassigned when the fellowship name actually changes (fix-round SF-1, /// reassigned when the fellowship name actually changes (fix-round SF-1,
/// the same zero-allocation-while-idle discipline /// the same zero-allocation-while-idle discipline
@ -455,10 +449,19 @@ public sealed class SocialFellowshipPageController
if (tooltip is not null) if (tooltip is not null)
checkbox.TooltipText = tooltip; 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 = () => checkbox.OnClick = () =>
{ {
bool next = !checkbox.Selected; bool next = !_bindings.CurrentCharacterOption(id);
checkbox.Selected = next;
_bindings.SetCharacterOption(id, next); _bindings.SetCharacterOption(id, next);
}; };
} }
@ -725,6 +728,15 @@ public sealed class SocialFellowshipPageController
uint guid = member.Guid; uint guid = member.Guid;
nameText.OnClick = () => SelectFellow(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) foreach (RuntimeFellowMemberSnapshot member in members)
@ -738,13 +750,11 @@ public sealed class SocialFellowshipPageController
if (widgets.Name is { } nameText) if (widgets.Name is { } nameText)
{ {
string name = member.Name; string name = member.Name;
// MUST-FIX 4 (fix round): selection takes priority over the // 2026-08-13 gate (user-directed): fellow names render WHITE,
// leader tint when a row is both — it's the more immediate, // always — the AD-82 invented leader/selection tints are retired;
// user-driven state (see SelectedNameColor's own doc). // selection feedback is the in-game selection itself
Vector4 color = _selectedFellowGuid == member.Guid // (SelectFellow drives the world selection ring).
? SelectedNameColor nameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
: snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
nameText.LinesProvider = () => [new UiText.Line(name, color)];
} }
if (widgets.Stats is { } statsText) if (widgets.Stats is { } statsText)

View file

@ -15,17 +15,15 @@ namespace AcDream.App.UI.Layout;
/// fix-round visibility gate). /// fix-round visibility gate).
/// ///
/// <para> /// <para>
/// <b>D1 — inert actions.</b> This campaign implements no Friends /// <b>Actions LIVE as of 2026-08-13 (AD-79 retired for this page).</b>
/// add/remove/appear-offline wire (the trivially-pinnable bar D1 sets was /// Add (name box → <c>0x0018</c>), Remove (selected row → <c>0x0017</c>),
/// not met for this family — see the plan's D1 decision). The three /// and Appear Offline (CharacterOption <c>0x27</c> via the immediate
/// buttons (<c>0x10000514</c>/<c>0x10000515</c>/<c>0x10000516</c>) and the /// <c>0x0005</c> auto-save) are wired through <see cref="Actions"/> — the
/// "Appear Offline" checkbox (<c>0x1000052C</c>) are left AUTHORED and /// wire beneath existed end-to-end all along
/// CLICKABLE with no handler — <see cref="DatWidgetFactory"/> already /// (docs/research/2026-08-13-social-wire-completion.md §4). Send Tell
/// builds every button/checkbox with a null <c>OnClick</c> by default, so /// (<c>0x10000516</c>) remains the one inert button — not in the
/// no explicit "leave unbound" code is needed here. See register row /// 2026-08-13 order and needing the chat-tell seam; tracked in AD-79's
/// AD-79 (<c>docs/architecture/retail-divergence-register.md</c>), which /// remainder.
/// covers the whole D1 Friends/Squelch inert-actions scope (one row, not
/// one per button).
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -68,21 +66,56 @@ namespace AcDream.App.UI.Layout;
public sealed class SocialFriendsPageController public sealed class SocialFriendsPageController
{ {
private const uint ListBoxId = 0x10000517u; 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;
/// <summary>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).</summary>
public sealed record Actions(
Action<string> AddFriend,
Action<uint> RemoveFriend,
Func<bool> CurrentAppearOffline,
Action<bool> SetAppearOffline);
private readonly UiTemplateListBox _listBox; private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends; private readonly FriendsState _friends;
private readonly Actions? _actions;
private readonly UiField? _nameField;
private readonly UiButton? _appearOfflineCheckbox;
private long _lastRevision = long.MinValue; 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; _listBox = listBox;
_friends = friends; _friends = friends;
_actions = actions;
_nameField = nameField;
_appearOfflineCheckbox = appearOfflineCheckbox;
} }
public static SocialFriendsPageController? Bind( public static SocialFriendsPageController? Bind(
UiElement pageRoot, UiElement pageRoot,
FriendsState friends, FriendsState friends,
Func<uint, uint, UiElement?> templateResolver) Func<uint, uint, UiElement?> templateResolver,
Actions? actions = null)
{ {
ArgumentNullException.ThrowIfNull(pageRoot); ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(friends); ArgumentNullException.ThrowIfNull(friends);
@ -108,13 +141,52 @@ public sealed class SocialFriendsPageController
$"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} " $"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Friends list will not scroll."); + "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(); controller.Refresh();
return controller; 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() public void Tick()
{ {
if (_actions is { } actions && _appearOfflineCheckbox is { } checkbox)
checkbox.Selected = actions.CurrentAppearOffline();
long revision = _friends.Revision; long revision = _friends.Revision;
if (revision == _lastRevision) return; if (revision == _lastRevision) return;
Refresh(); Refresh();
@ -125,16 +197,24 @@ public sealed class SocialFriendsPageController
long revision = _friends.Revision; long revision = _friends.Revision;
_listBox.Flush(); _listBox.Flush();
bool allRowsResolved = true; bool allRowsResolved = true;
bool selectedStillPresent = false;
foreach (FriendEntry friend in _friends.Snapshot()) foreach (FriendEntry friend in _friends.Snapshot())
{ {
UiElement? row = _listBox.AddItemFromTemplateList(0); UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) { allRowsResolved = false; continue; } if (row is null) { allRowsResolved = false; continue; }
if (friend.Id == _selectedFriendGuid) selectedStillPresent = true;
if (SocialPanelRowText.FindDeepest(row) is { } text) if (SocialPanelRowText.FindDeepest(row) is { } text)
{ {
string name = friend.Name; string name = friend.Name;
uint guid = friend.Id;
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; 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 — // SF-3: only latch the revision once the rebuild actually reflects it —
// a resolver miss must not silently swallow a future revision bump. // a resolver miss must not silently swallow a future revision bump.
if (allRowsResolved) _lastRevision = revision; if (allRowsResolved) _lastRevision = revision;

View file

@ -102,7 +102,11 @@ public sealed class SocialPanelController : IRetainedPanelController
SocialAllegiancePageController.Bindings Allegiance, SocialAllegiancePageController.Bindings Allegiance,
FriendsState Friends, FriendsState Friends,
SquelchState Squelch, SquelchState Squelch,
Func<uint, uint, UiElement?> TemplateResolver); Func<uint, uint, UiElement?> 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 UiTabPanel _tabPanel;
private readonly SocialFellowshipPageController? _fellowship; private readonly SocialFellowshipPageController? _fellowship;
@ -202,10 +206,12 @@ public sealed class SocialPanelController : IRetainedPanelController
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance); : SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance);
SocialFriendsPageController? friends = friendsPage is null SocialFriendsPageController? friends = friendsPage is null
? null ? null
: SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver); : SocialFriendsPageController.Bind(
friendsPage, callbacks.Friends, callbacks.TemplateResolver, callbacks.FriendsActions);
SocialSquelchPageController? squelch = squelchPage is null SocialSquelchPageController? squelch = squelchPage is null
? null ? null
: SocialSquelchPageController.Bind(squelchPage, callbacks.Squelch, callbacks.TemplateResolver); : SocialSquelchPageController.Bind(
squelchPage, callbacks.Squelch, callbacks.TemplateResolver, callbacks.SquelchActions);
if (fellowshipPage is null) if (fellowshipPage is null)
Console.WriteLine($"[D.2b] SocialPanelController: Fellowship page 0x{FellowshipPageId:X8} not found."); Console.WriteLine($"[D.2b] SocialPanelController: Fellowship page 0x{FellowshipPageId:X8} not found.");

View file

@ -53,21 +53,49 @@ namespace AcDream.App.UI.Layout;
public sealed class SocialSquelchPageController public sealed class SocialSquelchPageController
{ {
private const uint ListBoxId = 0x1000053Eu; 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;
/// <summary>2026-08-13 (AD-79 retired for this page): add-by-name rides
/// <c>0x0058</c> (character scope, guid 0, type AllChannels — ACE name-
/// looks-up) / <c>0x0059</c> (account scope); remove is <c>0x0058/0x0059</c>
/// with add=0 for the selected row. Wire details:
/// docs/research/2026-08-13-social-wire-completion.md §1.5.</summary>
public sealed record Actions(
Action<string> SquelchCharacter,
Action<string> SquelchAccount,
Action<uint, string> RemoveCharacterSquelch,
Action<string> RemoveAccountSquelch);
private readonly UiTemplateListBox _listBox; private readonly UiTemplateListBox _listBox;
private readonly SquelchState _squelch; private readonly SquelchState _squelch;
private readonly Actions? _actions;
private readonly UiField? _nameField;
private long _lastRevision = long.MinValue; 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; _listBox = listBox;
_squelch = squelch; _squelch = squelch;
_actions = actions;
_nameField = nameField;
} }
public static SocialSquelchPageController? Bind( public static SocialSquelchPageController? Bind(
UiElement pageRoot, UiElement pageRoot,
SquelchState squelch, SquelchState squelch,
Func<uint, uint, UiElement?> templateResolver) Func<uint, uint, UiElement?> templateResolver,
Actions? actions = null)
{ {
ArgumentNullException.ThrowIfNull(pageRoot); ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(squelch); ArgumentNullException.ThrowIfNull(squelch);
@ -93,11 +121,46 @@ public sealed class SocialSquelchPageController
$"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} " $"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Squelch list will not scroll."); + "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(); controller.Refresh();
return controller; 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() public void Tick()
{ {
long revision = _squelch.Revision; long revision = _squelch.Revision;
@ -112,22 +175,38 @@ public sealed class SocialSquelchPageController
SquelchDatabase database = _squelch.Snapshot(); SquelchDatabase database = _squelch.Snapshot();
bool allRowsResolved = true; bool allRowsResolved = true;
foreach (SquelchInfo character in database.Characters.Values) bool selectedStillPresent = false;
allRowsResolved &= AddRow(character.Name); 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) 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 — // SF-3: only latch the revision once the rebuild actually reflects it —
// a resolver miss must not silently swallow a future revision bump. // a resolver miss must not silently swallow a future revision bump.
if (allRowsResolved) _lastRevision = revision; if (allRowsResolved) _lastRevision = revision;
} }
private bool AddRow(string name) private bool AddRow(string name, uint guid, bool isAccount)
{ {
UiElement? row = _listBox.AddItemFromTemplateList(0); UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) return false; if (row is null) return false;
if (SocialPanelRowText.FindDeepest(row) is { } text) if (SocialPanelRowText.FindDeepest(row) is { } text)
{
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)]; 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; return true;
} }
} }

View file

@ -2861,7 +2861,33 @@ public sealed class RetailUiRuntime : IDisposable
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)), ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)),
Friends: _bindings.Social.Friends, Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch, 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): // A second DatLock scope (MountOptionsPanel's own precedent, above):
// SocialFellowshipPageController.Bind resolves the Open/Close // SocialFellowshipPageController.Bind resolves the Open/Close

View file

@ -22,8 +22,25 @@ namespace AcDream.App.UI;
/// </summary> /// </summary>
public sealed class UiText : UiElement, IUiDatStateful public sealed class UiText : UiElement, IUiDatStateful
{ {
/// <summary>Optional base-element click notice used by authored text tabs.</summary> /// <summary>Optional base-element click notice used by authored text tabs.
public Action? OnClick { get; set; } /// Assigning a handler also clears <see cref="UiElement.ClickThrough"/> —
/// 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 <see cref="HandlesClick"/> says
/// (the 2026-08-13 social gate's unclickable fellowship roster rows,
/// probe-proven in <c>ProbeSocialClickRouting</c>).</summary>
public Action? OnClick
{
get => _onClick;
set
{
_onClick = value;
if (value is not null)
ClickThrough = false;
}
}
private Action? _onClick;
public override bool HandlesClick public override bool HandlesClick
=> OnClick is not null || WheelScrollEnabled || base.HandlesClick; => OnClick is not null || WheelScrollEnabled || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary> /// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>

View file

@ -257,7 +257,10 @@ public sealed class SelectionInteractionControllerTests
Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); 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(Target, h.Selection.SelectedObjectId);
Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events);
Assert.Equal(new[] { Target }, h.Examines); Assert.Equal(new[] { Target }, h.Examines);

View file

@ -716,16 +716,28 @@ public sealed class SocialFellowshipPageControllerTests
[Fact] [Fact]
public void Checkbox_Click_TogglesAndWritesTheCharacterOption() 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 _); UiElement root = BuildPageRoot(out _, out _);
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } }; 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)!; var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!;
Assert.True(shareXp.SuppressSelfToggle);
Assert.False(shareXp.Selected); Assert.False(shareXp.Selected);
shareXp.OnClick!(); shareXp.OnClick!();
Assert.True(shareXp.Selected);
Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]); 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] [Fact]

View file

@ -306,7 +306,9 @@ public sealed class SocialPanelControllerTests
var monarchName = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000257u)); var monarchName = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000257u));
Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text); Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text);
var monarchFollowers = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000258u)); var monarchFollowers = Assert.IsType<UiText>(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);
} }
/// <summary>SF-7's own additional test — the monarch IS the viewer: /// <summary>SF-7's own additional test — the monarch IS the viewer:

View file

@ -475,6 +475,240 @@ public sealed class SocialPanelLiveMountProbeTests
DumpInfoTree(c, depth + 1, maxDepth); DumpInfoTree(c, depth + 1, maxDepth);
} }
/// <summary>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.</summary>
[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<UiTabPanel>(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<RuntimeFellowMemberSnapshot>
{
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) private static int CountDescendants(UiElement root, uint id)
{ {
int count = root.DatElementId == id ? 1 : 0; int count = root.DatElementId == id ? 1 : 0;