fix: friends list live Online/Offline status - authored row state

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>
This commit is contained in:
Erik 2026-08-14 10:57:57 +02:00
parent 1390f9477d
commit e449c75e9f
5 changed files with 150 additions and 3 deletions

View file

@ -796,6 +796,23 @@ public static class DatWidgetFactory
}
}
// Per-STATE authored strings (0x17 on the element's own states — the
// friends row's status cell authors 'Online'/'Offline' this way,
// with per-state colors). TrySetRetailState swaps the line when the
// incoming state authors one; see UiText.SetAuthoredStateStrings.
Dictionary<uint, string>? stateStrings = null;
foreach (var (stateId, state) in info.States)
{
if (stateId == UiStateInfo.DirectStateId
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo)
continue;
if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text)
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
}
if (stateStrings is not null)
t.SetAuthoredStateStrings(stateStrings);
return t;
}

View file

@ -192,6 +192,16 @@ public sealed class SocialFriendsPageController
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;
@ -203,15 +213,28 @@ public sealed class SocialFriendsPageController
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) { allRowsResolved = false; continue; }
if (friend.Id == _selectedFriendGuid) selectedStillPresent = true;
if (SocialPanelRowText.FindDeepest(row) is { } text)
// 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;
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
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).
text.OnClick = () => _selectedFriendGuid = guid;
nameText.OnClick = () => _selectedFriendGuid = guid;
nameText.TrySetRetailState(
friend.Online ? OnlineStateId : OfflineStateId);
}
else
{
allRowsResolved = false;
}
}
if (!selectedStillPresent) _selectedFriendGuid = 0u;

View file

@ -293,6 +293,15 @@ public sealed class UiText : UiElement, IUiDatStateful
public override string ActiveCursorStateName => _activeDatStateName;
private Dictionary<uint, string>? _authoredStateStrings;
/// <summary>Per-state authored strings (dat property 0x17 on the
/// element's OWN states, resolved at build by DatWidgetFactory).
/// <see cref="TrySetRetailState"/> swaps the displayed line when the
/// incoming state authors one — retail's state-cascade text swap.</summary>
internal void SetAuthoredStateStrings(Dictionary<uint, string> strings)
=> _authoredStateStrings = strings;
internal void ConfigureDatState(ElementInfo info)
{
_datInfo = info;
@ -356,6 +365,18 @@ public sealed class UiText : UiElement, IUiDatStateful
&& TryColor(color, out Vector4 resolvedColor))
DefaultColor = resolvedColor;
// Retail's state cascade also swaps the element's AUTHORED string
// when the incoming state carries its own 0x17 (the friends row's
// status cell: 'Online'/'Offline' with per-state colors). Resolved
// at build by DatWidgetFactory; single-line — no per-state multiline
// template exists in the authored set today. DefaultColor is read
// per call so THIS state's 0x1B (applied just above) colors it.
if (_authoredStateStrings is { } stateStrings
&& stateStrings.TryGetValue(stateId, out string? authoredLine))
{
LinesProvider = () => [new Line(authoredLine, DefaultColor)];
}
if (propagate && state?.PassToChildren == true)
{
foreach (UiElement child in Children)