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:
parent
1390f9477d
commit
e449c75e9f
5 changed files with 150 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -730,6 +730,47 @@ public class DatWidgetFactoryTests
|
|||
Assert.Equal("six", lines[1].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-14 friends gate: a Type-12 element whose OWN states author
|
||||
/// 0x17 strings (the friends row's status cell: 'Online'/'Offline' with
|
||||
/// per-state colors) swaps its displayed line on TrySetRetailState —
|
||||
/// retail's state-cascade text swap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildText_PerStateAuthoredStrings_SwapWithRetailState()
|
||||
{
|
||||
var info = new ElementInfo { Type = 12, Width = 270, Height = 24 };
|
||||
var online = new UiStateInfo { Id = 0x10000054u, Name = "Online" };
|
||||
online.Properties.Values[0x17u] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.StringInfo,
|
||||
// (Token, StringId, TableId, ...) — StringId 2 = 'Online'.
|
||||
StringInfoValue = new UiStringInfoValue(0, 2, 1, 0, 1, 0),
|
||||
};
|
||||
online.Properties.Values[0x1Bu] = Color(0, 255, 0);
|
||||
var offline = new UiStateInfo { Id = 0x10000055u, Name = "Offline" };
|
||||
offline.Properties.Values[0x17u] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.StringInfo,
|
||||
StringInfoValue = new UiStringInfoValue(0, 3, 1, 0, 1, 0),
|
||||
};
|
||||
info.States[0x10000054u] = online;
|
||||
info.States[0x10000055u] = offline;
|
||||
|
||||
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
||||
info, NoTex, null,
|
||||
stringResolve: si => si.StringId == 2u ? "Online" : "Offline"));
|
||||
|
||||
Assert.True(text.TrySetRetailState(0x10000054u));
|
||||
var line = Assert.Single(text.LinesProvider!());
|
||||
Assert.Equal("Online", line.Text);
|
||||
Assert.Equal(1f, line.Color.Y, 3); // authored green rides the state
|
||||
|
||||
Assert.True(text.TrySetRetailState(0x10000055u));
|
||||
line = Assert.Single(text.LinesProvider!());
|
||||
Assert.Equal("Offline", line.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-14 gate: the stateful-fill meter (gmPowerbarUI's shape in
|
||||
/// LayoutDesc 0x21000072) also absorbs its Type-12 caption child
|
||||
|
|
|
|||
|
|
@ -35,6 +35,51 @@ public sealed class PowerbarLayoutProbeTests
|
|||
Dump(dats, strings, CombatUiController.LayoutId, "combat-panel");
|
||||
}
|
||||
|
||||
/// <summary>2026-08-14 friends gate: dump the friends row TEMPLATE tree
|
||||
/// (ListBox 0x10000517's authored template list) so the status cell binds
|
||||
/// the exact authored element instead of a guess.</summary>
|
||||
[Fact]
|
||||
public void ProbeFriendsRowTemplate()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "1")
|
||||
return;
|
||||
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var strings = new DatStringResolver(dats);
|
||||
|
||||
ElementInfo? root = LayoutImporter.ImportInfos(
|
||||
dats, SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId);
|
||||
Assert.NotNull(root);
|
||||
ElementInfo? listBox = FindById(root!, 0x10000517u);
|
||||
Assert.NotNull(listBox);
|
||||
Console.WriteLine(
|
||||
$"[pbprobe] friends listbox 0x10000517 templates={listBox!.TemplateList.Count}");
|
||||
foreach (UiTemplateListEntry template in listBox.TemplateList)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[pbprobe] template layout=0x{template.TemplateLayoutId:X8} "
|
||||
+ $"element=0x{template.TemplateElementId:X8}");
|
||||
ElementInfo? row = LayoutImporter.ImportInfos(
|
||||
dats, template.TemplateLayoutId, template.TemplateElementId);
|
||||
if (row is not null)
|
||||
DumpElement(strings, row, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElementInfo? FindById(ElementInfo element, uint id)
|
||||
{
|
||||
if (element.Id == id) return element;
|
||||
foreach (ElementInfo child in element.Children)
|
||||
if (FindById(child, id) is { } found)
|
||||
return found;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Dump(
|
||||
DatCollection dats,
|
||||
DatStringResolver strings,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue