fix #FA4-mechanism-MUST-FIX-3,SF-4: re-arm 0x00A6 on reconnect; unsubscribe ActivePageChanged on Dispose

MUST-FIX 3 -- SocialFellowshipPageController.SetPageVisible is edge-triggered
on a bool that survives a generation reset unchanged while the panel stays
open, so a reconnect never re-sends 0x00A6 and fellow vitals freeze for the
rest of the new session. SocialPanelController.ResetSessionDeclaration
clears the fellowship page's latch (SocialFellowshipPageController.
ResetPageVisibleLatch, this commit's counterpart) and re-evaluates the
existing "window shown AND Fellowship active" conjunction, wired into
RetailUiRuntime.ResetSessionTransientUi -- a seam that already runs on
every generation reset. A still-open Fellowship page re-declares; a closed
or other-tab page correctly stays silent.

SF-4 -- SocialPanelController's constructor subscribed an anonymous lambda
to UiTabPanel.ActivePageChanged with no way to remove it; Dispose only set
a flag. A tab switch after Dispose still reached
UpdateFellowshipPageVisibility and issued a Runtime command, since Tick's
own _disposed guard doesn't cover this event path. Stored the handler as a
field and unsubscribe it in Dispose.

Also adds the panel-level D4 conjunction test mechanism SF-5 flagged as
missing (the only prior D4 test exercised the PAGE controller's own
edge-trigger directly, never SocialPanelController's "window shown AND
Fellowship active" logic or its ActivePageChanged subscription).

Per docs/research/2026-08-12-fa4-review-mechanism.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 07:33:32 +02:00
parent 5499f0581f
commit df00030697
3 changed files with 166 additions and 2 deletions

View file

@ -105,6 +105,16 @@ public sealed class SocialPanelController : IRetainedPanelController
private readonly SocialAllegiancePageController? _allegiance;
private readonly SocialFriendsPageController? _friends;
private readonly SocialSquelchPageController? _squelch;
/// <summary>Fix-round SF-4: stored so <see cref="Dispose"/> can
/// unsubscribe it — the constructor previously subscribed an anonymous
/// lambda with no way to remove it, so a tab switch reaching
/// <see cref="UiTabPanel.ActivePageChanged"/> AFTER <see cref="Dispose"/>
/// still dispatched into <see cref="UpdateFellowshipPageVisibility"/> and
/// a Runtime command (<c>Tick</c> itself already guards on
/// <see cref="_disposed"/>; this event does not go through <c>Tick</c>).</summary>
private readonly Action<uint, uint> _onActivePageChanged;
private bool _disposed;
/// <summary>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;
}
/// <summary>
@ -267,6 +279,28 @@ public sealed class SocialPanelController : IRetainedPanelController
private void UpdateFellowshipPageVisibility() =>
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
/// <summary>
/// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from
/// <see cref="RetailUiRuntime.ResetSessionTransientUi"/> — a seam that
/// already runs on every generation reset — to re-arm the Fellowship
/// page's <c>0x00A6</c> edge-trigger latch. <see cref="SocialPanelController"/>
/// is process-lifetime and the latch
/// (<see cref="SocialFellowshipPageController.SetPageVisible"/>) is
/// edge-triggered on a bool that survives a reconnect unchanged, so
/// without this a still-open Fellowship page never re-declares
/// <c>0x00A6</c> on the fresh session and every fellow's vitals freeze
/// for its remaining lifetime. Clears the latch, then re-evaluates the
/// SAME D4 conjunction <see cref="OnShown"/>/<see cref="OnHidden"/>/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).
/// </summary>
public void ResetSessionDeclaration()
{
_fellowship?.ResetPageVisibleLatch();
UpdateFellowshipPageVisibility();
}
/// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
/// data-driven <see cref="UiElement.Visible"/> 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;
}
}

View file

@ -729,11 +729,24 @@ public sealed class RetailUiRuntime : IDisposable
}
}
/// <summary>
/// MUST-FIX 3 (FA4 fix round): also re-arms the fellowship page's
/// <c>0x00A6</c> panel-open declaration latch
/// (<see cref="Layout.SocialPanelController.ResetSessionDeclaration"/>).
/// <see cref="SocialPanelController"/> is process-lifetime, but its
/// edge-trigger latch is not — without this, a reconnect while the
/// Fellowship page is left open never re-sends <c>0x00A6</c> on the new
/// session, and every fellow's vitals freeze for the rest of the
/// session (lane B §4.5: ACE streams <c>0x02C0</c> only to fellows with
/// <c>FellowshipPanelOpen</c> set, and that flag is set only by
/// <c>0x00A6</c>).
/// </summary>
public void ResetSessionTransientUi()
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
Host.HideWindow(WindowNames.Examination);
SocialPanelController?.ResetSessionDeclaration();
}
public void UpdateCursor(IEnumerable<IMouse> mice)