diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
index 9ec9e69a..825fdc39 100644
--- a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
@@ -61,7 +61,26 @@ namespace AcDream.App.UI.Layout;
/// leadership in the row at all. Lacking a decompiled anchor either way,
/// this controller tints the leader's name text a distinct gold
/// () as a minimal, clearly-adaptive visual
-/// cue rather than inventing a DAT mechanism that was not found.
+/// cue rather than inventing a DAT mechanism that was not found — register
+/// row AD-82.
+///
+///
+///
+/// World→panel selection (fix-round MUST-FIX 4).
+/// gmFellowshipUI::UpdateFellowSelection @0x0048F0F0 is retail's
+/// REVERSE arm of 's panel-to-world direction:
+/// selecting a fellow in the 3D world (or by any other selection origin)
+/// selects their panel row too, enabling Dismiss/Assign-Leader. Retail
+/// keys row identity via a generic ListBox primitive
+/// (SetAttribute_InstanceID(row, 0x1000000D, fellowIid) +
+/// UIElement_ListBox::SetSelectedItem) that acdream's
+/// does not port (register row AD-82 —
+/// "no Flush, no selection model, no per-row instance-id",
+/// docs/research/2026-08-11-fa-panel-structure.md §6.6).
+/// reproduces the OBSERVABLE behavior — button-enable + a visual row
+/// highlight — against this controller's OWN guid-keyed row dictionary
+/// () via instead of
+/// a generic ListBox selection model; see AD-82 for what's deferred.
///
///
public sealed class SocialFellowshipPageController
@@ -110,6 +129,23 @@ public sealed class SocialFellowshipPageController
private static readonly Vector4 LeaderNameColor = new(1f, 0.84f, 0f, 1f);
private static readonly Vector4 MemberNameColor = Vector4.One;
+ /// Fix-round MUST-FIX 4 — the panel-local "this row is selected"
+ /// visual cue (see the class doc's world→panel selection note).
+ /// Same disposition as : no decompiled
+ /// anchor for a per-row selection sprite/marker was found, so this is a
+ /// minimal, clearly-adaptive tint rather than an invented DAT mechanism
+ /// — register row AD-82. Takes priority over
+ /// when a row is both the leader and the current selection.
+ private static readonly Vector4 SelectedNameColor = new(0.45f, 0.85f, 1f, 1f);
+
+ /// Cached per — only
+ /// reassigned when the fellowship name actually changes (fix-round SF-1,
+ /// the same zero-allocation-while-idle discipline
+ /// SocialAllegiancePageController's BlankLineProvider/
+ /// NoLinesProvider statics established for the sibling page).
+ private string? _lastFellowshipName;
+ private Func>? _fellowshipNameLinesProvider;
+
/// The live read/write seam this page binds to — Runtime's
/// FA2 fellowship owner (snapshot + member enumeration), the seven
/// generation-gated commands routed through
@@ -164,6 +200,22 @@ public sealed class SocialFellowshipPageController
private readonly UiButton? _shareLootCheckbox;
private readonly Dictionary _rows = new();
+
+ /// Fix-round SF-2/SF-3: the TRUE member-guid set as of the last
+ /// rebuild attempt, independent of which rows actually finished
+ /// building. alone under-counts when a row template
+ /// permanently fails to build ('s own
+ /// continue) — comparing against .Count would
+ /// make 's membership-changed check true
+ /// FOREVER for that guid (every vitals tick re-attempts a full
+ /// DAT-locked
+ /// rebuild instead of only on a real join/leave/disband), and would let
+ /// Recruit's "already a fellow" check
+ /// () light up for someone who IS
+ /// already a fellow but whose row failed to build. This set is the
+ /// single source of truth for both.
+ private readonly HashSet _memberGuids = new();
+
private uint _selectedFellowGuid;
private long _lastRosterRevision = long.MinValue;
private bool? _lastOpenState;
@@ -359,7 +411,21 @@ public sealed class SocialFellowshipPageController
_openButton.OnClick = () =>
{
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
- _bindings.SetOpen(!snapshot.IsOpen);
+ bool newOpenState = !snapshot.IsOpen;
+ _bindings.SetOpen(newOpenState);
+
+ // N-0 (fix-round NIT): lane B feature 11 — retail's Open
+ // button handler pre-toggles its own _open_fellow BEFORE
+ // Event_ChangeFellowOpeness, so the caption flips
+ // immediately rather than waiting for the 0x02BE echo.
+ // RefreshOpenCaption's own _lastOpenState latch means this
+ // optimistic set is a no-op (already matches) once the real
+ // snapshot catches up, and is silently corrected on the
+ // next Tick if the server ever disagrees.
+ _lastOpenState = newOpenState;
+ string? label = newOpenState ? _closeCaption : _openCaption;
+ if (label is not null)
+ _openButton.Label = label;
};
}
@@ -407,6 +473,11 @@ public sealed class SocialFellowshipPageController
///
public void Tick()
{
+ // N-3 (fix-round NIT): Snapshot() and Members() below each take
+ // RuntimeFellowshipState._gate separately, so Revision/MemberCount
+ // can momentarily disagree with the roster RefreshRoster reads a
+ // few lines later. Self-correcting on the next revision bump
+ // (single-threaded tick today) — not assumed atomic.
RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
bool inFellowship = snapshot.IsInFellowship;
_notInFellowshipFrame.Visible = !inFellowship;
@@ -422,9 +493,12 @@ public sealed class SocialFellowshipPageController
_listBox?.Flush();
_rows.Clear();
}
+ _memberGuids.Clear();
_selectedFellowGuid = 0u;
_lastRosterRevision = long.MinValue;
_lastOpenState = null;
+ _lastFellowshipName = null;
+ _fellowshipNameLinesProvider = null;
return;
}
@@ -436,6 +510,12 @@ public sealed class SocialFellowshipPageController
RefreshRoster(snapshot);
}
+ // MUST-FIX 4 (fix round): retail's UpdateFellowSelection runs from
+ // Update EVERY frame (not just on a selection-changed notice) —
+ // this call site mirrors that placement. See the class doc's
+ // world->panel selection note.
+ SyncSelectionFromWorld();
+
RefreshButtonStates(snapshot);
RefreshOpenCaption(snapshot);
}
@@ -457,6 +537,18 @@ public sealed class SocialFellowshipPageController
_bindings.SetPanelOpen(visible);
}
+ ///
+ /// MUST-FIX 3 (FA4 fix round) — clears the edge-trigger latch WITHOUT
+ /// sending anything, so the next call
+ /// re-declares 0x00A6 even if the caller's own visibility
+ /// hasn't changed from ITS perspective. Called by
+ /// on every
+ /// generation reset — the page itself is process-lifetime and survives
+ /// a reconnect, but the fresh session's server has no memory of the
+ /// previous session's 0x00A6 declaration.
+ ///
+ public void ResetPageVisibleLatch() => _pageVisible = false;
+
private void RefreshCreateButtonState()
{
if (_createButton is null) return;
@@ -486,10 +578,20 @@ public sealed class SocialFellowshipPageController
_bindings.CurrentCharacterOption(CharacterOptionId.FellowshipShareLoot);
}
+ /// Fix-round SF-1: reassigns
+ /// (a display-class + delegate allocation) only when the name actually
+ /// changed, instead of on every — this method used to
+ /// allocate once per frame even while the panel is hidden, exactly the
+ /// bug 35c40a9b fixed on the sibling
+ /// SocialAllegiancePageController.
private void RefreshFellowshipName(string name)
{
if (_fellowshipNameText is null) return;
- _fellowshipNameText.LinesProvider = () => [new UiText.Line(name, MemberNameColor)];
+ if (_fellowshipNameLinesProvider is not null && _lastFellowshipName == name) return;
+
+ _lastFellowshipName = name;
+ _fellowshipNameLinesProvider = () => [new UiText.Line(name, MemberNameColor)];
+ _fellowshipNameText.LinesProvider = _fellowshipNameLinesProvider;
}
private void RefreshOpenCaption(RuntimeFellowshipSnapshot snapshot)
@@ -524,7 +626,12 @@ public sealed class SocialFellowshipPageController
if (_recruitButton is not null)
{
uint? targetGuid = _bindings.Selection.SelectedObjectId;
- bool targetValid = targetGuid is { } id && id != selfGuid && !_rows.ContainsKey(id);
+ // Fix-round SF-3: reads TRUE membership (_memberGuids), not
+ // which rows happen to have finished building (_rows) — a row
+ // that failed to build for a permanently-unresolvable template
+ // must not let Recruit re-invite someone who IS already a
+ // fellow.
+ bool targetValid = targetGuid is { } id && id != selfGuid && !_memberGuids.Contains(id);
bool notFull = snapshot.MemberCount < MaxFellowshipSize;
// Retail additionally requires the target to be a player
// (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap
@@ -537,10 +644,12 @@ public sealed class SocialFellowshipPageController
}
///
- /// Diffs the live member GUID set against the currently-rendered rows.
- /// Unchanged set → update every row's bound widgets in place (no ListBox
- /// mutation, scroll untouched). Changed set → full rebuild via
- /// (carry-forward 1).
+ /// Diffs the live member GUID set ( — fix-round
+ /// SF-2, NOT .Count, see that field's own doc) against
+ /// the current roster. Unchanged set → update every row's bound widgets
+ /// in place (no ListBox mutation, scroll untouched). Changed set → full
+ /// rebuild via
+ /// (carry-forward 1).
///
private void RefreshRoster(RuntimeFellowshipSnapshot snapshot)
{
@@ -548,12 +657,12 @@ public sealed class SocialFellowshipPageController
var members = new List(_bindings.Members());
- bool membershipChanged = members.Count != _rows.Count;
+ bool membershipChanged = members.Count != _memberGuids.Count;
if (!membershipChanged)
{
foreach (RuntimeFellowMemberSnapshot member in members)
{
- if (_rows.ContainsKey(member.Guid)) continue;
+ if (_memberGuids.Contains(member.Guid)) continue;
membershipChanged = true;
break;
}
@@ -565,7 +674,7 @@ public sealed class SocialFellowshipPageController
foreach (RuntimeFellowMemberSnapshot member in members)
UpdateRow(member, snapshot);
- if (_selectedFellowGuid != 0u && !_rows.ContainsKey(_selectedFellowGuid))
+ if (_selectedFellowGuid != 0u && !_memberGuids.Contains(_selectedFellowGuid))
_selectedFellowGuid = 0u;
}
@@ -574,6 +683,14 @@ public sealed class SocialFellowshipPageController
_listBox!.FlushPreservingScroll();
_rows.Clear();
+ // Fix-round SF-2: record the FULL attempted member set up front,
+ // regardless of which rows below actually finish building — a
+ // permanently-unbuildable template is attempted once per real
+ // membership change, not once per vitals tick forever.
+ _memberGuids.Clear();
+ foreach (RuntimeFellowMemberSnapshot member in members)
+ _memberGuids.Add(member.Guid);
+
foreach (RuntimeFellowMemberSnapshot member in members)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
@@ -611,7 +728,12 @@ public sealed class SocialFellowshipPageController
if (widgets.Name is { } nameText)
{
string name = member.Name;
- Vector4 color = snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
+ // MUST-FIX 4 (fix round): selection takes priority over the
+ // leader tint when a row is both — it's the more immediate,
+ // user-driven state (see SelectedNameColor's own doc).
+ Vector4 color = _selectedFellowGuid == member.Guid
+ ? SelectedNameColor
+ : snapshot.LeaderGuid == member.Guid ? LeaderNameColor : MemberNameColor;
nameText.LinesProvider = () => [new UiText.Line(name, color)];
}
@@ -639,6 +761,24 @@ public sealed class SocialFellowshipPageController
/// acdream ExperienceSystem exists yet); guessing a formula here is
/// exactly what CLAUDE.md's workflow forbids, so this is a scoped,
/// documented gap rather than an invented number.
+ ///
+ ///
+ /// MUST-FIX 1 (fix round) — retail TRUNCATES, it does not round.
+ /// gmFellowshipUI::UpdateFellowStats @0x0048ECC9 forms
+ /// pct * 100.0f on the x87 stack (exact — both operands are
+ /// floats) then calls _ftol2 @0x005DE394 (MSVC's helper that
+ /// turns the FPU's round-to-nearest fistp into C's
+ /// truncate-toward-zero), never MathF.Round. This matters ONLY
+ /// at 6 and 8 fellows: the stored constants are
+ /// 0.44999998807907104 and 0.3499999940395355 (byte-read
+ /// from the PDB-paired binary), so retail's own products are
+ /// 44.999998… / 34.999999… and truncate to 44 /
+ /// 34 — a naive (int)MathF.Round(pct * 100f) (or even
+ /// (int)(pct * 100f), since 0.45f * 100f already rounds UP
+ /// to exactly 45.0f in SINGLE precision) yields the wrong 45/35.
+ /// Forming the product in double before truncating reproduces
+ /// retail's wider-than-single-precision x87 product exactly.
+ ///
///
private static string FormatStatsText(RuntimeFellowMemberSnapshot member, RuntimeFellowshipSnapshot snapshot)
{
@@ -647,7 +787,7 @@ public sealed class SocialFellowshipPageController
if (snapshot.EvenXpSplit)
{
float pct = EvenSplitPercent(snapshot.MemberCount);
- return $"{member.Level} {(int)MathF.Round(pct * 100f)}%";
+ return $"{member.Level} {(int)((double)pct * 100.0)}%";
}
return member.Level.ToString();
}
@@ -655,6 +795,21 @@ public sealed class SocialFellowshipPageController
private static float EvenSplitPercent(int memberCount) =>
memberCount is >= 1 and <= 10 ? EvenSplitPercentTable[memberCount - 1] : 0f;
+ ///
+ /// N-1 (fix-round NIT): retail's UpdateFellowVitals @0x0048ED60
+ /// sets meter attribute 0x69 to cur/max AND writes the
+ /// cur/max ints into the meter's authored CHILD text elements
+ /// (0x10000286 under health, 0x10000288 under stamina) —
+ /// acdream instead uses /
+ /// because
+ /// is true (a pre-existing widget decision, not this slice's). Noted
+ /// here so a future reader does not go hunting for the unbound
+ /// 0x10000286/0x10000288/0x1000028A children.
+ /// N-2: the max > 0 guard below is an acdream-side engineering
+ /// call (retail divides unconditionally) — the right one, since a
+ /// pre-population zero-max member should not render a divide-by-zero
+ /// fill, but not itself a "verbatim port" claim.
+ ///
private static void SetVitals(UiMeter? meter, uint current, uint max)
{
if (meter is null) return;
@@ -671,11 +826,49 @@ public sealed class SocialFellowshipPageController
/// panel selects them in the world too. acdream has no generic
/// per-row-element click primitive on an imported template subtree, so
/// the row's own name text (always present per the row inventory) is
- /// the click target.
+ /// the click target (register row AD-82 — retail selects on the whole
+ /// row, not just its name text).
///
private void SelectFellow(uint guid)
{
- _selectedFellowGuid = guid;
+ SetSelectedFellow(guid);
_bindings.Selection.Select(guid, SelectionChangeSource.Social);
}
+
+ ///
+ /// MUST-FIX 4 (fix round) — the world→panel arm of retail's
+ /// two-directional selection coupling (see the class doc). Retail's
+ /// UpdateFellowSelection loop has two arms: a world selection
+ /// that matches a fellow row becomes the panel selection (this
+ /// method's job — the "found" arm); otherwise the CURRENT panel
+ /// selection is kept as a fallback as long as that fellow is still on
+ /// the roster (retail's "remember row as the fallback" arm — no code
+ /// needed here, since this method simply does nothing when the world
+ /// selection doesn't match a member, leaving
+ /// untouched), and already clears the
+ /// selection when its fellow leaves the roster (retail's
+ /// fallback-is-null case).
+ ///
+ private void SyncSelectionFromWorld()
+ {
+ if (_bindings.Selection.SelectedObjectId is { } id && _memberGuids.Contains(id))
+ SetSelectedFellow(id);
+ }
+
+ /// Sets the panel's own selection (idempotent) and immediately
+ /// refreshes every row's visual selection cue — selection changes are
+ /// rare (a click, or a world-selection change), so re-running
+ /// for the bounded (≤9) roster on a change
+ /// is cheap and keeps the highlight in sync without waiting for the
+ /// next vitals-driven revision bump.
+ private void SetSelectedFellow(uint guid)
+ {
+ if (_selectedFellowGuid == guid) return;
+ _selectedFellowGuid = guid;
+
+ if (_rows.Count == 0) return;
+ RuntimeFellowshipSnapshot snapshot = _bindings.Snapshot();
+ foreach (RuntimeFellowMemberSnapshot member in _bindings.Members())
+ UpdateRow(member, snapshot);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
index 8c2d196d..45a4c100 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs
@@ -126,6 +126,7 @@ public sealed class SocialFellowshipPageControllerTests
public SelectionState Selection = new();
public uint LocalPlayerGuid;
public Func TemplateResolver = FakeRowResolver;
+ public Func ResolveString = static (_, _) => null;
public SocialFellowshipPageController.Bindings Build() => new(
Snapshot: () => Snapshot,
@@ -142,7 +143,7 @@ public sealed class SocialFellowshipPageControllerTests
LocalPlayerGuid: () => LocalPlayerGuid,
CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v,
SetCharacterOption: (id, value) => { Options[id] = value; Calls.Add($"set-option:{id}:{value}"); },
- ResolveString: (_, _) => null);
+ ResolveString: ResolveString);
}
// ── Empty/full frame swap (still correct after the FA4 rewrite) ────────
@@ -240,8 +241,21 @@ public sealed class SocialFellowshipPageControllerTests
Assert.Equal(0.10f, health.Fill()!.Value, 3);
}
+ ///
+ /// Fix-round SF-5: RENAMED from
+ /// ..._ButPreservesScrollPosition — with only one short row this
+ /// test cannot observe a NONZERO preserved offset (its own prior
+ /// comment admitted as much), so the "preserves scroll position" half
+ /// of the old name was aspirational, not verified here. The actual
+ /// shrink/clamp math IS covered, correctly, at the widget level
+ /// (UiTemplateListBoxFlushPreservingScrollTests.FlushPreservingScroll_ClampsToTheNewShorterContent).
+ /// This test's real job — proven below — is that a genuine roster
+ /// CHANGE (Bob joins) goes through
+ /// (not the scroll-resetting ) and
+ /// produces the right row count.
+ ///
[Fact]
- public void Tick_MembershipChange_RebuildsRoster_ButPreservesScrollPosition()
+ public void Tick_MembershipChange_RebuildsRoster()
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
@@ -256,13 +270,6 @@ public sealed class SocialFellowshipPageControllerTests
listBox.ViewportForTest!.LayoutScrollableChildren();
listBox.Scroll.SetScrollY(0); // only one short row — nothing to scroll, but exercise the path
- // Carry-forward 1: a genuine roster change (Bob joins) must not
- // silently desync — verified via row count — AND must go through
- // the scroll-preserving flush, not the scroll-resetting one. We
- // can't observe a NONZERO preserved offset with only one short
- // row, so this test's decisive assertion is closer to the wiring:
- // FlushPreservingScroll leaves Scroll.ScrollY untouched immediately
- // after the clear, unlike Flush.
var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 2 };
b.Members = [alice, bob];
@@ -271,6 +278,196 @@ public sealed class SocialFellowshipPageControllerTests
Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
}
+ /// Fix-round SF-5: the shrink counterpart of the join test
+ /// above — a member LEAVING must also rebuild (not silently desync),
+ /// and a departed fellow's panel selection must clear rather than stay
+ /// stale (so a subsequent Dismiss/Leader click can't re-target a guid
+ /// that is no longer on the roster).
+ [Fact]
+ public void Tick_MemberLeaves_ShrinksRoster_AndClearsSelectionIfTheyWereSelected()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var alice = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var bob = new RuntimeFellowMemberSnapshot(0x50000002u, "Bob", 10, 90, 70, 50, 90, 70, 50, false);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot
+ {
+ IsInFellowship = true, Revision = 1, MemberCount = 2, LeaderGuid = 0x50000001u,
+ },
+ Members = [alice, bob],
+ LocalPlayerGuid = 0x50000001u, // Alice, the leader
+ };
+
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
+
+ UiElement bobRow = listBox.ViewportForTest!.Children[1];
+ UiText bobName = Assert.IsType(UiElement.FindDescendant(bobRow, RowNameTextId));
+ bobName.OnClick!(); // selects Bob
+ ((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
+ Assert.Contains("dismiss:50000002", b.Calls);
+ b.Calls.Clear();
+
+ // Bob leaves (independently of the click above — e.g. he quit).
+ b.Snapshot = b.Snapshot with { Revision = 2, MemberCount = 1 };
+ b.Members = [alice];
+ controller.Tick();
+
+ Assert.Single(listBox.ViewportForTest!.Children);
+
+ // The stale selection must be gone — Dismiss must not re-send
+ // Bob's guid.
+ ((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).OnClick!();
+ Assert.DoesNotContain(b.Calls, c => c.StartsWith("dismiss:"));
+ }
+
+ /// Fix-round SF-2: a permanently-unbuildable row template must
+ /// be attempted once, at the real membership change, not re-attempted
+ /// (a full DAT-locked
+ /// rebuild) on every subsequent pure-vitals revision bump. Without the
+ /// fix, comparing against _rows.Count (which stays 0 forever
+ /// here) makes every Tick see a "membership change" that never actually
+ /// happened.
+ [Fact]
+ public void Tick_RowTemplatePermanentlyFailsToBuild_DoesNotRetryOnEveryVitalsTick()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ int resolverCalls = 0;
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1 },
+ Members = [member],
+ TemplateResolver = (_, _) => { resolverCalls++; return null; }, // permanently unbuildable
+ };
+
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ Assert.Empty(listBox.ViewportForTest?.Children ?? []);
+ int callsAfterFirstAttempt = resolverCalls;
+ Assert.True(callsAfterFirstAttempt >= 1);
+
+ // Two pure vitals ticks: SAME member set, revision bumps (the exact
+ // shape a 0x02C0 vitals refresh produces).
+ b.Snapshot = b.Snapshot with { Revision = 2 };
+ controller.Tick();
+ b.Snapshot = b.Snapshot with { Revision = 3 };
+ controller.Tick();
+
+ Assert.Equal(callsAfterFirstAttempt, resolverCalls);
+ }
+
+ /// Fix-round SF-3: Recruit's "already a fellow" check must read
+ /// TRUE membership, not which rows happen to have finished building —
+ /// otherwise a permanently-unbuildable row lets Recruit light up for
+ /// someone who IS already a fellow.
+ [Fact]
+ public void RecruitButton_TargetIsAnExistingFellowWhoseRowFailedToBuild_StaysDisabled()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var selection = new SelectionState();
+ selection.Select(0x50000001u, SelectionChangeSource.World); // targeting the (already-a-fellow) guid
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 1u },
+ Members = [member],
+ LocalPlayerGuid = 1u,
+ Selection = selection,
+ TemplateResolver = (_, _) => null, // Alice's row never builds -- but she IS still a fellow
+ };
+
+ SocialFellowshipPageController.Bind(root, b.Build());
+
+ Assert.False(((UiButton)UiElement.FindDescendant(root, RecruitButtonId)!).Enabled);
+ }
+
+ // ── MUST-FIX 4: world→panel selection sync ──────────────────────────
+
+ [Fact]
+ public void WorldSelectionOfAFellow_EnablesDismissAndLeader_WithoutClickingTheirRow()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var selection = new SelectionState();
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
+ Members = [member],
+ LocalPlayerGuid = 0xFFu, // leader
+ Selection = selection,
+ };
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+ Assert.False(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
+ Assert.False(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
+
+ // MUST-FIX 4 — selecting Alice in the WORLD (not clicking her panel
+ // row) must still enable Dismiss/Leader, matching retail's
+ // gmFellowshipUI::UpdateFellowSelection reverse arm.
+ selection.Select(0x50000001u, SelectionChangeSource.World);
+ controller.Tick();
+
+ Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
+ Assert.True(((UiButton)UiElement.FindDescendant(root, LeaderButtonId)!).Enabled);
+ }
+
+ [Fact]
+ public void WorldSelectionOfANonFellow_DoesNotClearAnExistingPanelSelection()
+ {
+ UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);
+ var member = new RuntimeFellowMemberSnapshot(0x50000001u, "Alice", 12, 100, 80, 60, 100, 80, 60, false);
+ var selection = new SelectionState();
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, Revision = 1, MemberCount = 1, LeaderGuid = 0xFFu },
+ Members = [member],
+ LocalPlayerGuid = 0xFFu,
+ Selection = selection,
+ };
+ SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!;
+
+ UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
+ UiText name = Assert.IsType(UiElement.FindDescendant(row, RowNameTextId));
+ name.OnClick!(); // selects Alice via her row
+ controller.Tick(); // Enabled is refreshed by RefreshButtonStates, which only runs in Tick
+ Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
+
+ // Retail's fallback arm: a world selection that is NOT a fellow
+ // must not clear the panel's own selection while that fellow is
+ // still on the roster.
+ selection.Select(0x99999999u, SelectionChangeSource.World);
+ controller.Tick();
+
+ Assert.True(((UiButton)UiElement.FindDescendant(root, DismissButtonId)!).Enabled);
+ }
+
+ // ── N-0: optimistic Open/Close caption ──────────────────────────────
+
+ [Fact]
+ public void OpenButton_Click_FlipsCaptionImmediately_NotWaitingForTheNextTick()
+ {
+ UiElement root = BuildPageRoot(out _, out _);
+ var b = new FellowshipBindingsBuilder
+ {
+ Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true, IsOpen = false, LeaderGuid = 1u },
+ LocalPlayerGuid = 1u,
+ ResolveString = (_, hash) =>
+ hash == DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText") ? "Open"
+ : hash == DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText") ? "Close"
+ : null,
+ };
+ SocialFellowshipPageController.Bind(root, b.Build());
+ var openButton = (UiButton)UiElement.FindDescendant(root, OpenButtonId)!;
+ Assert.Equal("Open", openButton.Label);
+
+ openButton.OnClick!();
+
+ // N-0 (fix round): retail's Open button handler pre-toggles its own
+ // state before the server echo (0x02BE) lands (lane B feature 11)
+ // -- the caption flips on click, not on the next Tick.
+ Assert.Equal("Close", openButton.Label);
+ }
+
[Fact]
public void Tick_NotInFellowship_ClearsAnyStaleRoster()
{
@@ -294,8 +491,17 @@ public sealed class SocialFellowshipPageControllerTests
[Theory]
[InlineData(false, true, 1, "12 0%")] // ShareXp off -> retail's literal 0.0
- [InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> round(31.11) = 31
+ [InlineData(true, true, 9, "12 31%")] // even split, 9 fellows -> 0.3111111 -> truncate(31.11) = 31
[InlineData(true, false, 3, "12")] // proportional -> no acdream XP table -> level only, no invented %
+ // MUST-FIX 1 (fix round): retail TRUNCATES (_ftol2 @0x0048ECC9), it
+ // does not round. The stored float constants for 6 and 8 fellows are
+ // 0.44999998807907104 and 0.3499999940395355 (byte-read from the
+ // PDB-paired binary) -- ×100 = 44.999998.../34.999999..., which
+ // truncate to 44/34, NOT 45/35 (what MathF.Round -- or even a naive
+ // single-precision (int)(pct*100f) cast, since 0.45f*100f already
+ // rounds UP to exactly 45.0f in single precision -- would produce).
+ [InlineData(true, true, 6, "12 44%")]
+ [InlineData(true, true, 8, "12 34%")]
public void FormatStatsText_MatchesD5Rules(bool shareXp, bool evenSplit, int memberCount, string expected)
{
UiElement root = BuildPageRoot(out UiTemplateListBox listBox, out _);