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:
parent
ec2a7b0cce
commit
72ceddce2e
15 changed files with 1018 additions and 65 deletions
File diff suppressed because one or more lines are too long
452
docs/research/2026-08-13-social-wire-completion.md
Normal file
452
docs/research/2026-08-13-social-wire-completion.md
Normal 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 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<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
|
||||||
|
`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.
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 >= 9</c> (lane B §7.1).</summary>
|
/// <c>_currNum >= 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→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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue