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>
212 lines
8.6 KiB
C#
212 lines
8.6 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Social;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign FA slice FA3, D1: the Squelch page's read-only roster — names
|
|
/// only, bound directly to the J4.1 <see cref="SquelchState"/> owner
|
|
/// (<c>RuntimeCommunicationState.Squelch</c>). Lists both squelched
|
|
/// characters (<see cref="SquelchDatabase.Characters"/>, keyed by the
|
|
/// character's own display name) and squelched accounts
|
|
/// (<see cref="SquelchDatabase.Accounts"/>, whose dictionary KEY is the
|
|
/// account name itself). Rebuilds on <see cref="SquelchState.Revision"/>
|
|
/// change; polled every frame from <see cref="SocialPanelController.Tick"/>
|
|
/// WHILE THE PANEL IS VISIBLE (see the rebuild-discipline paragraph below).
|
|
///
|
|
/// <para>
|
|
/// <b>D1 — inert actions.</b> Same disposition as
|
|
/// <see cref="SocialFriendsPageController"/>: the three buttons
|
|
/// (<c>0x10000547</c>/<c>0x1000054B</c>/<c>0x1000054C</c>) are left
|
|
/// AUTHORED and CLICKABLE with no handler. See register row AD-79
|
|
/// (<c>docs/architecture/retail-divergence-register.md</c>), which covers
|
|
/// the whole D1 Friends/Squelch inert-actions scope.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Row template <c>0x21000060</c>/<c>0x10000541</c> (FA3 live-mount probe);
|
|
/// see <see cref="SocialPanelRowText"/> for the name-text resolution note
|
|
/// (<c>gmSquelchUI</c> is outside this campaign's decompiled scope).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
|
|
/// ListBox <c>0x1000053E</c> authors a direct sibling scrollbar,
|
|
/// <c>0x10000543</c> — resolved via the built
|
|
/// <see cref="UiTemplateListBox.ScrollbarElementId"/> and wired to
|
|
/// <see cref="UiTemplateListBox.Scroll"/> the same way
|
|
/// <see cref="SocialFriendsPageController"/> wires its own (see that
|
|
/// class's doc for the full rationale, including why there is no wheel
|
|
/// fallback and why the authored field replaces a hardcoded literal).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Rebuild discipline (fix-round blast SF-2/SF-3).</b> Same discipline as
|
|
/// <see cref="SocialFriendsPageController"/>: <see cref="Refresh"/> only
|
|
/// advances <see cref="_lastRevision"/> after every row resolves, and
|
|
/// <see cref="SocialPanelController"/> gates <see cref="Tick"/> on the
|
|
/// panel's own visibility so no DAT-locked rebuild work runs while the
|
|
/// panel is closed.
|
|
/// </para>
|
|
/// </summary>
|
|
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,
|
|
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,
|
|
Actions? actions = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pageRoot);
|
|
ArgumentNullException.ThrowIfNull(squelch);
|
|
ArgumentNullException.ThrowIfNull(templateResolver);
|
|
|
|
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] SocialSquelchPageController: ListBox 0x{ListBoxId:X8} not "
|
|
+ "found — Squelch page will not populate.");
|
|
return null;
|
|
}
|
|
listBox.TemplateResolver = templateResolver;
|
|
|
|
uint scrollbarElementId = listBox.ScrollbarElementId;
|
|
UiElement? scrollbarElement = scrollbarElementId == 0
|
|
? null
|
|
: UiElement.FindDescendant(pageRoot, scrollbarElementId);
|
|
if (scrollbarElement is UiScrollbar scrollbar)
|
|
scrollbar.Model = listBox.Scroll;
|
|
else
|
|
Console.WriteLine(
|
|
$"[D.2b] SocialSquelchPageController: scrollbar 0x{scrollbarElementId:X8} "
|
|
+ "not found — the Squelch list will not scroll.");
|
|
|
|
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;
|
|
if (revision == _lastRevision) return;
|
|
Refresh();
|
|
}
|
|
|
|
private void Refresh()
|
|
{
|
|
long revision = _squelch.Revision;
|
|
_listBox.Flush();
|
|
SquelchDatabase database = _squelch.Snapshot();
|
|
|
|
bool allRowsResolved = true;
|
|
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, 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, 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;
|
|
}
|
|
}
|