fix: social panel completion batch (user gate 2026-08-13, "fix all")
One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):
1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
can't change any options"): the authored checkboxes carry DAT
ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
handlers read the flipped value and wrote the ORIGINAL back, snapping
every click to where it started (the probe recorded (id, oldValue)).
Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
name text is display-text ClickThrough=true, which the hit-test walk
skips regardless of HandlesClick - the wired OnClick was unreachable.
Fix: UiText.OnClick assignment now clears ClickThrough (central,
documented); the stats text gains the same select handler so most of
the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
the authored string resolves COMPLETE (three sentences) but embedded
'\n's rendered as one clipped line. DatWidgetFactory now splits
authored strings into one Line per newline, with the provider still
re-reading DefaultColor live (the state-color contract - caught by
BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
selection-blue tints are deleted; names always white (register row
narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
(user-specified format; the full retail StringInfo composition stays
AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
0x0018, retail clears the box - Request_AddFriend @0x0048D240),
Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
to your friend-of list), Squelch Character/Account add-by-name
(0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
row. The wire beneath (builders, WorldSession sends, Runtime commands,
parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
(docs/research/2026-08-13-social-wire-completion.md, committed here).
Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
unconditionally) - the includeSelf gate was an unregistered
divergence, now removed on both the left-click and right-click paths.
Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).
Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).
Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec2a7b0cce
commit
72ceddce2e
15 changed files with 1018 additions and 65 deletions
|
|
@ -257,7 +257,10 @@ public sealed class SelectionInteractionControllerTests
|
|||
|
||||
Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight));
|
||||
|
||||
Assert.False(h.Query.LastIncludeSelf);
|
||||
// 2026-08-13 gate: retail has NO self-exclusion on this path
|
||||
// (RecvNotice_SmartBoxObjectFound @0x004E5BAE selects/examines
|
||||
// unconditionally) — includeSelf is now always true.
|
||||
Assert.True(h.Query.LastIncludeSelf);
|
||||
Assert.Equal(Target, h.Selection.SelectedObjectId);
|
||||
Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events);
|
||||
Assert.Equal(new[] { Target }, h.Examines);
|
||||
|
|
|
|||
|
|
@ -716,16 +716,28 @@ public sealed class SocialFellowshipPageControllerTests
|
|||
[Fact]
|
||||
public void Checkbox_Click_TogglesAndWritesTheCharacterOption()
|
||||
{
|
||||
// 2026-08-13 gate fix: these authored checkboxes carry ToggleBehavior
|
||||
// (the button SELF-FLIPS Selected at MouseUp), so the handler derives
|
||||
// the next value from the STORE and the per-tick seeding mirrors it
|
||||
// back — the old handler read the already-flipped widget state and
|
||||
// wrote the ORIGINAL value forever (the stuck-checkbox gate report).
|
||||
UiElement root = BuildPageRoot(out _, out _);
|
||||
var b = new FellowshipBindingsBuilder { Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = false } };
|
||||
SocialFellowshipPageController.Bind(root, b.Build());
|
||||
SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
|
||||
var shareXp = (UiButton)UiElement.FindDescendant(root, ShareXpCheckboxId)!;
|
||||
Assert.True(shareXp.SuppressSelfToggle);
|
||||
Assert.False(shareXp.Selected);
|
||||
|
||||
shareXp.OnClick!();
|
||||
|
||||
Assert.True(shareXp.Selected);
|
||||
Assert.True(b.Options[CharacterOptionId.FellowshipShareXP]);
|
||||
controller.Tick(); // the seeding mirrors the store
|
||||
Assert.True(shareXp.Selected);
|
||||
|
||||
shareXp.OnClick!(); // and the toggle works BOTH ways
|
||||
Assert.False(b.Options[CharacterOptionId.FellowshipShareXP]);
|
||||
controller.Tick();
|
||||
Assert.False(shareXp.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -306,7 +306,9 @@ public sealed class SocialPanelControllerTests
|
|||
var monarchName = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000257u));
|
||||
Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text);
|
||||
var monarchFollowers = Assert.IsType<UiText>(UiElement.FindDescendant(monarchField, 0x10000258u));
|
||||
Assert.Equal("2", Assert.Single(monarchFollowers.LinesProvider()).Text);
|
||||
// 2026-08-13 gate: the bare count gained its label (user-directed
|
||||
// format; the full retail StringInfo composition stays AD-85's gap).
|
||||
Assert.Equal("Followers: 2", Assert.Single(monarchFollowers.LinesProvider()).Text);
|
||||
}
|
||||
|
||||
/// <summary>SF-7's own additional test — the monarch IS the viewer:
|
||||
|
|
|
|||
|
|
@ -475,6 +475,240 @@ public sealed class SocialPanelLiveMountProbeTests
|
|||
DumpInfoTree(c, depth + 1, maxDepth);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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<UiTabPanel>(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<RuntimeFellowMemberSnapshot>
|
||||
{
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue