diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index af6abb63..8d6391c5 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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? 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())[stateId] = text; + } + if (stateStrings is not null) + t.SetAuthoredStateStrings(stateStrings); + return t; } diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs index 38de1fb6..12606a90 100644 --- a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs @@ -192,6 +192,16 @@ public sealed class SocialFriendsPageController 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; @@ -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; diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index f2d2d897..0f7ba89f 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -293,6 +293,15 @@ public sealed class UiText : UiElement, IUiDatStateful public override string ActiveCursorStateName => _activeDatStateName; + private Dictionary? _authoredStateStrings; + + /// Per-state authored strings (dat property 0x17 on the + /// element's OWN states, resolved at build by DatWidgetFactory). + /// swaps the displayed line when the + /// incoming state authors one — retail's state-cascade text swap. + internal void SetAuthoredStateStrings(Dictionary 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) diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 232c9fa9..bf560ce3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -730,6 +730,47 @@ public class DatWidgetFactoryTests Assert.Equal("six", lines[1].Text); } + /// + /// 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. + /// + [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(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); + } + /// /// 2026-08-14 gate: the stateful-fill meter (gmPowerbarUI's shape in /// LayoutDesc 0x21000072) also absorbs its Type-12 caption child diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs index 531b6f35..0b5aa190 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs @@ -35,6 +35,51 @@ public sealed class PowerbarLayoutProbeTests Dump(dats, strings, CombatUiController.LayoutId, "combat-panel"); } + /// 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. + [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,