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

View file

@ -148,7 +148,14 @@ internal sealed class SelectionInteractionController
public void PickAndStoreSelection(bool useImmediately)
{
uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive);
// 2026-08-13 gate fix ("when I click on my own char it should select
// myself"): retail has NO self-exclusion anywhere in this chain —
// CPhysicsPart::Draw @0x0050D823 arms every physobj for the pick and
// RecvNotice_SmartBoxObjectFound @0x004E5BAE selects unconditionally
// (docs/research/2026-08-13-social-wire-completion.md §9/§10). The
// old includeSelf gate (target-mode-only) was an unregistered
// divergence, now removed.
uint? picked = _query.PickAtCursor(includeSelf: true);
if (picked is not uint guid)
{
if (!_items.IsAnyTargetModeActive)
@ -196,7 +203,9 @@ internal sealed class SelectionInteractionController
/// </summary>
public void PickSelectAndExamine()
{
uint? picked = _query.PickAtCursor(includeSelf: false);
// Same self-inclusion as PickAndStoreSelection above — retail
// right-click examines yourself too.
uint? picked = _query.PickAtCursor(includeSelf: true);
if (picked is not uint guid)
return;

View file

@ -702,7 +702,19 @@ public static class DatWidgetFactory
t.OutlineColor = info.OutlineColor.Value;
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)];
{
// 2026-08-13 social gate: authored strings can carry embedded
// newlines (the fellowship empty-state is three sentences over
// '\n's). A single Line renders them as one clipped run — split
// into one Line per authored line, exactly as retail's multiline
// UIElement_Text draws them. The provider re-reads DefaultColor
// per call (NOT captured eagerly) so state-driven font-color
// changes keep tracking, the same live-color contract the
// single-line provider always had.
string[] parts = [.. authored.Split('\n').Select(static p => p.TrimEnd('\r'))];
t.LinesProvider = () =>
[.. parts.Select(p => new UiText.Line(p, t.DefaultColor))];
}
return t;
}

View file

@ -421,10 +421,16 @@ public sealed class SocialAllegiancePageController
if (tooltip is not null)
_ignoreRequestsCheckbox.TooltipText = tooltip;
// 2026-08-13 gate fix ("can't press Ignore Allegiance requests —
// always checked"): identical double-toggle to the fellowship page's
// checkboxes — the authored ToggleBehavior self-flip made the old
// `!Selected` read the flipped value and write the ORIGINAL back.
// Same CH6a/b mirror discipline: suppress the self-flip, derive the
// next value from the STORE, per-tick seeding mirrors the outcome.
_ignoreRequestsCheckbox.SuppressSelfToggle = true;
_ignoreRequestsCheckbox.OnClick = () =>
{
bool next = !_ignoreRequestsCheckbox.Selected;
_ignoreRequestsCheckbox.Selected = next;
bool next = !_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests);
_bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next);
};
}
@ -518,8 +524,12 @@ public sealed class SocialAllegiancePageController
private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot)
{
SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor);
SetLine(_selfFollowers, ref _lastSelfFollowers, snapshot.TotalVassals.ToString(), TextColor);
SetLine(_selfRank, ref _lastSelfRank, snapshot.Rank.ToString(), TextColor);
// 2026-08-13 gate ("On top it just says 0 then 0"): the bare numbers
// gain their labels in the user-specified format — the full retail
// StringInfo composition for these fields remains AD-85's gap; this
// is its user-directed partial fill.
SetLine(_selfFollowers, ref _lastSelfFollowers, $"Followers: {snapshot.TotalVassals}", TextColor);
SetLine(_selfRank, ref _lastSelfRank, $"Rank: [{snapshot.Rank}]", TextColor);
}
// gmAllegianceUI::UpdateMonarchData @0x00491B40 — see class doc.
@ -541,10 +551,11 @@ public sealed class SocialAllegiancePageController
RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value;
SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor);
// 2026-08-13 gate: same label treatment as the self block's followers.
SetLine(
_monarchFollowers,
ref _lastMonarchFollowers,
(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u).ToString(),
$"Followers: {(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u)}",
TextColor);
_monarchField.Enabled = monarchData.IsLoggedIn;

View file

@ -58,11 +58,11 @@ namespace AcDream.App.UI.Layout;
/// <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 /
/// three meter+text pairs, nothing else) — retail may simply not mark
/// leadership in the row at all. Lacking a decompiled anchor either way,
/// this controller tints the leader's name text a distinct gold
/// (<see cref="LeaderNameColor"/>) as a minimal, clearly-adaptive visual
/// cue rather than inventing a DAT mechanism that was not found — register
/// row AD-82.
/// leadership in the row at all. The earlier gold leader tint and blue
/// selection tint this controller invented for that gap were RETIRED at the
/// 2026-08-13 gate by user direction: names render white, always
/// (<see cref="MemberNameColor"/>), and selection feedback is the in-game
/// selection itself — register row AD-82 updated accordingly.
/// </para>
///
/// <para>
@ -126,18 +126,12 @@ public sealed class SocialFellowshipPageController
/// <c>_currNum &gt;= 9</c> (lane B §7.1).</summary>
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;
/// <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
/// reassigned when the fellowship name actually changes (fix-round SF-1,
/// the same zero-allocation-while-idle discipline
@ -455,10 +449,19 @@ public sealed class SocialFellowshipPageController
if (tooltip is not null)
checkbox.TooltipText = tooltip;
// 2026-08-13 gate fix ("I can't change any options"): these authored
// checkboxes carry DAT ToggleBehavior, so the button SELF-FLIPS
// Selected at MouseUp — the old `!checkbox.Selected` here then read
// the ALREADY-FLIPPED value and wrote the ORIGINAL back, visually
// snapping every click to where it started (probe-proven:
// ProbeSocialClickRouting recorded (id, oldValue)). Same CH6a/b
// mirror discipline as the chat indicators: suppress the blind
// self-flip, derive the next value from the STORE, and let the
// per-tick seeding mirror the outcome back onto Selected.
checkbox.SuppressSelfToggle = true;
checkbox.OnClick = () =>
{
bool next = !checkbox.Selected;
checkbox.Selected = next;
bool next = !_bindings.CurrentCharacterOption(id);
_bindings.SetCharacterOption(id, next);
};
}
@ -725,6 +728,15 @@ public sealed class SocialFellowshipPageController
uint guid = member.Guid;
nameText.OnClick = () => SelectFellow(guid);
}
// 2026-08-13 gate fix ("I only get the move window cursor"):
// give the STATS text the same click target so most of the row's
// width selects the fellow (UiText.OnClick now clears the
// display-text ClickThrough default — see UiText.OnClick's doc).
if (widgets.Stats is { } statsText)
{
uint guid = member.Guid;
statsText.OnClick = () => SelectFellow(guid);
}
}
foreach (RuntimeFellowMemberSnapshot member in members)
@ -738,13 +750,11 @@ public sealed class SocialFellowshipPageController
if (widgets.Name is { } nameText)
{
string name = member.Name;
// MUST-FIX 4 (fix round): selection takes priority over the
// leader tint when a row is both — it's the more immediate,
// user-driven state (see SelectedNameColor's own doc).
Vector4 color = _selectedFellowGuid == member.Guid
? SelectedNameColor
: snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
nameText.LinesProvider = () => [new UiText.Line(name, color)];
// 2026-08-13 gate (user-directed): fellow names render WHITE,
// always — the AD-82 invented leader/selection tints are retired;
// selection feedback is the in-game selection itself
// (SelectFellow drives the world selection ring).
nameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
}
if (widgets.Stats is { } statsText)

View file

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

View file

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

View file

