diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs
index 59538a3e..65692704 100644
--- a/src/AcDream.App/UI/Layout/SocialPanelController.cs
+++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs
@@ -105,6 +105,16 @@ public sealed class SocialPanelController : IRetainedPanelController
private readonly SocialAllegiancePageController? _allegiance;
private readonly SocialFriendsPageController? _friends;
private readonly SocialSquelchPageController? _squelch;
+
+ /// Fix-round SF-4: stored so can
+ /// unsubscribe it — the constructor previously subscribed an anonymous
+ /// lambda with no way to remove it, so a tab switch reaching
+ /// AFTER
+ /// still dispatched into and
+ /// a Runtime command (Tick itself already guards on
+ /// ; this event does not go through Tick).
+ private readonly Action _onActivePageChanged;
+
private bool _disposed;
/// Root element of the imported panel (the tab host itself —
@@ -136,8 +146,10 @@ public sealed class SocialPanelController : IRetainedPanelController
// four-page mount that's the conjunction of both. Subscribed here
// (not in Bind) so it observes every tab switch, including the
// very first one ActivateTabBehavior's default-entry activation
- // fires.
- _tabPanel.ActivePageChanged += (_, _) => UpdateFellowshipPageVisibility();
+ // fires. Fix-round SF-4: stored as a field (not an inline lambda)
+ // so Dispose can unsubscribe it.
+ _onActivePageChanged = (_, _) => UpdateFellowshipPageVisibility();
+ _tabPanel.ActivePageChanged += _onActivePageChanged;
}
///
@@ -267,6 +279,28 @@ public sealed class SocialPanelController : IRetainedPanelController
private void UpdateFellowshipPageVisibility() =>
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
+ ///
+ /// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from
+ /// — a seam that
+ /// already runs on every generation reset — to re-arm the Fellowship
+ /// page's 0x00A6 edge-trigger latch.
+ /// is process-lifetime and the latch
+ /// () is
+ /// edge-triggered on a bool that survives a reconnect unchanged, so
+ /// without this a still-open Fellowship page never re-declares
+ /// 0x00A6 on the fresh session and every fellow's vitals freeze
+ /// for its remaining lifetime. Clears the latch, then re-evaluates the
+ /// SAME D4 conjunction //tab
+ /// switches use — a still-open Fellowship page re-declares; a closed or
+ /// other-tab page correctly stays silent (the conjunction is false, so
+ /// the now-cleared latch is simply left at its default).
+ ///
+ public void ResetSessionDeclaration()
+ {
+ _fellowship?.ResetPageVisibleLatch();
+ UpdateFellowshipPageVisibility();
+ }
+
///
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
/// data-driven provider exists) and the
@@ -306,5 +340,9 @@ public sealed class SocialPanelController : IRetainedPanelController
{
if (_disposed) return;
_disposed = true;
+ // Fix-round SF-4: without this, a tab switch after Dispose still
+ // reaches UpdateFellowshipPageVisibility -> a Runtime command —
+ // Tick's own _disposed guard doesn't cover this event handler.
+ _tabPanel.ActivePageChanged -= _onActivePageChanged;
}
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index cc85f4c7..e82a3c01 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -729,11 +729,24 @@ public sealed class RetailUiRuntime : IDisposable
}
}
+ ///
+ /// MUST-FIX 3 (FA4 fix round): also re-arms the fellowship page's
+ /// 0x00A6 panel-open declaration latch
+ /// ().
+ /// is process-lifetime, but its
+ /// edge-trigger latch is not — without this, a reconnect while the
+ /// Fellowship page is left open never re-sends 0x00A6 on the new
+ /// session, and every fellow's vitals freeze for the rest of the
+ /// session (lane B §4.5: ACE streams 0x02C0 only to fellows with
+ /// FellowshipPanelOpen set, and that flag is set only by
+ /// 0x00A6).
+ ///
public void ResetSessionTransientUi()
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
Host.HideWindow(WindowNames.Examination);
+ SocialPanelController?.ResetSessionDeclaration();
}
public void UpdateCursor(IEnumerable mice)
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
index ff0db6a8..3640f260 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
@@ -497,4 +497,117 @@ public sealed class SocialPanelControllerTests
controller.Tick();
Assert.Single(listBox.ViewportForTest!.Children);
}
+
+ // ── D4 conjunction + reconnect re-arm (mechanism SF-5, MUST-FIX 3) ─────
+
+ ///
+ /// Fix-round mechanism SF-5 — the only pre-existing D4 test
+ /// (SocialFellowshipPageControllerTests.SetPageVisible_SendsPanelOpen_OnlyOnATransition)
+ /// exercises the PAGE controller's edge-trigger directly; nothing
+ /// exercised the actual conjunction this slice is named for —
+ /// SocialPanelController's own "window shown AND Fellowship
+ /// active" logic, its ActivePageChanged subscription, or
+ /// /.
+ ///
+ [Fact]
+ public void FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive()
+ {
+ var calls = new List();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout,
+ MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
+ Assert.NotNull(controller);
+ controller!.ActivateTabs(); // authored default tab is Allegiance, not Fellowship
+ calls.Clear();
+
+ // Window not shown yet, and not on the Fellowship tab -> no send.
+ Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
+
+ // Window shown, but STILL on Allegiance -> conjunction false, still no send.
+ controller.OnShown();
+ Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
+
+ // Switch to Fellowship while shown -> conjunction true -> declares.
+ controller.ShowFellowship();
+ Assert.Contains("fellowship-set-panel-open:True", calls);
+
+ calls.Clear();
+ controller.OnHidden(); // window closes while on Fellowship -> declares false
+ Assert.Contains("fellowship-set-panel-open:False", calls);
+ }
+
+ ///
+ /// MUST-FIX 3 (FA4 fix round) — a generation reset (reconnect) while
+ /// the Fellowship page is still open must re-declare 0x00A6 on
+ /// the fresh session, not silently stay latched from the old one.
+ ///
+ [Fact]
+ public void ResetSessionDeclaration_ReArms0x00A6_ForAStillOpenFellowshipPage()
+ {
+ var calls = new List();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout,
+ MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
+ Assert.NotNull(controller);
+ controller!.ActivateTabs();
+ controller.OnShown();
+ controller.ShowFellowship();
+ Assert.Contains("fellowship-set-panel-open:True", calls);
+ calls.Clear();
+
+ // Simulate a reconnect: generation reset while the panel stays open
+ // on the Fellowship tab (nothing else in this controller changes).
+ controller.ResetSessionDeclaration();
+
+ Assert.Contains("fellowship-set-panel-open:True", calls);
+ }
+
+ /// MUST-FIX 3's counterpart: a reconnect while the panel is
+ /// closed (or on a different tab) must NOT spuriously declare
+ /// 0x00A6 true.
+ [Fact]
+ public void ResetSessionDeclaration_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
+ {
+ var calls = new List();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout,
+ MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
+ Assert.NotNull(controller);
+ controller!.ActivateTabs(); // default tab: Allegiance
+ controller.OnShown();
+ calls.Clear();
+
+ controller.ResetSessionDeclaration();
+
+ Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
+ }
+
+ // ── SF-4: Dispose unsubscribes ActivePageChanged ────────────────────────
+
+ [Fact]
+ public void Dispose_UnsubscribesActivePageChanged_TabSwitchAfterDisposeSendsNoCommand()
+ {
+ var calls = new List();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout,
+ MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }));
+ Assert.NotNull(controller);
+ controller!.ActivateTabs();
+ controller.OnShown();
+ calls.Clear();
+
+ controller.Dispose();
+
+ // A tab switch after Dispose must not reach UpdateFellowshipPageVisibility
+ // (and therefore must not issue a Runtime command) -- the whole
+ // point of unsubscribing in Dispose rather than relying on Tick's
+ // own _disposed guard, since this path never goes through Tick.
+ controller.ShowFellowship();
+
+ Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
+ }
}