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;
}
}