using System; using System.Numerics; using AcDream.Core.Social; namespace AcDream.App.UI.Layout; /// /// Campaign FA slice FA3, D1: the Friends page's read-only roster — names /// only, bound directly to the J4.1 owner /// (RuntimeCommunicationState.Friends). Rebuilds on /// change; polled every frame from /// WHILE THE PANEL IS VISIBLE /// (cheap — a single comparison when nothing /// changed; see the scrollbar/rebuild-discipline paragraph below for the /// fix-round visibility gate). /// /// /// Actions LIVE as of 2026-08-13 (AD-79 retired for this page). /// Add (name box → 0x0018), Remove (selected row → 0x0017), /// and Appear Offline (CharacterOption 0x27 via the immediate /// 0x0005 auto-save) are wired through — the /// wire beneath existed end-to-end all along /// (docs/research/2026-08-13-social-wire-completion.md §4). Send Tell /// (0x10000516) remains the one inert button — not in the /// 2026-08-13 order and needing the chat-tell seam; tracked in AD-79's /// remainder. /// /// /// /// Row template 0x2100005D/0x10000519 (FA3 live-mount probe); /// 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). /// /// /// /// Scrollbar (fix-round blast MF-1; FA4 carry-forward 3). The /// ListBox 0x10000517 authors a direct sibling scrollbar, /// 0x10000518 — wired to the /// SAME way every other UiTemplateListBox consumer wires its own /// scrollbar. Resolved via the built /// (dat property 0x72, populated by ) /// rather than the hardcoded literal a prior revision of this file used — /// same "prefer the DAT field over a duplicated constant" fix /// ConfigOptionsPageController.Bind already applied (OP6 review N2, /// feedback_prefer_dat_field_over_geometry), 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 /// ( has no wheel handler) and is completely /// unreachable past the box's visible extent. /// /// /// /// Rebuild discipline (fix-round blast SF-2/SF-3). /// only advances after a successful rebuild — a /// transient resolver miss ( /// returning null) leaves the revision UNCONSUMED so the next /// retries instead of latching an empty list until the /// NEXT server-side change. also gates /// this controller's on the panel's own visibility (its /// IRetainedPanelController.OnShown/OnHidden hooks) — the /// per-row template resolve takes the shared DAT lock, so no rebuild work /// runs while the panel is closed. /// /// 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; /// 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). public sealed record Actions( Action AddFriend, Action RemoveFriend, Func CurrentAppearOffline, Action 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 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(); } /// The row template's LEFT name cell (template layout /// 0x2100005D root 0x10000519). Its Online/Offline /// 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 gmFriendsUI row shape. 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; } }