using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Runtime; using DatReaderWriter; using DatReaderWriter.Options; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign FA slice FA3, item 7: extends the ACDREAM_PROBE_LIVE_MOUNT pattern /// (the #372/#375/#378 lesson — fixture-green alone is NOT acceptance for /// anything mounted) to the four-tab social panel. Asserts the PRODUCTION /// mount path resolves: the tab host as with its /// 4-entry table, all four page elements, the fellowship empty/full frame /// pair, the allegiance signature elements, and non-empty captions on the /// tab buttons (the #375 resolver class). Also dumps the raw tab table so /// its button→page pairing + default entry can be recorded — coordinator /// addendum, docs/research/2026-08-11-fa-panel-structure.md §10. /// public sealed class SocialPanelLiveMountProbeTests { [Fact] public void ProbeLiveMountShapes() { if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "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); ImportedLayout layout = LayoutImporter.Build( root!, _ => (1u, 8, 8), null, null, strings.Resolve); Console.WriteLine( $"[socialprobe] root id=0x{root!.Id:X8} type={root.Type} " + $"({root.X},{root.Y} {root.Width}x{root.Height}) children={root.Children.Count} " + $"restorePrevious={(root.TryGetEffectiveBool(RetailPanelUiController.RestorePreviousPropertyId, out bool rp) && rp)}"); foreach (ElementInfo c in root.Children) { string p12 = c.TryGetEffectiveProperty(0x12u, out var v12) ? $"0x{v12.UnsignedValue:X8}" : "ABSENT"; string p57 = c.TryGetEffectiveProperty(0x57u, out var v57) ? $"{v57.Kind}=0x{v57.UnsignedValue:X8}" : "ABSENT"; Console.WriteLine( $"[socialprobe] root-child 0x{c.Id:X8} type=0x{c.Type:X8} ({c.X},{c.Y} {c.Width}x{c.Height}) " + $"kids={c.Children.Count} P0x12={p12} P0x57={p57}"); } { string rootP57 = root.TryGetEffectiveProperty(0x57u, out var rv57) ? $"{rv57.Kind}=0x{rv57.UnsignedValue:X8}" : "ABSENT"; Console.WriteLine($"[socialprobe] root P0x57={rootP57}"); } UiTabPanel tabs = Assert.IsType(layout.Root); Console.WriteLine($"[socialprobe] tab table entries={tabs.Tabs.Count}"); foreach (UiTabTableEntry t in tabs.Tabs) Console.WriteLine( $"[socialprobe] button=0x{t.ButtonElementId:X8} page=0x{t.PageElementId:X8} default={t.IsDefault}"); Assert.True(tabs.Tabs.Count >= 4); // Production mount: activate behavior exactly like SocialPanelController.ActivateTabs. tabs.ActivateTabBehavior(); Assert.Empty(tabs.UnresolvedEntries); // Fix-round mechanism SF-3: page exclusivity was activated but never // asserted — the #372 class ("the panel mounts but the pages are // wrong/blank") deserves a real assertion, not just a hope. bool sawVisiblePage = false; string? visiblePageName = null; foreach ((uint pageId, string name) in new[] { (0x10000513u, "Friends"), (0x10000291u, "Allegiance"), (0x10000292u, "Fellowship"), (0x1000054Au, "Squelch"), }) { UiElement? page = UiElement.FindDescendant(tabs, pageId); Console.WriteLine( $"[socialprobe] page {name} 0x{pageId:X8} -> {(page is null ? "MISSING" : page.GetType().Name)} " + $"Visible={page?.Visible}"); Assert.NotNull(page); if (page!.Visible) { Assert.False( sawVisiblePage, $"page exclusivity violated: both '{visiblePageName}' and '{name}' report Visible=true after ActivateTabBehavior()"); sawVisiblePage = true; visiblePageName = name; } } Assert.True(sawVisiblePage, "no page reports Visible=true after ActivateTabBehavior()"); Assert.Equal("Allegiance", visiblePageName); // Fellowship empty/full frame pair. foreach ((uint id, string name) in new[] { (0x1000026Bu, "NotInAFellowshipFrame"), (0x10000275u, "InAFellowshipFrame"), }) { UiElement? el = UiElement.FindDescendant(tabs, id); Console.WriteLine($"[socialprobe] fellowship frame {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}"); Assert.NotNull(el); } // Campaign FA slice FA4, item 8: the fellowship page's own control // inventory against the PRODUCTION mount (not a fixture) — the // name-entry field must build as UiField (proving the DAT authors // Editable=1, SocialFellowshipPageController's load-bearing // assumption), and every button/checkbox must resolve as UiButton. UiElement? nameField = UiElement.FindDescendant(tabs, 0x1000026Fu); Console.WriteLine( $"[socialprobe] fellowship name-entry field 0x1000026F -> {(nameField is null ? "MISSING" : nameField.GetType().Name)}"); Assert.IsType(nameField); foreach ((uint id, string name) in new[] { (0x10000274u, "CreateFellowshipButton"), (0x1000027Bu, "FellowLeaderButton"), (0x1000027Cu, "FellowQuitButton"), (0x1000027Du, "FellowOpenButton"), (0x1000027Eu, "FellowRecruitButton"), (0x1000027Fu, "FellowDismissButton"), (0x10000280u, "FellowDisbandButton"), (0x10000270u, "IgnoreFellowshipRequestsCheckbox"), (0x10000271u, "FellowshipAutoAcceptRequestsCheckbox"), (0x10000272u, "FellowshipShareXPCheckbox"), (0x10000273u, "FellowshipShareLootCheckbox"), }) { UiElement? el = UiElement.FindDescendant(tabs, id); Console.WriteLine($"[socialprobe] fellowship control {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}"); Assert.IsType(el); } // The fellowship ListBox's own authored template pair (lane A U10 — // read P0x64 on 0x10000279) and its scrollbar (P0x72). UiElement? fellowshipListBoxEl = UiElement.FindDescendant(tabs, 0x10000279u); UiTemplateListBox fellowshipListBox = Assert.IsType(fellowshipListBoxEl); Console.WriteLine( $"[socialprobe] fellowship ListBox 0x10000279 templates={fellowshipListBox.Templates.Count} " + $"scrollbar=0x{fellowshipListBox.ScrollbarElementId:X8}"); Assert.NotEmpty(fellowshipListBox.Templates); UiTemplateListEntry fellowRowTemplate = fellowshipListBox.Templates[0]; // Production-resolver build of the row template (FA4 carry-forward // 2): the SAME RowTemplateResolver shape MountSocialPanel uses in // production — Import then Build against the LIVE dats, not a // fixture — proving the 8-part row (lane A §3.1) actually resolves // end-to-end, not merely that the id pair is authored. var rowTemplates = new RowTemplateResolver( (layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId), info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root); UiElement? fellowRow = rowTemplates.Resolve( fellowRowTemplate.TemplateLayoutId, fellowRowTemplate.TemplateElementId); Console.WriteLine( $"[socialprobe] fellowship row template 0x{fellowRowTemplate.TemplateLayoutId:X8}/" + $"0x{fellowRowTemplate.TemplateElementId:X8} -> {(fellowRow is null ? "IMPORT NULL" : fellowRow.GetType().Name)}"); Assert.NotNull(fellowRow); foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[] { (0x10000283u, "FellowName", typeof(UiText)), (0x10000284u, "FellowStats", typeof(UiText)), (0x10000285u, "HealthMeter", typeof(UiMeter)), (0x10000287u, "StaminaMeter", typeof(UiMeter)), (0x10000289u, "ManaMeter", typeof(UiMeter)), }) { UiElement? el = UiElement.FindDescendant(fellowRow!, id); Console.WriteLine($"[socialprobe] fellowship row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}"); Assert.IsType(expectedType, el); } // Checkbox labels/tooltips (0x23000003) and the Open/Close caption // pair (0x23000001) — the two string tables SocialFellowshipPageController // resolves at Bind time (lane A §4.3). Fix-round SF-6: these were // previously PRINTED but never asserted even though the ledger's // live-DAT paragraph cited them as verified — the same finding // FA3's own mechanism SF-3 raised for a different table. foreach (string retailName in new[] { "IgnoreFellowshipRequests", "FellowshipAutoAcceptRequests", "FellowshipShareXP", "FellowshipShareLoot", }) { string? label = strings.Resolve(0x23000003u, DatStringResolver.ComputeHash($"ID_PlayerOption_{retailName}")); Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_{retailName} -> '{label}'"); Assert.False( string.IsNullOrEmpty(label), $"checkbox label ID_PlayerOption_{retailName} (table 0x23000003) did not resolve."); } string? openLabel = strings.Resolve( 0x23000001u, DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText")); string? closeLabel = strings.Resolve( 0x23000001u, DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText")); Console.WriteLine($"[socialprobe] fellowship caption ID_Fellowship_OpenFellowshipButtonText -> '{openLabel}'"); Console.WriteLine($"[socialprobe] fellowship caption ID_Fellowship_CloseFellowshipButtonText -> '{closeLabel}'"); Assert.Equal("Open", openLabel); Assert.Equal("Close", closeLabel); // Full end-to-end Bind against the PRODUCTION mount (the same // resolver/scrollbar/frame wiring MountSocialPanel does) — proves // Bind() finds every element it needs with zero "not found" // console warnings, not just that the individual ids resolve in // isolation above. UiElement? fellowshipPageForBind = UiElement.FindDescendant(tabs, 0x10000292u); Assert.NotNull(fellowshipPageForBind); var originalOut = Console.Out; var capture = new StringWriter(); Console.SetOut(capture); SocialFellowshipPageController? fellowshipController; try { fellowshipController = SocialFellowshipPageController.Bind( fellowshipPageForBind!, new SocialFellowshipPageController.Bindings( Snapshot: () => new RuntimeFellowshipSnapshot(), Members: () => [], TemplateResolver: rowTemplates.Resolve, Create: (_, _) => default, Recruit: _ => default, Dismiss: _ => default, Quit: _ => default, AssignLeader: _ => default, SetOpen: _ => default, SetPanelOpen: _ => default, Selection: new AcDream.Core.Selection.SelectionState(), LocalPlayerGuid: () => 0u, CurrentCharacterOption: _ => false, SetCharacterOption: (_, _) => { }, ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId))); } finally { Console.SetOut(originalOut); } string bindLog = capture.ToString(); Console.WriteLine($"[socialprobe] fellowship Bind() console output:\n{bindLog}"); Assert.NotNull(fellowshipController); Assert.DoesNotContain("not found", bindLog); // Allegiance signature elements. foreach ((uint id, string name) in new[] { (0x10000255u, "MonarchField"), (0x1000025Au, "PatronField"), (0x10000260u, "VassalListBox"), (0x10000263u, "SwearButton"), (0x10000264u, "BreakButton"), (0x10000265u, "KickButton"), }) { UiElement? el = UiElement.FindDescendant(tabs, id); Console.WriteLine($"[socialprobe] allegiance element {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}"); Assert.NotNull(el); } // 0x10000492 is authored TWICE inside the allegiance page (monarch // block child AND patron block child) — count occurrences under the // allegiance page root specifically, not the whole tree. UiElement? allegiancePage = UiElement.FindDescendant(tabs, 0x10000291u); Assert.NotNull(allegiancePage); int passupCount = CountDescendants(allegiancePage!, 0x10000492u); Console.WriteLine($"[socialprobe] 0x10000492 occurrences under allegiance page = {passupCount}"); // Fix-round mechanism SF-3: this was printed but never asserted — a // future importer change that collapses or drops one instance must // fail this test, not just the log. Assert.Equal(2, passupCount); // Campaign FA slice FA5, item 6: the DOUBLED 0x10000492 SCOPED // resolution — proves SocialAllegiancePageController's two // FindDescendant calls (one under 0x10000490, one under the patron // field 0x1000025A) each resolve to a DIFFERENT real instance, not // just that two instances exist somewhere in the subtree (the count // check above). A flat/unscoped lookup would silently pick ONE // instance for BOTH roles and this probe would not catch it. UiElement? monarchField = UiElement.FindDescendant(tabs, 0x10000255u); UiElement? patronField = UiElement.FindDescendant(tabs, 0x1000025Au); Assert.NotNull(monarchField); Assert.NotNull(patronField); UiElement? monarchIsPatronSubBlock = UiElement.FindDescendant(monarchField!, 0x10000490u); Console.WriteLine( $"[socialprobe] allegiance sub-block 0x10000490 (under monarch field) -> " + $"{(monarchIsPatronSubBlock is null ? "MISSING" : monarchIsPatronSubBlock.GetType().Name)}"); Assert.NotNull(monarchIsPatronSubBlock); UiElement? monarchScopedPassup = UiElement.FindDescendant(monarchIsPatronSubBlock!, 0x10000492u); UiElement? patronScopedPassup = UiElement.FindDescendant(patronField!, 0x10000492u); Console.WriteLine( $"[socialprobe] 0x10000492 under 0x10000490 -> {(monarchScopedPassup is null ? "MISSING" : "0x" + monarchScopedPassup.DatElementId.ToString("X8"))}; " + $"under patron field -> {(patronScopedPassup is null ? "MISSING" : "0x" + patronScopedPassup.DatElementId.ToString("X8"))}"); Assert.NotNull(monarchScopedPassup); Assert.NotNull(patronScopedPassup); Assert.NotSame(monarchScopedPassup, patronScopedPassup); // The vassal ListBox's own authored template pair and scrollbar // (mirrors the fellowship row-template check above). UiElement? vassalListBoxEl = UiElement.FindDescendant(tabs, 0x10000260u); UiTemplateListBox vassalListBox = Assert.IsType(vassalListBoxEl); Console.WriteLine( $"[socialprobe] allegiance ListBox 0x10000260 templates={vassalListBox.Templates.Count} " + $"scrollbar=0x{vassalListBox.ScrollbarElementId:X8}"); Assert.NotEmpty(vassalListBox.Templates); UiTemplateListEntry vassalRowTemplate = vassalListBox.Templates[0]; UiElement? vassalRow = rowTemplates.Resolve( vassalRowTemplate.TemplateLayoutId, vassalRowTemplate.TemplateElementId); Console.WriteLine( $"[socialprobe] allegiance row template 0x{vassalRowTemplate.TemplateLayoutId:X8}/" + $"0x{vassalRowTemplate.TemplateElementId:X8} -> {(vassalRow is null ? "IMPORT NULL" : vassalRow.GetType().Name)}"); Assert.NotNull(vassalRow); foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[] { (0x10000268u, "VassalName", typeof(UiText)), (0x10000269u, "VassalExperiencePassedUp", typeof(UiText)), }) { UiElement? el = UiElement.FindDescendant(vassalRow!, id); Console.WriteLine($"[socialprobe] allegiance row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}"); Assert.IsType(expectedType, el); } UiElement? offlineMarker = UiElement.FindDescendant(vassalRow!, 0x100004AAu); Console.WriteLine($"[socialprobe] allegiance row field OfflineMarker 0x100004AA -> {(offlineMarker is null ? "MISSING" : offlineMarker.GetType().Name)}"); Assert.NotNull(offlineMarker); // The IgnoreAllegianceRequests checkbox (D7: dimmed on the Options // Character tab, but still a live read/write toggle here — same // shape as the fellowship page's four checkboxes). UiElement? ignoreRequestsCheckbox = UiElement.FindDescendant(tabs, 0x10000262u); Console.WriteLine( $"[socialprobe] allegiance checkbox IgnoreAllegianceRequests 0x10000262 -> " + $"{(ignoreRequestsCheckbox is null ? "MISSING" : ignoreRequestsCheckbox.GetType().Name)}"); Assert.IsType(ignoreRequestsCheckbox); // CF-1 + AD-85: the checkbox label/tooltip (0x23000003) and the four // confirmation-dialog templates + two label captions (0x23000001) — // resolved verbatim, never substituted (class doc). string? ignoreRequestsLabel = strings.Resolve( 0x23000003u, DatStringResolver.ComputeHash("ID_PlayerOption_IgnoreAllegianceRequests")); Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_IgnoreAllegianceRequests -> '{ignoreRequestsLabel}'"); Assert.False(string.IsNullOrEmpty(ignoreRequestsLabel)); foreach (string key in new[] { "ID_Allegiance_MonarchLabel", "ID_Allegiance_PatronSlashMonarchLabel", "ID_Allegiance_SwearConfirmation", "ID_Allegiance_BreakConfirmation", "ID_Allegiance_KickConfirmation", }) { string? value = strings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key)); Console.WriteLine($"[socialprobe] allegiance string {key} -> '{value}'"); Assert.False(string.IsNullOrEmpty(value)); } // Full end-to-end Bind against the PRODUCTION mount (mirrors the // fellowship Bind check above) — proves Bind() finds every element // it needs with zero "not found" console warnings. UiElement? allegiancePageForBind = UiElement.FindDescendant(tabs, 0x10000291u); Assert.NotNull(allegiancePageForBind); var allegianceOriginalOut = Console.Out; var allegianceCapture = new StringWriter(); Console.SetOut(allegianceCapture); SocialAllegiancePageController? allegianceController; try { allegianceController = SocialAllegiancePageController.Bind( allegiancePageForBind!, new SocialAllegiancePageController.Bindings( Snapshot: () => new RuntimeAllegianceSnapshot(), Monarch: () => null, Patron: _ => null, Member: _ => null, Vassals: _ => [], Swear: _ => default, Break: _ => default, Kick: _ => default, SetUpdateSubscription: _ => default, Selection: new AcDream.Core.Selection.SelectionState(), LocalPlayerGuid: () => 0u, CurrentCharacterOption: _ => false, SetCharacterOption: (_, _) => { }, TemplateResolver: rowTemplates.Resolve, ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId), ResolveWorldObjectName: _ => null, ShowConfirmation: (_, _) => 0u)); } finally { Console.SetOut(allegianceOriginalOut); } string allegianceBindLog = allegianceCapture.ToString(); Console.WriteLine($"[socialprobe] allegiance Bind() console output:\n{allegianceBindLog}"); Assert.NotNull(allegianceController); Assert.DoesNotContain("not found", allegianceBindLog); // Tab button captions — non-empty (the #375 resolver class: a missing // string resolver renders blank captions even though the layout mounts). foreach (UiTabTableEntry t in tabs.Tabs) { UiElement? button = UiElement.FindDescendant(tabs, t.ButtonElementId); string? caption = button switch { UiText text => text.LinesProvider?.Invoke() is { Count: > 0 } lines ? lines[0].Text : null, UiButton btn => btn.Label, _ => null, }; Console.WriteLine( $"[socialprobe] tab button 0x{t.ButtonElementId:X8} ({button?.GetType().Name}) caption='{caption}'"); Assert.False(string.IsNullOrEmpty(caption)); } // U6: containment of the fellowship empty/full frames. ElementInfo? notInFellowshipFrame = FindInfo(root, 0x1000026Bu); ElementInfo? inFellowshipFrame = FindInfo(root, 0x10000275u); Console.WriteLine( $"[socialprobe] 0x1000026B (NotInAFellowshipFrame) children: " + string.Join(",", (notInFellowshipFrame?.Children ?? new()).ConvertAll(c => $"0x{c.Id:X8}"))); Console.WriteLine( $"[socialprobe] 0x10000275 (InAFellowshipFrame) children: " + string.Join(",", (inFellowshipFrame?.Children ?? new()).ConvertAll(c => $"0x{c.Id:X8}"))); // Dump two levels of children under each page slot for U3/U4/U6/U7 sweep. foreach (uint pageId in new[] { 0x10000513u, 0x10000291u, 0x10000292u, 0x1000054Au }) { ElementInfo? pageInfo = FindInfo(root, pageId); if (pageInfo is null) { Console.WriteLine($"[socialprobe] page-info 0x{pageId:X8} MISSING from ElementInfo tree"); continue; } Console.WriteLine( $"[socialprobe] page-info 0x{pageId:X8} P0x57={(pageInfo.TryGetEffectiveProperty(0x57u, out var p57) ? p57.UnsignedValue.ToString() : "ABSENT")} children={pageInfo.Children.Count}"); DumpInfoTree(pageInfo, 1, maxDepth: 3); } // Friends/Squelch row templates — find the name-text child. foreach ((uint tLayout, uint tElement, string name) in new[] { (0x2100005Du, 0x10000519u, "FriendsRow"), (0x21000060u, 0x10000541u, "SquelchRow"), }) { ElementInfo? row = LayoutImporter.ImportInfos(dats, tLayout, tElement); if (row is null) { Console.WriteLine($"[socialprobe] {name} template 0x{tLayout:X8}/0x{tElement:X8} IMPORT NULL"); continue; } Console.WriteLine($"[socialprobe] {name} template:"); DumpInfoTree(row, 1, maxDepth: 3); } } private static void DumpInfoTree(ElementInfo info, int depth, int maxDepth) { if (depth > maxDepth) return; string templates = info.TemplateList.Count > 0 ? $" templates={info.TemplateList.Count}[{string.Join(",", info.TemplateList.ConvertAll(t => $"0x{t.TemplateLayoutId:X8}/0x{t.TemplateElementId:X8}"))}] scrollbar=0x{info.ScrollbarElementId:X8}" : ""; Console.WriteLine( $"[socialprobe] {new string(' ', depth * 2)}0x{info.Id:X8} type=0x{info.Type:X8} " + $"({info.X},{info.Y} {info.Width}x{info.Height}) kids={info.Children.Count}{templates}"); foreach (ElementInfo c in info.Children) DumpInfoTree(c, depth + 1, maxDepth); } /// User gate 2026-08-13 ("I can't change any options", "I only /// get the move window cursor" over roster rows): reproduce the click /// ROUTING in-process — mount the panel through the PRODUCTION window /// frame, build a live roster, then hit-test each interactive widget's /// center through the real UiRoot walk and print WHAT claims the point /// plus every ancestor's Visible/Enabled/ClickThrough. Also dumps the /// no-fellowship frame's text children for the truncated empty-state /// string. [Fact] public void ProbeSocialClickRouting() { if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "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? rootInfo = LayoutImporter.ImportInfos( dats, SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId); Assert.NotNull(rootInfo); ImportedLayout layout = LayoutImporter.Build( rootInfo!, _ => (1u, 8, 8), null, null, strings.Resolve); UiTabPanel tabs = Assert.IsType(layout.Root); tabs.ActivateTabBehavior(); var rowTemplates = new RowTemplateResolver( (layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId), info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root); // Mutable live state the controller reads each Tick. RuntimeFellowshipSnapshot snapshot = default(RuntimeFellowshipSnapshot) with { Revision = 1, IsInFellowship = true, Name = "Probe", LeaderGuid = 0x50000001u, }; var members = new List { new(0x50000001u, "Leader", 10, 100, 100, 100, 100, 100, 100, false), new(0x50000002u, "Fellow", 12, 120, 120, 120, 120, 120, 120, false), }; var optionWrites = new List<(uint Id, bool Value)>(); UiElement? fellowshipPage = UiElement.FindDescendant(tabs, 0x10000292u); Assert.NotNull(fellowshipPage); SocialFellowshipPageController? controller = SocialFellowshipPageController.Bind( fellowshipPage!, new SocialFellowshipPageController.Bindings( Snapshot: () => snapshot, Members: () => members, TemplateResolver: rowTemplates.Resolve, Create: (_, _) => default, Recruit: _ => default, Dismiss: _ => default, Quit: _ => default, AssignLeader: _ => default, SetOpen: _ => default, SetPanelOpen: _ => default, Selection: new AcDream.Core.Selection.SelectionState(), LocalPlayerGuid: () => 0x50000001u, CurrentCharacterOption: _ => false, SetCharacterOption: (id, value) => optionWrites.Add(((uint)id, value)), ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId))); Assert.NotNull(controller); // The PRODUCTION window frame (RetailUiRuntime.MountSocialPanel's // exact options minus the born-hidden flag) inside a real root. var uiRoot = new UiRoot { Width = 1280, Height = 720 }; RetailWindowHandle handle = RetailWindowFrame.Mount( uiRoot, tabs, _ => (1u, 8, 8), new RetailWindowFrame.Options { WindowName = WindowNames.SocialPanel, Chrome = RetailWindowChrome.NineSlice, Left = 200f, Top = 140f, ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, ConstrainDragToParent = true, ConstrainResizeToParent = true, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, ContentClickThrough = false, }); handle.Show(); tabs.SwitchTo(0x10000292u); // the Fellowship page controller!.Tick(); // builds the two roster rows foreach ((uint id, string name) in new (uint, string)[] { (0x10000270u, "IgnoreRequestsCheckbox"), (0x10000271u, "AutoAcceptCheckbox"), (0x10000272u, "ShareXpCheckbox"), (0x10000273u, "ShareLootCheckbox"), (0x1000027Fu, "DismissButton"), (0x10000283u, "RowNameText(first)"), }) { UiElement? el = UiElement.FindDescendant(uiRoot, id); if (el is null) { Console.WriteLine($"[clickprobe] {name} 0x{id:X8}: MISSING under the mounted root"); continue; } (float ax, float ay) = Absolute(el); float cx = ax + el.Width / 2f, cy = ay + el.Height / 2f; UiElement? winner = uiRoot.HitTest(cx, cy); Console.WriteLine( $"[clickprobe] {name} 0x{id:X8}: abs=({ax},{ay} {el.Width}x{el.Height}) " + $"hit@({cx},{cy}) -> {(winner is null ? "NULL" : $"{winner.GetType().Name} 0x{winner.DatElementId:X8}")} " + $"{(ReferenceEquals(winner, el) ? "SELF" : "NOT-SELF")}"); for (UiElement? a = el; a is not null; a = a.Parent) Console.WriteLine( $"[clickprobe] ancestor {a.GetType().Name} 0x{a.DatElementId:X8} " + $"({a.Left},{a.Top} {a.Width}x{a.Height}) " + $"Visible={a.Visible} Enabled={a.Enabled} ClickThrough={a.ClickThrough}"); } // The checkbox CLICK itself, end-to-end: synthesize a click on the // first checkbox through the root's own pointer pipeline. if (UiElement.FindDescendant(uiRoot, 0x10000270u) is { } cb) { (float ax, float ay) = Absolute(cb); int px = (int)(ax + cb.Width / 2f), py = (int)(ay + cb.Height / 2f); uiRoot.OnMouseDown(UiMouseButton.Left, px, py); uiRoot.OnMouseUp(UiMouseButton.Left, px, py); Console.WriteLine( $"[clickprobe] synthetic click on IgnoreRequestsCheckbox -> optionWrites=[{string.Join(",", optionWrites)}]"); } // The truncated empty-state: flip to no-fellowship and dump the // frame's text children. snapshot = snapshot with { IsInFellowship = false, Revision = 2 }; controller.Tick(); if (UiElement.FindDescendant(uiRoot, 0x1000026Bu) is { } emptyFrame) DumpTexts(emptyFrame, 0); // Round 2: with the no-fellowship frame now VISIBLE, does a real // click toggle the checkbox? optionWrites.Clear(); if (UiElement.FindDescendant(uiRoot, 0x10000270u) is { } cb2) { (float ax, float ay) = Absolute(cb2); int px = (int)(ax + cb2.Width / 2f), py = (int)(ay + cb2.Height / 2f); UiElement? winner = uiRoot.HitTest(px, py); Console.WriteLine( $"[clickprobe] NOT-in-fellowship hit@({px},{py}) -> " + $"{(winner is null ? "NULL" : $"{winner.GetType().Name} 0x{winner.DatElementId:X8}")}"); uiRoot.OnMouseDown(UiMouseButton.Left, px, py); uiRoot.OnMouseUp(UiMouseButton.Left, px, py); Console.WriteLine( $"[clickprobe] NOT-in-fellowship synthetic click -> optionWrites=[{string.Join(",", optionWrites)}]"); } // Friends + Squelch action widgets: authored labels → button roles // (never guess an id's role). foreach (uint pageId in new[] { 0x10000513u, 0x1000054Au }) { if (UiElement.FindDescendant(uiRoot, pageId) is not { } page) continue; DumpActionWidgets(page, 0); } // The allegiance page's own checkbox (the user's "always checked, // can't press"): what is it, where does it live, is it reachable? tabs.SwitchTo(0x10000291u); foreach (uint id in new[] { 0x10000266u, 0x10000267u, 0x10000268u, 0x10000269u, 0x1000026Au }) { UiElement? el = UiElement.FindDescendant(uiRoot, id); if (el is null) continue; (float ax, float ay) = Absolute(el); UiElement? winner = uiRoot.HitTest(ax + el.Width / 2f, ay + el.Height / 2f); Console.WriteLine( $"[clickprobe] allegiance 0x{id:X8} {el.GetType().Name} abs=({ax},{ay} {el.Width}x{el.Height}) " + $"Visible={el.Visible} Enabled={el.Enabled} ClickThrough={el.ClickThrough} " + $"hit -> {(winner is null ? "NULL" : $"{winner.GetType().Name} 0x{winner.DatElementId:X8}")}"); } } private static (float X, float Y) Absolute(UiElement el) { float x = 0, y = 0; for (UiElement? a = el; a is not null; a = a.Parent) { x += a.Left; y += a.Top; } return (x, y); } private static void DumpActionWidgets(UiElement el, int depth) { string extra = el switch { UiButton b => $" label='{b.Label}'", UiField => " FIELD", _ => "", }; if (el is UiButton or UiField || depth == 0) Console.WriteLine( $"[clickprobe] {new string(' ', depth * 2)}{el.GetType().Name} " + $"0x{el.DatElementId:X8} ({el.Left},{el.Top} {el.Width}x{el.Height}){extra}"); foreach (UiElement c in el.Children) DumpActionWidgets(c, depth + 1); } private static void DumpTexts(UiElement el, int depth) { if (el is UiText text) { string content = string.Join( " \\n ", (text.LinesProvider?.Invoke() ?? []).Select(l => l.Text)); Console.WriteLine( $"[clickprobe] {new string(' ', depth * 2)}TEXT 0x{el.DatElementId:X8} " + $"({el.Left},{el.Top} {el.Width}x{el.Height}) '{content}'"); } else { Console.WriteLine( $"[clickprobe] {new string(' ', depth * 2)}{el.GetType().Name} 0x{el.DatElementId:X8} " + $"({el.Left},{el.Top} {el.Width}x{el.Height})"); } foreach (UiElement c in el.Children) DumpTexts(c, depth + 1); } private static int CountDescendants(UiElement root, uint id) { int count = root.DatElementId == id ? 1 : 0; foreach (UiElement child in root.Children) count += CountDescendants(child, id); return count; } private static ElementInfo? FindInfo(ElementInfo root, uint id) { if (root.Id == id) return root; foreach (ElementInfo c in root.Children) { ElementInfo? found = FindInfo(c, id); if (found is not null) return found; } return null; } }