@ -53,21 +53,49 @@ namespace AcDream.App.UI.Layout;
public sealed class SocialSquelchPageController
{
private const uint ListBoxId = 0x1000053Eu;
// 2026-08-13 gate (AD-79 retirement): probe-verified roles
// (ProbeSocialClickRouting label dump) — the name box, the Remove button,
// and the two add buttons ("Squelch Character" / "Squelch Account").
private const uint NameFieldId = 0x10000540u;
private const uint RemoveButtonId = 0x10000547u;
private const uint SquelchCharacterButtonId = 0x1000054Bu;
private const uint SquelchAccountButtonId = 0x1000054Cu;
/// <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 SquelchState _squelch;
private readonly Actions? _actions;
private readonly UiField? _nameField;
private long _lastRevision = long.MinValue;
private (uint Guid, string Name, bool IsAccount)? _selected;
private SocialSquelchPageController(UiTemplateListBox listBox, SquelchState squelch)
private SocialSquelchPageController(
UiTemplateListBox listBox,
SquelchState squelch,
Actions? actions,
UiField? nameField)
{
_listBox = listBox;
_squelch = squelch;
_actions = actions;
_nameField = nameField;
}
public static SocialSquelchPageController? Bind(
UiElement pageRoot,
SquelchState squelch,
Func<uint, uint, UiElement?> templateResolver)
Func<uint, uint, UiElement?> templateResolver,
Actions? actions = null)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(squelch);
@ -93,11 +121,46 @@ public sealed class SocialSquelchPageController
$"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Squelch list will not scroll.");
var controller = new SocialSquelchPageController(listBox, squelch);
var nameField = UiElement.FindDescendant(pageRoot, NameFieldId) as UiField;
var controller = new SocialSquelchPageController(listBox, squelch, actions, nameField);
controller.WireActions(pageRoot);
controller.Refresh();
return controller;
}
private void WireActions(UiElement pageRoot)
{
if (_actions is not { } actions) return; // fixture callers stay inert
if (UiElement.FindDescendant(pageRoot, SquelchCharacterButtonId) is UiButton addCharacter)
addCharacter.OnClick = () =>
{
string name = _nameField?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name)) return;
actions.SquelchCharacter(name);
_nameField?.SetText(string.Empty);
};
if (UiElement.FindDescendant(pageRoot, SquelchAccountButtonId) is UiButton addAccount)
addAccount.OnClick = () =>
{
string name = _nameField?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name)) return;
actions.SquelchAccount(name);
_nameField?.SetText(string.Empty);
};
if (UiElement.FindDescendant(pageRoot, RemoveButtonId) is UiButton remove)
remove.OnClick = () =>
{
if (_selected is not { } selected) return;
if (selected.IsAccount)
actions.RemoveAccountSquelch(selected.Name);
else
actions.RemoveCharacterSquelch(selected.Guid, selected.Name);
};
}
public void Tick()
{
long revision = _squelch.Revision;
@ -112,22 +175,38 @@ public sealed class SocialSquelchPageController
SquelchDatabase database = _squelch.Snapshot();
bool allRowsResolved = true;
foreach (SquelchInfo character in database.Characters.Values)
allRowsResolved &= AddRow(character.Name);
bool selectedStillPresent = false;
foreach ((uint guid, SquelchInfo character) in database.Characters)
{
allRowsResolved &= AddRow(character.Name, guid, isAccount: false);
if (_selected is { IsAccount: false } s && s.Guid == guid)
selectedStillPresent = true;
}
foreach (string accountName in database.Accounts.Keys)
allRowsResolved &= AddRow(accountName);
{
allRowsResolved &= AddRow(accountName, 0u, isAccount: true);
if (_selected is { IsAccount: true } s && s.Name == accountName)
selectedStillPresent = true;
}
if (!selectedStillPresent) _selected = null;
// SF-3: only latch the revision once the rebuild actually reflects it —
// a resolver miss must not silently swallow a future revision bump.
if (allRowsResolved) _lastRevision = revision;
}
private bool AddRow(string name)
private bool AddRow(string name, uint guid, bool isAccount)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) return false;
if (SocialPanelRowText.FindDeepest(row) is { } text)
{
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
// 2026-08-13 gate: row click selects the entry the Remove button
// acts on (UiText.OnClick clears the display-text ClickThrough
// default).
text.OnClick = () => _selected = (guid, name, isAccount);
}
return true;
}
}

View file

@ -2861,7 +2861,33 @@ public sealed class RetailUiRuntime : IDisposable
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)),
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver);
TemplateResolver: TemplateResolver,
// 2026-08-13 (AD-79 retirement): the wire beneath every one of
// these commands shipped with J4.1/FA1 — builders, WorldSession
// sends, router registrations, inbound parsers (see
// docs/research/2026-08-13-social-wire-completion.md §4); the
// panel publishes onto the SAME bus every other surface uses.
FriendsActions: new Layout.SocialFriendsPageController.Actions(
AddFriend: name => _bindings.Options.CommandBus().Publish(
new AddFriendRuntimeCmd(name)),
RemoveFriend: guid => _bindings.Options.CommandBus().Publish(
new RemoveFriendRuntimeCmd(guid)),
CurrentAppearOffline: () => _bindings.Options.CurrentCharacterOption(
(uint)CharacterOptionId.AppearOffline),
SetAppearOffline: value => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.AppearOffline, value))),
SquelchActions: new Layout.SocialSquelchPageController.Actions(
// Character scope, guid 0 (ACE name-looks-up), AllChannels —
// research §1.5's exact retail body.
SquelchCharacter: name => _bindings.Options.CommandBus().Publish(
new ModifyCharacterSquelchRuntimeCmd(true, 0u, name, 1u)),
SquelchAccount: name => _bindings.Options.CommandBus().Publish(
new ModifyAccountSquelchRuntimeCmd(true, name)),
RemoveCharacterSquelch: (guid, name) => _bindings.Options.CommandBus().Publish(
new ModifyCharacterSquelchRuntimeCmd(false, guid, name, 1u)),
RemoveAccountSquelch: name => _bindings.Options.CommandBus().Publish(
new ModifyAccountSquelchRuntimeCmd(false, name))));
// A second DatLock scope (MountOptionsPanel's own precedent, above):
// SocialFellowshipPageController.Bind resolves the Open/Close

View file

@ -22,8 +22,25 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiText : UiElement, IUiDatStateful
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
/// <summary>Optional base-element click notice used by authored text tabs.
/// 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
=> OnClick is not null || WheelScrollEnabled || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>