machine + UiText per-state string swap (user retail gate) The state side already worked (FriendsState replaces the full entry and bumps Revision on 0x0021 OnlineStatus updates; the parser reads full FriendData for every update type). The UI side had two gaps, exposed by the installed-DAT row-template probe (template layout 0x2100005D root 0x10000519): - The row authors TWO cells: the LEFT name text 0x1000051A whose Online (0x10000054) / Offline (0x10000055) PassToChildren states cascade into the RIGHT status grandchild 0x1000051F, which authors per-state 'Online'/'Offline' strings AND per-state colors (retail's green Online). The controller's FindDeepest binding wrote the NAME into the STATUS cell (the deepest text IS the status grandchild) and never flipped the state machine - so the status column never showed or updated anything. - UiText had no per-state authored-string swap: ApplyDatState switched sprite + color per state but never the 0x17 string. Ported now (second consumer of the mechanism after the powerbar caption): DatWidgetFactory pre-resolves each state's authored string; TrySetRetailState swaps the line, colored by the SAME state's authored 0x1B. SocialFriendsPageController now binds the name to its own cell and flips the authored Online/Offline state per friend on every Revision-driven rebuild - the cascade renders the status cell exactly as retail's gmFriendsUI does, green Online included. App suite 4,989/3 skips (new per-state swap conformance test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
245 lines
11 KiB
C#
245 lines
11 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Social;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign FA slice FA3, D1: the Friends page's read-only roster — names
|
|
/// only, bound directly to the J4.1 <see cref="FriendsState"/> owner
|
|
/// (<c>RuntimeCommunicationState.Friends</c>). Rebuilds on
|
|
/// <see cref="FriendsState.Revision"/> change; polled every frame from
|
|
/// <see cref="SocialPanelController.Tick"/> WHILE THE PANEL IS VISIBLE
|
|
/// (cheap — a single <see langword="long"/> comparison when nothing
|
|
/// changed; see the scrollbar/rebuild-discipline paragraph below for the
|
|
/// fix-round visibility gate).
|
|
///
|
|
/// <para>
|
|
/// <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>
|
|
/// Row template <c>0x2100005D</c>/<c>0x10000519</c> (FA3 live-mount probe);
|
|
/// <see cref="SocialPanelRowText.FindDeepest"/> resolves the row's own
|
|
/// name-text leaf — see that class's doc for why the deepest match is used
|
|
/// (gmFriendsUI is outside this campaign's decompiled scope).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Scrollbar (fix-round blast MF-1; FA4 carry-forward 3).</b> The
|
|
/// ListBox <c>0x10000517</c> authors a direct sibling scrollbar,
|
|
/// <c>0x10000518</c> — wired to <see cref="UiTemplateListBox.Scroll"/> the
|
|
/// SAME way every other <c>UiTemplateListBox</c> consumer wires its own
|
|
/// scrollbar. Resolved via the built <see cref="UiTemplateListBox.ScrollbarElementId"/>
|
|
/// (dat property <c>0x72</c>, populated by <see cref="ElementReader"/>)
|
|
/// rather than the hardcoded literal a prior revision of this file used —
|
|
/// same "prefer the DAT field over a duplicated constant" fix
|
|
/// <c>ConfigOptionsPageController.Bind</c> already applied (OP6 review N2,
|
|
/// <c>feedback_prefer_dat_field_over_geometry</c>), scoped to this page's
|
|
/// own subtree so a flat lookup can't grab a same-id sibling on another
|
|
/// page. Without it the list has NO wheel fallback
|
|
/// (<see cref="UiScrollablePanel"/> has no wheel handler) and is completely
|
|
/// unreachable past the box's visible extent.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Rebuild discipline (fix-round blast SF-2/SF-3).</b> <see cref="Refresh"/>
|
|
/// only advances <see cref="_lastRevision"/> after a successful rebuild — a
|
|
/// transient resolver miss (<see cref="UiTemplateListBox.AddItemFromTemplateList"/>
|
|
/// returning null) leaves the revision UNCONSUMED so the next
|
|
/// <see cref="Tick"/> retries instead of latching an empty list until the
|
|
/// NEXT server-side change. <see cref="SocialPanelController"/> also gates
|
|
/// this controller's <see cref="Tick"/> on the panel's own visibility (its
|
|
/// <c>IRetainedPanelController.OnShown</c>/<c>OnHidden</c> hooks) — the
|
|
/// per-row template resolve takes the shared DAT lock, so no rebuild work
|
|
/// runs while the panel is closed.
|
|
/// </para>
|
|
/// </summary>
|
|
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,
|
|
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,
|
|
Actions? actions = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pageRoot);
|
|
ArgumentNullException.ThrowIfNull(friends);
|
|
ArgumentNullException.ThrowIfNull(templateResolver);
|
|
|
|
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] SocialFriendsPageController: ListBox 0x{ListBoxId:X8} not "
|
|
+ "found — Friends 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] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
|
|
+ "not found — the Friends list will not scroll.");
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>The row template's LEFT name cell (template layout
|
|
/// 0x2100005D root 0x10000519). Its <c>Online</c>/<c>Offline</c>
|
|
/// PassToChildren states cascade into the RIGHT status grandchild
|
|
/// 0x1000051F, which authors the per-state 'Online'/'Offline' strings
|
|
/// and colors (retail's green Online) — installed-DAT probe 2026-08-14,
|
|
/// retail's <c>gmFriendsUI</c> row shape.</summary>
|
|
private const uint RowNameTextId = 0x1000051Au;
|
|
private const uint OnlineStateId = 0x10000054u;
|
|
private const uint OfflineStateId = 0x10000055u;
|
|
|
|
private void Refresh()
|
|
{
|
|
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;
|
|
// 2026-08-14 friends gate ("detects online/offline but the list
|
|
// does not update"): the old FindDeepest binding wrote the NAME
|
|
// into the STATUS cell (the deepest text IS the status
|
|
// grandchild) and never flipped the authored state machine, so
|
|
// the status column never showed anything. Bind the name to its
|
|
// own cell and flip the authored Online/Offline state — the
|
|
// cascade swaps the status cell's authored string + color.
|
|
if (UiElement.FindDescendant(row, RowNameTextId) is UiText nameText)
|
|
{
|
|
string name = friend.Name;
|
|
uint guid = friend.Id;
|
|
nameText.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).
|
|
nameText.OnClick = () => _selectedFriendGuid = guid;
|
|
nameText.TrySetRetailState(
|
|
friend.Online ? OnlineStateId : OfflineStateId);
|
|
}
|
|
else
|
|
{
|
|
allRowsResolved = false;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|