diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index aecc6c82..b0bc15a7 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -897,7 +897,29 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
isOpen => late.GameRuntime.FellowshipSetOpen(isOpen),
panelOpen => late.GameRuntime.FellowshipSetPanelOpen(panelOpen),
d.Actions.Selection,
- () => d.PlayerIdentity.ServerGuid),
+ () => d.PlayerIdentity.ServerGuid,
+ // Campaign FA slice FA5: IRuntimeAllegianceView's out-param
+ // accessors projected into nullable-returning delegates —
+ // the same shape SocialAllegiancePageController.Bindings
+ // wants, mirroring the Fellowship projection above.
+ AllegianceMonarch: () =>
+ d.Runtime.Allegiance.TryGetMonarch(out var monarch)
+ ? monarch
+ : (RuntimeAllegianceMemberSnapshot?)null,
+ AllegiancePatron: guid =>
+ d.Runtime.Allegiance.TryGetPatron(guid, out var patron)
+ ? patron
+ : (RuntimeAllegianceMemberSnapshot?)null,
+ AllegianceMember: guid =>
+ d.Runtime.Allegiance.TryGetMember(guid, out var member)
+ ? member
+ : (RuntimeAllegianceMemberSnapshot?)null,
+ AllegianceVassals: guid => d.Runtime.Allegiance.GetVassals(guid),
+ AllegianceSwear: guid => late.GameRuntime.AllegianceSwear(guid),
+ AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid),
+ AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid),
+ AllegianceSetUpdateSubscription: on =>
+ late.GameRuntime.AllegianceSetUpdateSubscription(on)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,
diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
index 12b410c4..18ea229f 100644
--- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
+++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
@@ -158,6 +158,29 @@ internal sealed class DeferredGameRuntimeStateCommands
Invoke((commands, generation) => commands.Fellowship.SetPanelOpen(
generation, panelOpen));
+ // ── Campaign FA slice FA5: allegiance page commands ─────────────────
+ // Same shape as the fellowship block above.
+
+ public RuntimeCommandResult AllegianceSwear(uint patronGuid) =>
+ Invoke((commands, generation) => commands.Allegiance.Swear(
+ generation, patronGuid));
+
+ public RuntimeCommandResult AllegianceBreak(uint targetGuid) =>
+ Invoke((commands, generation) => commands.Allegiance.Break(
+ generation, targetGuid));
+
+ public RuntimeCommandResult AllegianceKick(uint vassalGuid) =>
+ Invoke((commands, generation) => commands.Allegiance.Kick(
+ generation, vassalGuid));
+
+ /// 0x001F — CF-1's data subscription toggle (the
+ /// allegiance-page analogue of 's
+ /// 0x00A6). See
+ /// for the three retail arming points this forwards.
+ public RuntimeCommandResult AllegianceSetUpdateSubscription(bool on) =>
+ Invoke((commands, generation) => commands.Allegiance.SetUpdateSubscription(
+ generation, on));
+
public void Deactivate()
{
lock (_gate)
diff --git a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
index da360b14..e1752484 100644
--- a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
@@ -1,137 +1,782 @@
using System;
using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Net.Messages;
using AcDream.Runtime;
namespace AcDream.App.UI.Layout;
///
-/// Campaign FA slice FA3: the Allegiance page's empty-state presentation —
-/// the ONLY behavior this SHELL slice owns for this page (live monarch/
-/// patron/vassal population, swear/break/kick + confirmations, and the
-/// per-member online-state dimming are FA5 scope).
+/// Campaign FA slice FA5: the Allegiance page fully live — the 0x001F
+/// data subscription (CF-1), monarch/patron/self blocks, the flat vassal
+/// list, swear/break/kick + their local confirmation dialogs, and the
+/// per-relationship empty-state gate FA3's fix round flagged as owed
+/// (mechanism SF-7). FA3 shipped only the coarse
+/// gate — see that
+/// slice's own class doc, now superseded by this one.
///
///
-/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it
-/// hides the monarch/patron blocks and blanks their name text to a literal
-/// space per-block, gated on whether each relationship exists
-/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract
-/// simplifies this to the single gate this campaign's Runtime owner
-/// actually exposes today:
-/// — no allegiance push has landed this generation. When a profile HAS
-/// arrived, this controller leaves both blocks visible with their
-/// build-time (empty) text rather than fabricate monarch/patron content —
-/// that population is FA5's job.
+/// CF-1 — the data subscription.
+/// docs/research/2026-08-11-fa-allegiance-wire.md §1.2 pins THREE
+/// retail arming points for CM_Allegiance::Event_UpdateRequest(u32)
+/// (opcode 0x001F), all sending 1 except the last:
+/// gmAllegianceUI::PostInit @0x004911C6, RecvNotice_PlayerDescReceived
+/// @0x00490D40, and OnVisibilityChanged's visible/hidden branches
+/// (@0x004912DD/@0x00491311). attempts the
+/// PostInit arm (almost always a pre-world no-op in this campaign's
+/// process-lifetime mount — see 's own
+/// doc for why that is not a gap); is
+/// the PlayerDescReceived arm, wired to the LiveSession EnteredWorld
+/// seam exactly like FA4's 0x00A6; is the
+/// visible-branch arm, called by on every
+/// "window shown AND Allegiance active" transition. Without this,
+/// 0x0020 AllegianceUpdate never arrives on demand (lane C §2 row 4/5)
+/// and the panel shows nothing — 0x027B AllegianceInfoResponse is
+/// text-only chat (FA2 MF-2's correction) and is NOT wired here.
///
///
///
-/// Element ids confirmed by the FA3 live-mount probe:
-/// 0x10000255 monarch-field container, 0x1000025A
-/// patron-field container, 0x10000257 monarch-name text (inside the
-/// monarch field), 0x1000025C patron-name text (inside the patron
-/// field). Both lookups are SCOPED to the allegiance page root — the panel
-/// also authors 0x10000492 (the XP-passed-up text) TWICE, once under
-/// each block, so a flat lookup anywhere in this subsystem would risk
-/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP
-/// 0x10000211-in-two-layouts lesson).
+/// The FA4 MF-3-REOPEN lesson, applied here. A reconnect's generation
+/// reset runs BEFORE the new session is in world, where every Runtime
+/// command is world-gated (Inactive, nothing sent).
+/// (wired to the pre-world reset seam)
+/// ONLY clears the local latch — it must never itself declare.
+/// (wired to the POST-world seam)
+/// does the actual send, and — unlike — is
+/// UNCONDITIONAL: it does not compare against the current latch first,
+/// because retail's own RecvNotice_PlayerDescReceived arm does not
+/// check current visibility either (§1.2's table: it always sends 1).
+/// The latch still advances "only on Accepted" (FA4's re-fix rule), so a
+/// dropped/rejected send leaves the latch clear for a later retry rather
+/// than lying about what was actually published.
///
///
///
-/// FA5 scope note (fix-round mechanism SF-7). This shell's gate is
-/// coarser than retail's: gmAllegianceUI::UpdateMonarchData
-/// @0x00491B40 hides the monarch block when there is no monarch OR the
-/// monarch IS the viewer, and hides the patron block on the analogous
-/// per-relationship test — this controller instead gates BOTH blocks on
-/// the single flag. The
-/// plan's FA5 row records the two per-relationship acceptance lines FA5
-/// owes. Also note for whoever lands FA5: reassigns
-/// UNCONDITIONALLY every frame — FA5's
-/// real monarch/patron name population must change this method at the
-/// same time, or its content will be overwritten on the very next frame.
+/// Per-relationship empty-state gate (fix-round SF-7).
+/// gmAllegianceUI::UpdateMonarchData @0x00491B40 hides the monarch
+/// block when there is no monarch OR the monarch IS the viewer;
+/// UpdatePatronData @0x004917C0 hides the patron block when there is
+/// no patron OR the patron IS the monarch (in which case the monarch block
+/// relabels via ID_Allegiance_PatronSlashMonarchLabel and reveals its
+/// 0x10000490 sub-block, byte-verified at the same two functions).
+/// Both hidden branches blank their name text to a literal single space —
+/// UpdateMonarchData ALSO blanks the monarch-followers text in its
+/// hidden branch (@0x00491FCD), a detail the coarser FA3 gate did not
+/// need to model.
+///
+///
+///
+/// Numeric-only text, no invented English (register row AD-85). Every
+/// retail string site beyond a bare name or a variable-free caption
+/// (ID_Allegiance_MonarchLabel/PatronSlashMonarchLabel, which
+/// carry NO StringInfo variables and so resolve and render exactly as
+/// authored) needs StringInfo variable substitution acdream has not
+/// ported (the same gap AD-81 filed for the Fellowship page). This
+/// controller therefore renders followers/rank/experience-passed-up as bare
+/// numbers with no surrounding words — the retail-authored NUMBERS, never
+/// invented sentences — and the three local confirmation dialogs
+/// (Swear/Break/Kick) show retail's own unsubstituted template text
+/// verbatim when it resolves, falling back to the bare target name (also
+/// non-invented) when it does not.
+///
+///
+///
+/// Field sources, decompiled (`gmAllegianceUI::UpdatePlayerData
+/// @0x00491330`, UpdateMonarchData, UpdatePatronData).
+/// 0x10000251 = the ALLEGIANCE's own name (_allegiance.m_AllegianceName
+/// — "CharacterName" in the id is a misnomer), not the viewer's own name.
+/// 0x10000252 ("your followers") = _total_vassals directly.
+/// 0x10000258 (monarch followers) = _total_members - 1. The
+/// self-rank field (0x10000253) queries a LIVE buffed quality
+/// (CBaseQualities::InqInt(qualities, 0x1e), i.e.
+/// PropertyInt.AllegianceRank) plus AllegianceData::GetTitle's
+/// 20-table title lookup — neither is ported here (the buffed/unbuffed
+/// distinction needs a Character-system seam this controller does not have,
+/// and the title table doesn't exist in acdream at all), so this controller
+/// substitutes the numerically-equivalent
+/// (same 0x0020 message, same underlying stat in every observed case)
+/// rendered bare. The "experience passed up" text (0x10000492, doubled
+/// under 0x10000490 and the patron field) and the vassal row's own
+/// 0x10000269 both source AllegianceData::_cp_tithed — the
+/// VIEWER's own tithed amount under the monarch/patron blocks, the VASSAL's
+/// own tithed amount in each row.
///
///
public sealed class SocialAllegiancePageController
{
+ // ── Element ids (docs/research/2026-08-11-fa-panel-structure.md §3.2) ──
+ private const uint SelfNameTextId = 0x10000251u;
+ private const uint SelfFollowersTextId = 0x10000252u;
+ private const uint SelfRankTextId = 0x10000253u;
+ private const uint MonarchFieldId = 0x10000255u;
+ private const uint MonarchLabelTextId = 0x10000256u;
+ private const uint MonarchNameTextId = 0x10000257u;
+ private const uint MonarchFollowersTextId = 0x10000258u;
+ private const uint PatronFieldId = 0x1000025Au;
+ private const uint PatronNameTextId = 0x1000025Cu;
+ private const uint VassalListBoxId = 0x10000260u;
+ private const uint IgnoreRequestsCheckboxId = 0x10000262u;
+ private const uint SwearButtonId = 0x10000263u;
+ private const uint BreakButtonId = 0x10000264u;
+ private const uint KickButtonId = 0x10000265u;
+
+ /// Child of — shown only when the
+ /// viewer's patron IS the monarch (SF-7).
+ private const uint MonarchIsPatronSubBlockId = 0x10000490u;
+
+ /// Authored TWICE inside this page (§6 DISCIPLINE) — once under
+ /// , once directly under
+ /// . Every lookup MUST be scoped to the
+ /// correct parent; a flat FindDescendant from the page root would
+ /// non-deterministically pick one instance for both roles.
+ private const uint ExperiencePassedUpTextId = 0x10000492u;
+
+ // ── Row-template element ids (same doc — the 3-part vassal row) ──
+ private const uint RowNameTextId = 0x10000268u;
+ private const uint RowExperiencePassedUpTextId = 0x10000269u;
+ private const uint RowOfflineMarkerId = 0x100004AAu;
+
+ private const uint StringTableId = 0x23000001u;
+ private const uint OptionStringTableId = 0x23000003u;
+
+ private static readonly Vector4 TextColor = Vector4.One;
+ private static readonly Vector4 OfflineNameColor = new(0.6f, 0.6f, 0.6f, 1f);
+
private static readonly IReadOnlyList BlankLine =
- [new UiText.Line(" ", System.Numerics.Vector4.One)];
-
- private static readonly IReadOnlyList NoLines = [];
-
- ///
- /// Fix-round mechanism SF-2 / blast SF-4: hoisted
- /// delegates. runs every frame regardless of panel
- /// visibility (see class doc); the original () => lines closure
- /// captured a local and allocated a display class PLUS a delegate on
- /// every call. These two cached delegates make the per-frame
- /// reassignment a plain field write — zero allocation while idle.
- ///
+ [new UiText.Line(" ", TextColor)];
private static readonly Func> BlankLineProvider = () => BlankLine;
- private static readonly Func> NoLinesProvider = () => NoLines;
+
+ /// Sentinel passed to for the "hidden,
+ /// blanked" state — deliberately NOT , since the
+ /// backing _lastXxx fields also start at
+ /// (uninitialized). Using as both the sentinel
+ /// AND the initial value would make the very first blank-state
+ /// a false "unchanged" no-op, leaving the widget at
+ /// its import-time (non-blank) text instead of retail's literal single
+ /// space.
+ private const string BlankSentinel = " blank ";
+
+ /// The live read/write seam this page binds to — mirrors
+ /// 's shape.
+ /// ///
+ /// project 's
+ /// out-param accessors into plain nullable-returning delegates.
+ /// is Swear's target-name source —
+ /// the target is a WORLD selection, not (yet) an allegiance member, so
+ /// the allegiance-profile accessors cannot name them (retail's own
+ /// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE), the SAME
+ /// resolver ToolbarRuntimeBindings.ResolveName already wires from
+ /// ClientObjectTable).
+ public sealed record Bindings(
+ Func Snapshot,
+ Func Monarch,
+ Func Patron,
+ Func Member,
+ Func> Vassals,
+ Func Swear,
+ Func Break,
+ Func Kick,
+ Func SetUpdateSubscription,
+ AcDream.Core.Selection.SelectionState Selection,
+ Func LocalPlayerGuid,
+ Func CurrentCharacterOption,
+ Action SetCharacterOption,
+ Func TemplateResolver,
+ Func ResolveString,
+ Func ResolveWorldObjectName,
+ Func, uint> ShowConfirmation);
+
+ private readonly record struct VassalRowWidgets(
+ UiText? Name,
+ UiText? ExperiencePassedUp,
+ UiElement? OfflineMarker);
+
+ private readonly Bindings _bindings;
+
+ private readonly UiText? _selfName;
+ private readonly UiText? _selfFollowers;
+ private readonly UiText? _selfRank;
private readonly UiElement _monarchField;
- private readonly UiElement _patronField;
+ private readonly UiText? _monarchLabel;
private readonly UiText? _monarchName;
+ private readonly UiText? _monarchFollowers;
+ private readonly UiElement? _monarchIsPatronSubBlock;
+ private readonly UiText? _monarchExperiencePassedUp;
+
+ private readonly UiElement _patronField;
private readonly UiText? _patronName;
- private readonly Func _snapshot;
+ private readonly UiText? _patronExperiencePassedUp;
+
+ private readonly UiTemplateListBox? _vassalListBox;
+ private readonly UiButton? _ignoreRequestsCheckbox;
+ private readonly UiButton? _swearButton;
+ private readonly UiButton? _breakButton;
+ private readonly UiButton? _kickButton;
+
+ /// Bind-time-resolved (never per-tick — same discipline every
+ /// other DatStringResolver consumer in this codebase follows).
+ /// Null when resolution failed — the affected widget then keeps its
+ /// import-time text/caption rather than showing invented English.
+ private readonly string? _monarchLabelCaption;
+ private readonly string? _patronSlashMonarchLabelCaption;
+ private readonly string? _swearConfirmationTemplate;
+ private readonly string? _breakConfirmationTemplate;
+ private readonly string? _kickConfirmationTemplate;
+
+ private readonly Dictionary _rows = new();
+ private readonly HashSet _vassalGuids = new();
+
+ private uint _selectedVassalGuid;
+ private long _lastRosterRevision = long.MinValue;
+
+ /// CF-1's local latch — see the class doc's CF-1 section.
+ /// Advances only on .
+ private bool _subscribed;
+
+ private string? _lastSelfName;
+ private string? _lastSelfFollowers;
+ private string? _lastSelfRank;
+ private string? _lastMonarchName;
+ private string? _lastMonarchFollowers;
+ private string? _lastMonarchExperiencePassedUp;
+ private string? _lastPatronName;
+ private string? _lastPatronExperiencePassedUp;
private SocialAllegiancePageController(
+ Bindings bindings,
+ UiText? selfName,
+ UiText? selfFollowers,
+ UiText? selfRank,
UiElement monarchField,
- UiElement patronField,
+ UiText? monarchLabel,
UiText? monarchName,
+ UiText? monarchFollowers,
+ UiElement? monarchIsPatronSubBlock,
+ UiText? monarchExperiencePassedUp,
+ UiElement patronField,
UiText? patronName,
- Func snapshot)
+ UiText? patronExperiencePassedUp,
+ UiTemplateListBox? vassalListBox,
+ UiButton? ignoreRequestsCheckbox,
+ UiButton? swearButton,
+ UiButton? breakButton,
+ UiButton? kickButton,
+ string? monarchLabelCaption,
+ string? patronSlashMonarchLabelCaption,
+ string? swearConfirmationTemplate,
+ string? breakConfirmationTemplate,
+ string? kickConfirmationTemplate)
{
+ _bindings = bindings;
+ _selfName = selfName;
+ _selfFollowers = selfFollowers;
+ _selfRank = selfRank;
_monarchField = monarchField;
- _patronField = patronField;
+ _monarchLabel = monarchLabel;
_monarchName = monarchName;
+ _monarchFollowers = monarchFollowers;
+ _monarchIsPatronSubBlock = monarchIsPatronSubBlock;
+ _monarchExperiencePassedUp = monarchExperiencePassedUp;
+ _patronField = patronField;
_patronName = patronName;
- _snapshot = snapshot;
+ _patronExperiencePassedUp = patronExperiencePassedUp;
+ _vassalListBox = vassalListBox;
+ _ignoreRequestsCheckbox = ignoreRequestsCheckbox;
+ _swearButton = swearButton;
+ _breakButton = breakButton;
+ _kickButton = kickButton;
+ _monarchLabelCaption = monarchLabelCaption;
+ _patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption;
+ _swearConfirmationTemplate = swearConfirmationTemplate;
+ _breakConfirmationTemplate = breakConfirmationTemplate;
+ _kickConfirmationTemplate = kickConfirmationTemplate;
}
- public static SocialAllegiancePageController? Bind(
- UiElement pageRoot,
- Func snapshot)
+ public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(pageRoot);
- ArgumentNullException.ThrowIfNull(snapshot);
+ ArgumentNullException.ThrowIfNull(bindings);
- if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField
- || UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField)
+ if (UiElement.FindDescendant(pageRoot, MonarchFieldId) is not { } monarchField
+ || UiElement.FindDescendant(pageRoot, PatronFieldId) is not { } patronField)
{
Console.WriteLine(
"[D.2b] SocialAllegiancePageController: monarch/patron field "
- + "containers (0x10000255/0x1000025A) not found — allegiance page "
- + "will not present its empty state.");
+ + $"containers (0x{MonarchFieldId:X8}/0x{PatronFieldId:X8}) not found — "
+ + "allegiance page will not present.");
return null;
}
- // Scoped to each block — 0x10000257/0x1000025C are unique per-block,
- // but the lookup discipline matters equally here (see class doc).
- UiText? monarchName = UiElement.FindDescendant(monarchField, 0x10000257u) as UiText;
- UiText? patronName = UiElement.FindDescendant(patronField, 0x1000025Cu) as UiText;
- if (monarchName is null)
- Console.WriteLine("[D.2b] SocialAllegiancePageController: monarch name text 0x10000257 not found.");
- if (patronName is null)
- Console.WriteLine("[D.2b] SocialAllegiancePageController: patron name text 0x1000025C not found.");
+ // Scoped lookups — see class doc + §6 DISCIPLINE. 0x10000492 is
+ // authored TWICE; each instance is resolved under its OWN parent.
+ UiElement? monarchIsPatronSubBlock =
+ UiElement.FindDescendant(monarchField, MonarchIsPatronSubBlockId);
+ UiText? monarchExperiencePassedUp = monarchIsPatronSubBlock is null
+ ? null
+ : UiElement.FindDescendant(monarchIsPatronSubBlock, ExperiencePassedUpTextId) as UiText;
+ UiText? patronExperiencePassedUp =
+ UiElement.FindDescendant(patronField, ExperiencePassedUpTextId) as UiText;
+
+ UiText? selfName = UiElement.FindDescendant(pageRoot, SelfNameTextId) as UiText;
+ UiText? selfFollowers = UiElement.FindDescendant(pageRoot, SelfFollowersTextId) as UiText;
+ UiText? selfRank = UiElement.FindDescendant(pageRoot, SelfRankTextId) as UiText;
+ UiText? monarchLabel = UiElement.FindDescendant(monarchField, MonarchLabelTextId) as UiText;
+ UiText? monarchName = UiElement.FindDescendant(monarchField, MonarchNameTextId) as UiText;
+ UiText? monarchFollowers = UiElement.FindDescendant(monarchField, MonarchFollowersTextId) as UiText;
+ UiText? patronName = UiElement.FindDescendant(patronField, PatronNameTextId) as UiText;
+
+ UiTemplateListBox? vassalListBox =
+ UiElement.FindDescendant(pageRoot, VassalListBoxId) as UiTemplateListBox;
+ if (vassalListBox is null)
+ Console.WriteLine(
+ $"[D.2b] SocialAllegiancePageController: ListBox 0x{VassalListBoxId:X8} not "
+ + "found — the vassal list will not populate.");
+ else
+ {
+ vassalListBox.TemplateResolver = bindings.TemplateResolver;
+ uint scrollbarElementId = vassalListBox.ScrollbarElementId;
+ UiElement? scrollbarElement = scrollbarElementId == 0
+ ? null
+ : UiElement.FindDescendant(pageRoot, scrollbarElementId);
+ if (scrollbarElement is UiScrollbar scrollbar)
+ scrollbar.Model = vassalListBox.Scroll;
+ else
+ Console.WriteLine(
+ $"[D.2b] SocialAllegiancePageController: scrollbar 0x{scrollbarElementId:X8} "
+ + "not found — the vassal list will not scroll.");
+ }
+
+ UiButton? ignoreRequestsCheckbox =
+ UiElement.FindDescendant(pageRoot, IgnoreRequestsCheckboxId) as UiButton;
+ UiButton? swearButton = UiElement.FindDescendant(pageRoot, SwearButtonId) as UiButton;
+ UiButton? breakButton = UiElement.FindDescendant(pageRoot, BreakButtonId) as UiButton;
+ UiButton? kickButton = UiElement.FindDescendant(pageRoot, KickButtonId) as UiButton;
+
+ // Bind-time only (class doc's numeric-only-text section). No
+ // StringInfo variables on either label — safe to use verbatim.
+ string? monarchLabelCaption = bindings.ResolveString(
+ StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_MonarchLabel"));
+ string? patronSlashMonarchLabelCaption = bindings.ResolveString(
+ StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_PatronSlashMonarchLabel"));
+
+ // AD-85: unsubstituted retail template text, used verbatim (never
+ // blended with an invented sentence) — see class doc.
+ string? swearConfirmationTemplate = bindings.ResolveString(
+ StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_SwearConfirmation"));
+ string? breakConfirmationTemplate = bindings.ResolveString(
+ StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_BreakConfirmation"));
+ string? kickConfirmationTemplate = bindings.ResolveString(
+ StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_KickConfirmation"));
var controller = new SocialAllegiancePageController(
- monarchField, patronField, monarchName, patronName, snapshot);
+ bindings,
+ selfName, selfFollowers, selfRank,
+ monarchField, monarchLabel, monarchName, monarchFollowers,
+ monarchIsPatronSubBlock, monarchExperiencePassedUp,
+ patronField, patronName, patronExperiencePassedUp,
+ vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton,
+ monarchLabelCaption, patronSlashMonarchLabelCaption,
+ swearConfirmationTemplate, breakConfirmationTemplate, kickConfirmationTemplate);
+
+ controller.WireButtons();
+ controller.WireCheckbox();
controller.Tick();
+
+ // CF-1's PostInit arm (gmAllegianceUI::PostInit @0x004911C6): almost
+ // always a pre-world no-op in this campaign's process-lifetime mount
+ // (Bind runs once, at UI construction, before any connection) — see
+ // RedeclareAfterWorldEntry for the arm that actually matters in
+ // practice. Attempted anyway for citation completeness and the rare
+ // case a host mounts post-world.
+ controller.SetPageVisible(true);
+
return controller;
}
+ private void WireButtons()
+ {
+ if (_swearButton is not null)
+ _swearButton.OnClick = OnSwearClick;
+ if (_breakButton is not null)
+ _breakButton.OnClick = OnBreakClick;
+ if (_kickButton is not null)
+ _kickButton.OnClick = OnKickClick;
+ }
+
+ private void WireCheckbox()
+ {
+ if (_ignoreRequestsCheckbox is null) return;
+
+ const string labelKey = "ID_PlayerOption_IgnoreAllegianceRequests";
+ string? label = _bindings.ResolveString(OptionStringTableId, DatStringResolver.ComputeHash(labelKey));
+ if (label is not null)
+ _ignoreRequestsCheckbox.Label = label;
+ else
+ Console.WriteLine(
+ $"[D.2b] SocialAllegiancePageController: label '{labelKey}' did not resolve — "
+ + "checkbox renders with no caption rather than invented English.");
+
+ string? tooltip = _bindings.ResolveString(
+ OptionStringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
+ if (tooltip is not null)
+ _ignoreRequestsCheckbox.TooltipText = tooltip;
+
+ _ignoreRequestsCheckbox.OnClick = () =>
+ {
+ bool next = !_ignoreRequestsCheckbox.Selected;
+ _ignoreRequestsCheckbox.Selected = next;
+ _bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next);
+ };
+ }
+
///
- /// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap
- /// — called every frame from (same
- /// reasoning as ).
+ /// gmAllegianceUI::UpdateSwearButton @0x004908E0's dialog half —
+ /// the TARGET is latched here, at click time (retail's own
+ /// m_iidPossibleNewPatron capture, lane C §1.3 step 2), not
+ /// re-read when the dialog closes. "No dialog if the name is empty"
+ /// (retail's own guard) is honored via the null/empty check below —
+ /// register row AD-84 covers the missing "target IS a player" enable-rule
+ /// check (mirrors AD-83's identical Recruit-button gap).
+ ///
+ private void OnSwearClick()
+ {
+ if (_bindings.Selection.SelectedObjectId is not { } targetGuid) return;
+ string? name = _bindings.ResolveWorldObjectName(targetGuid);
+ if (string.IsNullOrEmpty(name)) return;
+
+ string message = _swearConfirmationTemplate ?? name;
+ _bindings.ShowConfirmation(message, accepted =>
+ {
+ if (accepted) _bindings.Swear(targetGuid);
+ });
+ }
+
+ /// gmAllegianceUI::UpdateBreakButton @0x004909D0 +
+ /// MakeBreakConfirmationDialog @0x00492BF0: target = the viewer's
+ /// current patron, latched at click time.
+ private void OnBreakClick()
+ {
+ uint selfGuid = _bindings.LocalPlayerGuid();
+ if (_bindings.Patron(selfGuid) is not { } patron) return;
+
+ string message = _breakConfirmationTemplate ?? patron.Name;
+ _bindings.ShowConfirmation(message, accepted =>
+ {
+ if (accepted) _bindings.Break(patron.CharacterId);
+ });
+ }
+
+ /// MakeKickConfirmationDialog @0x00492E10: target = the
+ /// currently-selected vassal ROW (panel-local selection,
+ /// m_iidSelectedVassal — NOT the world selection; lane A §6.2
+ /// confirms Allegiance's list-selection message has no
+ /// ACCWeenieObject::SetSelectedObject call, unlike Fellowship's).
+ private void OnKickClick()
+ {
+ if (_selectedVassalGuid == 0u) return;
+ if (_bindings.Member(_selectedVassalGuid) is not { } vassal) return;
+
+ uint vassalGuid = _selectedVassalGuid;
+ string message = _kickConfirmationTemplate ?? vassal.Name;
+ _bindings.ShowConfirmation(message, accepted =>
+ {
+ if (accepted) _bindings.Kick(vassalGuid);
+ });
+ }
+
+ ///
+ /// Re-reads live state and applies retail's per-relationship gate,
+ /// roster diff, and button-enable rules. Called every frame from
+ /// unconditionally (same
+ /// reasoning FA3 established: cheap even while the panel is hidden — no
+ /// DAT access outside the revision-gated roster rebuild branch).
///
public void Tick()
{
- bool hasProfile = _snapshot().HasProfile;
- _monarchField.Visible = hasProfile;
- _patronField.Visible = hasProfile;
+ RuntimeAllegianceSnapshot snapshot = _bindings.Snapshot();
+ uint selfGuid = _bindings.LocalPlayerGuid();
+ RuntimeAllegianceMemberSnapshot? monarch = snapshot.HasProfile ? _bindings.Monarch() : null;
+ RuntimeAllegianceMemberSnapshot? patron = snapshot.HasProfile ? _bindings.Patron(selfGuid) : null;
- Func> provider = hasProfile ? NoLinesProvider : BlankLineProvider;
- if (_monarchName is not null) _monarchName.LinesProvider = provider;
- if (_patronName is not null) _patronName.LinesProvider = provider;
+ RefreshSelfBlock(snapshot);
+ RefreshMonarchBlock(snapshot, monarch, patron);
+ RefreshPatronBlock(snapshot, monarch, patron);
+
+ if (snapshot.Revision != _lastRosterRevision)
+ {
+ _lastRosterRevision = snapshot.Revision;
+ RefreshRoster(selfGuid);
+ }
+
+ RefreshCheckboxSelection();
+ RefreshButtonStates(snapshot, selfGuid, patron);
+ }
+
+ // gmAllegianceUI::UpdatePlayerData @0x00491330 — written unconditionally
+ // (lane A §4.5), no HasProfile gate; a zeroed/empty snapshot degrades to
+ // an empty name and zero counters naturally, not a special case.
+ private void RefreshSelfBlock(RuntimeAllegianceSnapshot snapshot)
+ {
+ SetLine(_selfName, ref _lastSelfName, snapshot.AllegianceName, TextColor);
+ SetLine(_selfFollowers, ref _lastSelfFollowers, snapshot.TotalVassals.ToString(), TextColor);
+ SetLine(_selfRank, ref _lastSelfRank, snapshot.Rank.ToString(), TextColor);
+ }
+
+ // gmAllegianceUI::UpdateMonarchData @0x00491B40 — see class doc.
+ private void RefreshMonarchBlock(
+ RuntimeAllegianceSnapshot snapshot,
+ RuntimeAllegianceMemberSnapshot? monarch,
+ RuntimeAllegianceMemberSnapshot? patron)
+ {
+ bool hasMonarch = monarch is { } m && m.CharacterId != _bindings.LocalPlayerGuid();
+ _monarchField.Visible = hasMonarch;
+
+ if (!hasMonarch)
+ {
+ SetProvider(_monarchName, ref _lastMonarchName, BlankSentinel, BlankLineProvider);
+ SetProvider(_monarchFollowers, ref _lastMonarchFollowers, BlankSentinel, BlankLineProvider);
+ if (_monarchIsPatronSubBlock is not null) _monarchIsPatronSubBlock.Visible = false;
+ return;
+ }
+
+ RuntimeAllegianceMemberSnapshot monarchData = monarch!.Value;
+ SetLine(_monarchName, ref _lastMonarchName, monarchData.Name, TextColor);
+ SetLine(
+ _monarchFollowers,
+ ref _lastMonarchFollowers,
+ (snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u).ToString(),
+ TextColor);
+ _monarchField.Enabled = monarchData.IsLoggedIn;
+
+ bool patronIsMonarch = patron is { } p && p.CharacterId == monarchData.CharacterId;
+ string? label = patronIsMonarch ? _patronSlashMonarchLabelCaption : _monarchLabelCaption;
+ if (_monarchLabel is not null && label is not null)
+ _monarchLabel.LinesProvider = () => [new UiText.Line(label, TextColor)];
+
+ if (_monarchIsPatronSubBlock is not null)
+ _monarchIsPatronSubBlock.Visible = patronIsMonarch;
+
+ if (patronIsMonarch)
+ {
+ uint tithed = _bindings.Member(_bindings.LocalPlayerGuid())?.CpTithed ?? 0u;
+ SetLine(_monarchExperiencePassedUp, ref _lastMonarchExperiencePassedUp, tithed.ToString(), TextColor);
+ }
+ }
+
+ // gmAllegianceUI::UpdatePatronData @0x004917C0 — see class doc.
+ private void RefreshPatronBlock(
+ RuntimeAllegianceSnapshot snapshot,
+ RuntimeAllegianceMemberSnapshot? monarch,
+ RuntimeAllegianceMemberSnapshot? patron)
+ {
+ bool hasPatron = patron is { } p
+ && (monarch is not { } m || p.CharacterId != m.CharacterId);
+ _patronField.Visible = hasPatron;
+
+ if (!hasPatron)
+ {
+ SetProvider(_patronName, ref _lastPatronName, BlankSentinel, BlankLineProvider);
+ return;
+ }
+
+ RuntimeAllegianceMemberSnapshot patronData = patron!.Value;
+ SetLine(_patronName, ref _lastPatronName, patronData.Name, TextColor);
+ _patronField.Enabled = patronData.IsLoggedIn;
+
+ uint tithed = _bindings.Member(_bindings.LocalPlayerGuid())?.CpTithed ?? 0u;
+ SetLine(_patronExperiencePassedUp, ref _lastPatronExperiencePassedUp, tithed.ToString(), TextColor);
+ }
+
+ private void RefreshCheckboxSelection()
+ {
+ if (_ignoreRequestsCheckbox is null) return;
+ _ignoreRequestsCheckbox.Selected =
+ _bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests);
+ }
+
+ // gmAllegianceUI::UpdateSwearButton/UpdateBreakButton (lane A §4.4) + the
+ // Kick enable check inlined at the end of gmAllegianceUI::Update.
+ private void RefreshButtonStates(
+ RuntimeAllegianceSnapshot snapshot,
+ uint selfGuid,
+ RuntimeAllegianceMemberSnapshot? patron)
+ {
+ if (_swearButton is not null)
+ {
+ uint? targetGuid = _bindings.Selection.SelectedObjectId;
+ // AD-84: retail additionally requires the target to be a player
+ // (ACCWeenieObject::IsPlayer) — acdream's UI layer has no cheap
+ // classification for this, same superset-of-retail limitation
+ // AD-83 already accepted for the Fellowship page's Recruit
+ // button. The server refuses a non-player target the same way
+ // retail's own disabled button would have silently no-op'd.
+ bool targetValid = targetGuid is { } id
+ && id != selfGuid
+ && _bindings.Member(id) is null;
+ _swearButton.Enabled = patron is null && targetValid;
+ }
+
+ if (_breakButton is not null)
+ _breakButton.Enabled = patron is not null;
+
+ if (_kickButton is not null)
+ _kickButton.Enabled = _selectedVassalGuid != 0u;
+ }
+
+ ///
+ /// Diffs the live vassal GUID set against the roster — unchanged set
+ /// updates rows in place; a real join/leave/kick rebuilds via
+ /// (the FA3/FA4
+ /// carry-forward — do NOT reintroduce the FA3 Flush() scroll
+ /// reset here).
+ ///
+ private void RefreshRoster(uint selfGuid)
+ {
+ if (_vassalListBox is null) return;
+
+ var vassals = new List(_bindings.Vassals(selfGuid));
+
+ bool membershipChanged = vassals.Count != _vassalGuids.Count;
+ if (!membershipChanged)
+ {
+ foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
+ {
+ if (_vassalGuids.Contains(vassal.CharacterId)) continue;
+ membershipChanged = true;
+ break;
+ }
+ }
+
+ if (membershipChanged)
+ RebuildRoster(vassals);
+ else
+ foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
+ UpdateRow(vassal);
+
+ if (_selectedVassalGuid != 0u && !_vassalGuids.Contains(_selectedVassalGuid))
+ _selectedVassalGuid = 0u;
+ }
+
+ private void RebuildRoster(List vassals)
+ {
+ _vassalListBox!.FlushPreservingScroll();
+ _rows.Clear();
+
+ _vassalGuids.Clear();
+ foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
+ _vassalGuids.Add(vassal.CharacterId);
+
+ foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
+ {
+ UiElement? row = _vassalListBox.AddItemFromTemplateList(0);
+ if (row is null)
+ {
+ Console.WriteLine(
+ "[D.2b] SocialAllegiancePageController: vassal row template did not "
+ + $"build for guid 0x{vassal.CharacterId:X8}.");
+ continue;
+ }
+
+ var widgets = new VassalRowWidgets(
+ UiElement.FindDescendant(row, RowNameTextId) as UiText,
+ UiElement.FindDescendant(row, RowExperiencePassedUpTextId) as UiText,
+ UiElement.FindDescendant(row, RowOfflineMarkerId));
+ _rows[vassal.CharacterId] = widgets;
+
+ // AD-82 addendum (SF-9): the SAME page-local "no generic
+ // per-row-element click primitive" limitation FA4 already
+ // registered — only the row's name text is a click target.
+ if (widgets.Name is { } nameText)
+ {
+ uint guid = vassal.CharacterId;
+ nameText.OnClick = () => SelectVassal(guid);
+ }
+ }
+
+ foreach (RuntimeAllegianceMemberSnapshot vassal in vassals)
+ UpdateRow(vassal);
+ }
+
+ private void UpdateRow(RuntimeAllegianceMemberSnapshot vassal)
+ {
+ if (!_rows.TryGetValue(vassal.CharacterId, out VassalRowWidgets widgets)) return;
+
+ if (widgets.Name is { } nameText)
+ {
+ string name = vassal.Name;
+ Vector4 color = vassal.IsLoggedIn ? TextColor : OfflineNameColor;
+ nameText.LinesProvider = () => [new UiText.Line(name, color)];
+ }
+
+ if (widgets.ExperiencePassedUp is { } xpText)
+ {
+ string tithed = vassal.CpTithed.ToString();
+ xpText.LinesProvider = () => [new UiText.Line(tithed, TextColor)];
+ }
+
+ // 0x100004AA — SetVisible(1) when !IsLoggedIn, SetVisible(0) when
+ // logged in (ghidra@0x00492340, lane A §3.2).
+ if (widgets.OfflineMarker is not null)
+ widgets.OfflineMarker.Visible = !vassal.IsLoggedIn;
+ }
+
+ private void SelectVassal(uint guid)
+ {
+ if (_selectedVassalGuid == guid) return;
+ _selectedVassalGuid = guid;
+ }
+
+ ///
+ /// CF-1's visible-branch arm — edge-triggered exactly like FA4's
+ /// SocialFellowshipPageController.SetPageVisible. Called by
+ /// on every "window shown AND
+ /// Allegiance active" transition, and once from as
+ /// the PostInit attempt (see class doc).
+ ///
+ public void SetPageVisible(bool visible)
+ {
+ if (_subscribed == visible) return;
+ if (_bindings.SetUpdateSubscription(visible).Status == RuntimeCommandStatus.Accepted)
+ _subscribed = visible;
+ }
+
+ ///
+ /// Pre-world generation-reset seam — clears the latch WITHOUT sending
+ /// anything, mirroring SocialFellowshipPageController.ResetPageVisibleLatch.
+ ///
+ public void ResetPageVisibleLatch() => _subscribed = false;
+
+ ///
+ /// CF-1's PlayerDescReceived arm — the post-world EnteredWorld
+ /// seam. UNCONDITIONAL (does not compare against the current latch
+ /// first): see the class doc's "FA4 MF-3-REOPEN lesson, applied here"
+ /// section for why this must not be edge-triggered like
+ /// .
+ ///
+ public void RedeclareAfterWorldEntry()
+ {
+ if (_bindings.SetUpdateSubscription(true).Status == RuntimeCommandStatus.Accepted)
+ _subscribed = true;
+ }
+
+ private static void SetLine(UiText? text, ref string? lastValue, string newValue, Vector4 color)
+ {
+ if (text is null) return;
+ if (lastValue == newValue) return;
+ lastValue = newValue;
+ text.LinesProvider = () => [new UiText.Line(newValue, color)];
+ }
+
+ private static void SetProvider(
+ UiText? text,
+ ref string? lastValue,
+ string? sentinelValue,
+ Func> provider)
+ {
+ if (text is null) return;
+ if (lastValue == sentinelValue) return;
+ lastValue = sentinelValue;
+ text.LinesProvider = provider;
}
}
diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs
index b6e7e60d..8713e362 100644
--- a/src/AcDream.App/UI/Layout/SocialPanelController.cs
+++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs
@@ -91,11 +91,15 @@ public sealed class SocialPanelController : IRetainedPanelController
/// FellowshipSnapshot field with the page's full read/write seam
/// (roster enumeration, the seven commands, selection, character
/// options, string resolution — see
- /// 's own doc).
+ /// 's own doc).
+ /// Campaign FA slice FA5: is the same
+ /// widening for the Allegiance page, replacing FA3's bare
+ /// AllegianceSnapshot field — see
+ /// 's own doc.
public sealed record Callbacks(
Action Toggle,
SocialFellowshipPageController.Bindings Fellowship,
- Func AllegianceSnapshot,
+ SocialAllegiancePageController.Bindings Allegiance,
FriendsState Friends,
SquelchState Squelch,
Func TemplateResolver);
@@ -148,7 +152,15 @@ public sealed class SocialPanelController : IRetainedPanelController
// very first one ActivateTabBehavior's default-entry activation
// fires. Fix-round SF-4: stored as a field (not an inline lambda)
// so Dispose can unsubscribe it.
- _onActivePageChanged = (_, _) => UpdateFellowshipPageVisibility();
+ // Campaign FA slice FA5: the Allegiance page's CF-1 subscription
+ // (0x001F) has the identical "window shown AND MY tab active"
+ // conjunction as Fellowship's 0x00A6 — folded into the same event
+ // subscription rather than a second one.
+ _onActivePageChanged = (_, _) =>
+ {
+ UpdateFellowshipPageVisibility();
+ UpdateAllegiancePageVisibility();
+ };
_tabPanel.ActivePageChanged += _onActivePageChanged;
}
@@ -187,7 +199,7 @@ public sealed class SocialPanelController : IRetainedPanelController
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.Fellowship);
SocialAllegiancePageController? allegiance = allegiancePage is null
? null
- : SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
+ : SocialAllegiancePageController.Bind(allegiancePage, callbacks.Allegiance);
SocialFriendsPageController? friends = friendsPage is null
? null
: SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver);
@@ -263,6 +275,7 @@ public sealed class SocialPanelController : IRetainedPanelController
{
_visible = true;
UpdateFellowshipPageVisibility();
+ UpdateAllegiancePageVisibility();
}
/// hook, fired on every
@@ -271,6 +284,7 @@ public sealed class SocialPanelController : IRetainedPanelController
{
_visible = false;
UpdateFellowshipPageVisibility();
+ UpdateAllegiancePageVisibility();
}
/// D4: the Fellowship page is "visible" (and therefore
@@ -279,6 +293,14 @@ public sealed class SocialPanelController : IRetainedPanelController
private void UpdateFellowshipPageVisibility() =>
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
+ /// CF-1's visible-branch analogue for the Allegiance page: the
+ /// social WINDOW is shown AND Allegiance is the active tab. Allegiance
+ /// is the authored DEFAULT tab, so this is true immediately after
+ /// — gated on
+ /// (initially false) the same way Fellowship's conjunction is.
+ private void UpdateAllegiancePageVisibility() =>
+ _allegiance?.SetPageVisible(_visible && IsShowingAllegiance);
+
///
/// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called
/// from , a seam
@@ -291,8 +313,14 @@ public sealed class SocialPanelController : IRetainedPanelController
/// dropped (Inactive). The actual re-declaration is
/// , wired to the post-world
/// EnteredWorld seam (FA4 re-review REOPEN, 2026-08-12).
+ /// Campaign FA slice FA5: also clears the Allegiance page's CF-1
+ /// 0x001F latch the identical way.
///
- public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch();
+ public void ResetSessionDeclaration()
+ {
+ _fellowship?.ResetPageVisibleLatch();
+ _allegiance?.ResetPageVisibleLatch();
+ }
///
/// MUST-FIX 3 (FA4 fix round) — the POST-WORLD half. Wired to the
@@ -306,8 +334,26 @@ public sealed class SocialPanelController : IRetainedPanelController
/// default). Idempotent — if a persisted layout already re-showed the
/// page and re-declared, the latch is already set
/// and this is a no-op.
+ ///
+ ///
+ /// Campaign FA slice FA5: also re-arms the Allegiance page's 0x001F
+ /// subscription — but via SocialAllegiancePageController.
+ /// RedeclareAfterWorldEntry, NOT the conjunction-gated
+ /// UpdateAllegiancePageVisibility: CF-1's PlayerDescReceived arm
+ /// is UNCONDITIONAL in retail (§1.2 — it always sends 1
+ /// regardless of panel visibility), unlike Fellowship's 0x00A6,
+ /// which retail only ever declares from the visibility toggle. Calling
+ /// the conjunction-gated method here instead would repeat the EXACT
+ /// MF-3-REOPEN class of bug this method's own history warns about — a
+ /// closed-panel reconnect would never re-subscribe and the allegiance
+ /// data would silently stay stale for the rest of the session.
+ ///
///
- public void RedeclareAfterWorldEntry() => UpdateFellowshipPageVisibility();
+ public void RedeclareAfterWorldEntry()
+ {
+ UpdateFellowshipPageVisibility();
+ _allegiance?.RedeclareAfterWorldEntry();
+ }
///
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 16fc5f6e..ce17be18 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -246,6 +246,20 @@ public sealed record OptionsRuntimeBindings(
/// (retail's ACCWeenieObject::selectedID — lane B §2.3, the
/// currently-selected WORLD object, not a panel-local selection).
///
+///
+///
+/// Campaign FA slice FA5 adds the Allegiance page's write/read surface,
+/// mirroring FA4's Fellowship shape: /
+/// //
+/// project
+/// 's out-param
+/// accessors into plain nullable-returning delegates (the shape
+/// wants), and
+/// //
+/// /
+/// route through
+/// exactly like the fellowship commands above.
+///
///
public sealed record SocialRuntimeBindings(
Func FellowshipSnapshot,
@@ -261,7 +275,15 @@ public sealed record SocialRuntimeBindings(
Func FellowshipSetOpen,
Func FellowshipSetPanelOpen,
SelectionState Selection,
- Func LocalPlayerGuid);
+ Func LocalPlayerGuid,
+ Func AllegianceMonarch,
+ Func AllegiancePatron,
+ Func AllegianceMember,
+ Func> AllegianceVassals,
+ Func AllegianceSwear,
+ Func AllegianceBreak,
+ Func AllegianceKick,
+ Func AllegianceSetUpdateSubscription);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@@ -2773,7 +2795,30 @@ public sealed class RetailUiRuntime : IDisposable
SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId)),
- AllegianceSnapshot: _bindings.Social.AllegianceSnapshot,
+ Allegiance: new Layout.SocialAllegiancePageController.Bindings(
+ Snapshot: _bindings.Social.AllegianceSnapshot,
+ Monarch: _bindings.Social.AllegianceMonarch,
+ Patron: _bindings.Social.AllegiancePatron,
+ Member: _bindings.Social.AllegianceMember,
+ Vassals: _bindings.Social.AllegianceVassals,
+ Swear: _bindings.Social.AllegianceSwear,
+ Break: _bindings.Social.AllegianceBreak,
+ Kick: _bindings.Social.AllegianceKick,
+ SetUpdateSubscription: _bindings.Social.AllegianceSetUpdateSubscription,
+ Selection: _bindings.Social.Selection,
+ LocalPlayerGuid: _bindings.Social.LocalPlayerGuid,
+ CurrentCharacterOption: id => _bindings.Options.CurrentCharacterOption((uint)id),
+ SetCharacterOption: (id, value) => _bindings.Options.CommandBus().Publish(
+ new SetSingleCharacterOptionRuntimeCmd((uint)id, value)),
+ TemplateResolver: TemplateResolver,
+ ResolveString: (tableId, stringId) => fellowshipStrings.Resolve(tableId, stringId),
+ // ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) — the same
+ // ClientObjectTable resolver ToolbarRuntimeBindings.ResolveName
+ // already wires (Swear's target is a WORLD selection, not yet
+ // an allegiance member, so the allegiance-profile accessors
+ // above cannot name them).
+ ResolveWorldObjectName: guid => _bindings.Inventory.Objects.Get(guid)?.GetAppropriateName(),
+ ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)),
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver);
diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
index ca1001e2..c920b911 100644
--- a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
@@ -72,6 +72,51 @@ public sealed class GameplayConfirmationControllerTests
Assert.Equal(0u, controller.ActiveDialogContext);
}
+ ///
+ /// Campaign FA slice FA5, item 3: verifies the allegiance-swear
+ /// confirmation (ConfirmationType.AllegianceSwear, type 1 —
+ /// RecvNotice_SwearAllegianceRequest ->
+ /// Handle_Character__ConfirmationRequest @0x005640A0's case 1)
+ /// reaches this SAME generic controller, mirroring the type-4 test
+ /// above exactly — no allegiance-specific intercept exists (there
+ /// never was one to remove; D6's fellowship correction did not touch
+ /// type 1 at all, but FA5's own contract calls for this explicit check
+ /// since SocialAllegiancePageController is the new panel that
+ /// makes this path reachable). Type 1 is NOT in the 2/3/5/6 " Continue?"
+ /// suffix set, so the message renders verbatim — ACE's own type-1
+ /// message is the target's BARE name (lane C §6.4:
+ /// Player_Allegiance.cs:91/ConfirmationManager.cs:38), not
+ /// a full sentence, which this test's message deliberately mirrors
+ /// rather than inventing retail's unported StringInfo-wrapped
+ /// sentence (AD-85).
+ ///
+ [Fact]
+ public void AllegianceSwearRequest_Type1_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ ImportedLayout? shown = null;
+ var factory = new RetailDialogFactory(root, _ =>
+ shown = FixtureLoader.LoadConfirmationDialog());
+ var responses = new List<(uint Type, uint Context, bool Accepted)>();
+ using var controller = new GameplayConfirmationController(
+ factory,
+ (type, context, accepted) => responses.Add((type, context, accepted)));
+
+ Assert.True(controller.HandleRequest(
+ new GameEvents.CharacterConfirmationRequest(1u, 13u, "Bob")));
+
+ Assert.Equal(
+ "Bob",
+ string.Join(" ", Assert.IsType(shown!.FindElement(
+ RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
+
+ Assert.IsType(shown.FindElement(
+ RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
+
+ Assert.Equal([(1u, 13u, true)], responses);
+ Assert.Equal(0u, controller.ActiveDialogContext);
+ }
+
[Fact]
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
index 58dce906..46bfbcf6 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
@@ -63,19 +63,66 @@ public sealed class SocialPanelControllerTests
ResolveString: resolveString ?? ((_, _) => null));
}
+ /// Campaign FA slice FA5: the Allegiance page's full read/write
+ /// seam, mirroring 's recorded-call
+ /// shape. Every accessor defaults to "empty" (no monarch/patron/member/
+ /// vassals) so a test that only cares about ONE relationship can pass a
+ /// bare without wiring the rest.
+ private static SocialAllegiancePageController.Bindings MakeAllegianceBindings(
+ List? calls = null,
+ RuntimeAllegianceSnapshot snapshot = default,
+ RuntimeAllegianceMemberSnapshot? monarch = null,
+ Func? patron = null,
+ Func? member = null,
+ Func>? vassals = null,
+ AcDream.Core.Selection.SelectionState? selection = null,
+ uint localPlayerGuid = 0u,
+ Func? currentCharacterOption = null,
+ Func? templateResolver = null,
+ Func? resolveString = null,
+ Func? resolveWorldObjectName = null,
+ Func, uint>? showConfirmation = null)
+ {
+ calls ??= new List();
+ return new SocialAllegiancePageController.Bindings(
+ Snapshot: () => snapshot,
+ Monarch: () => monarch,
+ Patron: patron ?? (_ => null),
+ Member: member ?? (_ => null),
+ Vassals: vassals ?? (_ => []),
+ Swear: guid => { calls.Add($"allegiance-swear:{guid:X8}"); return InactiveResult; },
+ Break: guid => { calls.Add($"allegiance-break:{guid:X8}"); return InactiveResult; },
+ Kick: guid => { calls.Add($"allegiance-kick:{guid:X8}"); return InactiveResult; },
+ // Defaults Accepted (in-world) like MakeFellowshipBindings' own
+ // SetPanelOpen default — CF-1's edge-trigger latch only advances
+ // on Accepted, so a test exercising OnShown->OnHidden needs the
+ // send to actually "take" for the second transition to register
+ // as a real change.
+ SetUpdateSubscription: on => { calls.Add($"allegiance-set-subscription:{on}"); return AcceptedResult; },
+ Selection: selection ?? new AcDream.Core.Selection.SelectionState(),
+ LocalPlayerGuid: () => localPlayerGuid,
+ CurrentCharacterOption: currentCharacterOption ?? (_ => false),
+ SetCharacterOption: (id, value) => calls.Add($"allegiance-set-option:{id}:{value}"),
+ TemplateResolver: templateResolver ?? FakeRowTemplateResolver,
+ ResolveString: resolveString ?? ((_, _) => null),
+ ResolveWorldObjectName: resolveWorldObjectName ?? (_ => null),
+ ShowConfirmation: showConfirmation ?? ((_, _) => 0u));
+ }
+
private static SocialPanelController.Callbacks MakeCallbacks(
List? calls = null,
RuntimeFellowshipSnapshot fellowship = default,
RuntimeAllegianceSnapshot allegiance = default,
FriendsState? friends = null,
SquelchState? squelch = null,
- Func? panelOpenInWorld = null)
+ Func? panelOpenInWorld = null,
+ SocialAllegiancePageController.Bindings? allegianceBindings = null)
{
calls ??= new List();
return new SocialPanelController.Callbacks(
Toggle: () => calls.Add("toggle"),
Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld),
- AllegianceSnapshot: () => allegiance,
+ Allegiance: allegianceBindings ?? MakeAllegianceBindings(calls, allegiance),
Friends: friends ?? new FriendsState(),
Squelch: squelch ?? new SquelchState(),
TemplateResolver: FakeRowTemplateResolver);
@@ -205,7 +252,7 @@ public sealed class SocialPanelControllerTests
Assert.True(inFellowship.Visible);
}
- // ── Allegiance empty state (item 4) ─────────────────────────────────────
+ // ── Allegiance empty state + per-relationship gate (FA5, fix-round SF-7) ─
[Fact]
public void Allegiance_NoProfile_HidesBlocksAndBlanksNames()
@@ -229,19 +276,335 @@ public sealed class SocialPanelControllerTests
Assert.Equal(" ", Assert.Single(patronName.LinesProvider()).Text);
}
+ ///
+ /// Fix-round SF-7: gmAllegianceUI::UpdateMonarchData @0x00491B40
+ /// hides the monarch block PER-RELATIONSHIP, not on the coarse FA3
+ /// HasProfile flag alone — a real monarch who is NOT the viewer
+ /// shows the block, with the name and _total_members - 1 follower
+ /// count rendered.
+ ///
[Fact]
- public void Allegiance_HasProfile_ShowsBothBlocks()
+ public void Allegiance_HasMonarch_NotSelf_ShowsMonarchBlock_AndRendersData()
{
+ const uint selfGuid = 100u;
+ const uint monarchGuid = 200u;
+ var monarch = new RuntimeAllegianceMemberSnapshot(
+ monarchGuid, 0u, true, "Queen Alice", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 3u },
+ monarch: monarch,
+ localPlayerGuid: selfGuid);
+
SocialPanelController? controller = SocialPanelController.Bind(
- layout,
- MakeCallbacks(allegiance: new RuntimeAllegianceSnapshot { HasProfile = true }));
+ layout, MakeCallbacks(allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
+ Assert.True(monarchField.Visible);
+ var monarchName = Assert.IsType(UiElement.FindDescendant(monarchField, 0x10000257u));
+ Assert.Equal("Queen Alice", Assert.Single(monarchName.LinesProvider()).Text);
+ var monarchFollowers = Assert.IsType(UiElement.FindDescendant(monarchField, 0x10000258u));
+ Assert.Equal("2", Assert.Single(monarchFollowers.LinesProvider()).Text);
+ }
+
+ /// SF-7's own additional test — the monarch IS the viewer:
+ /// retail hides the block even though a monarch record exists.
+ [Fact]
+ public void Allegiance_IsMonarch_HidesMonarchBlock()
+ {
+ const uint selfGuid = 100u;
+ var monarch = new RuntimeAllegianceMemberSnapshot(
+ selfGuid, 0u, true, "Me", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
+ monarch: monarch,
+ localPlayerGuid: selfGuid);
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
+ Assert.False(monarchField.Visible);
+ }
+
+ /// Patron analogue of the monarch test — a real patron who is
+ /// NOT the monarch shows the block with the patron's name.
+ [Fact]
+ public void Allegiance_HasPatron_NotMonarch_ShowsPatronBlock_AndRendersName()
+ {
+ const uint selfGuid = 100u;
+ const uint patronGuid = 300u;
+ var patron = new RuntimeAllegianceMemberSnapshot(
+ patronGuid, 0u, true, "Sir Bob", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
+ patron: guid => guid == selfGuid ? patron : null,
+ localPlayerGuid: selfGuid);
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement patronField = UiElement.FindDescendant(controller!.TabPanel, 0x1000025Au)!;
+ Assert.True(patronField.Visible);
+ var patronName = Assert.IsType(UiElement.FindDescendant(patronField, 0x1000025Cu));
+ Assert.Equal("Sir Bob", Assert.Single(patronName.LinesProvider()).Text);
+ }
+
+ ///
+ /// SF-7's headline case: patron IS the monarch — the patron block hides
+ /// AND the monarch block's 0x10000490 sub-block (the XP-passed-up
+ /// line) reveals, matching UpdateMonarchData's
+ /// PatronSlashMonarchLabel branch.
+ ///
+ [Fact]
+ public void Allegiance_PatronIsMonarch_HidesPatronBlock_RevealsMonarchSubBlock()
+ {
+ const uint selfGuid = 100u;
+ const uint monarchGuid = 200u;
+ var monarch = new RuntimeAllegianceMemberSnapshot(
+ monarchGuid, 0u, true, "Queen Alice", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, TotalMembers = 2u },
+ monarch: monarch,
+ patron: guid => guid == selfGuid ? monarch : null,
+ localPlayerGuid: selfGuid);
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(allegianceBindings: bindings));
Assert.NotNull(controller);
UiElement monarchField = UiElement.FindDescendant(controller!.TabPanel, 0x10000255u)!;
UiElement patronField = UiElement.FindDescendant(controller.TabPanel, 0x1000025Au)!;
Assert.True(monarchField.Visible);
- Assert.True(patronField.Visible);
+ Assert.False(patronField.Visible);
+
+ UiElement subBlock = UiElement.FindDescendant(monarchField, 0x10000490u)!;
+ Assert.True(subBlock.Visible);
+ }
+
+ /// The vassal list populates one row per vassal, in the
+ /// bindings' own (already-reversed) order — the controller must not
+ /// re-sort.
+ [Fact]
+ public void Allegiance_VassalList_PopulatesOneRowPerVassal()
+ {
+ const uint selfGuid = 100u;
+ var vassals = new List
+ {
+ new(401u, selfGuid, true, "Vassal One", 0, 0, 0, 0, 0u, 10u, 0, 0, true),
+ new(402u, selfGuid, false, "Vassal Two", 0, 0, 0, 0, 0u, 20u, 0, 0, true),
+ };
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
+ vassals: guid => guid == selfGuid ? vassals : [],
+ localPlayerGuid: selfGuid);
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(allegiancePage, 0x10000260u));
+ Assert.Equal(2, listBox.ViewportForTest!.Children.Count);
+ }
+
+ /// Swear latches the WORLD selection at click time and, on
+ /// accept, sends the swear command for that exact guid.
+ [Fact]
+ public void Allegiance_Swear_ShowsConfirmation_AndSendsOnAccept()
+ {
+ const uint selfGuid = 100u;
+ const uint targetGuid = 500u;
+ var calls = new List();
+ Action? capturedCallback = null;
+ var selection = new AcDream.Core.Selection.SelectionState();
+ selection.Select(targetGuid, AcDream.Core.Selection.SelectionChangeSource.World);
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ calls,
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
+ selection: selection,
+ localPlayerGuid: selfGuid,
+ resolveWorldObjectName: guid => guid == targetGuid ? "Target Player" : null,
+ showConfirmation: (message, completed) =>
+ {
+ calls.Add($"confirm:{message}");
+ capturedCallback = completed;
+ return 1u;
+ });
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(calls, allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
+ var swearButton = Assert.IsType(UiElement.FindDescendant(allegiancePage, 0x10000263u));
+ swearButton.OnClick!();
+
+ Assert.Contains("confirm:Target Player", calls);
+ Assert.NotNull(capturedCallback);
+ capturedCallback!(true);
+ Assert.Contains($"allegiance-swear:{targetGuid:X8}", calls);
+ }
+
+ /// Break targets the CURRENT patron (latched at click time),
+ /// not the world/panel selection.
+ [Fact]
+ public void Allegiance_Break_TargetsCurrentPatron()
+ {
+ const uint selfGuid = 100u;
+ const uint patronGuid = 300u;
+ var patron = new RuntimeAllegianceMemberSnapshot(
+ patronGuid, 0u, true, "Sir Bob", 0, 0, 0, 0, 0u, 0u, 0, 0, false);
+ var calls = new List();
+ Action? capturedCallback = null;
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ calls,
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true },
+ patron: guid => guid == selfGuid ? patron : null,
+ localPlayerGuid: selfGuid,
+ showConfirmation: (message, completed) =>
+ {
+ calls.Add($"confirm:{message}");
+ capturedCallback = completed;
+ return 1u;
+ });
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(calls, allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
+ var breakButton = Assert.IsType(UiElement.FindDescendant(allegiancePage, 0x10000264u));
+ breakButton.OnClick!();
+
+ Assert.NotNull(capturedCallback);
+ capturedCallback!(true);
+ Assert.Contains($"allegiance-break:{patronGuid:X8}", calls);
+ }
+
+ /// Kick targets the panel-local SELECTED VASSAL ROW, not the
+ /// world selection (lane A §6.2 — Allegiance's list-selection message
+ /// has no SetSelectedObject call, unlike Fellowship's).
+ [Fact]
+ public void Allegiance_Kick_TargetsSelectedVassalRow()
+ {
+ const uint selfGuid = 100u;
+ const uint vassalGuid = 401u;
+ var vassal = new RuntimeAllegianceMemberSnapshot(
+ vassalGuid, selfGuid, true, "Vassal One", 0, 0, 0, 0, 0u, 0u, 0, 0, true);
+ var calls = new List();
+ Action? capturedCallback = null;
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ // The shared fixture-page FakeRowTemplateResolver (untagged, sized
+ // for Friends/Squelch's two-deep text nesting) doesn't tag its
+ // built row with the vassal row's own element ids — a real click
+ // target needs 0x10000268 resolvable, so this test supplies its own
+ // tagged resolver (mirrors SocialFellowshipPageControllerTests'
+ // hand-built row shape).
+ UiElement? TaggedVassalRowResolver(uint layoutId, uint elementId)
+ {
+ var row = new UiPanel();
+ var name = new UiText();
+ name.DatElementId = 0x10000268u;
+ row.AddChild(name);
+ return row;
+ }
+ SocialAllegiancePageController.Bindings bindings = MakeAllegianceBindings(
+ calls,
+ snapshot: new RuntimeAllegianceSnapshot { HasProfile = true, Revision = 1 },
+ vassals: guid => guid == selfGuid ? [vassal] : [],
+ member: guid => guid == vassalGuid ? vassal : null,
+ localPlayerGuid: selfGuid,
+ templateResolver: TaggedVassalRowResolver,
+ showConfirmation: (message, completed) =>
+ {
+ calls.Add($"confirm:{message}");
+ capturedCallback = completed;
+ return 1u;
+ });
+
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(calls, allegianceBindings: bindings));
+ Assert.NotNull(controller);
+
+ UiElement allegiancePage = UiElement.FindDescendant(controller!.TabPanel, 0x10000291u)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(allegiancePage, 0x10000260u));
+ var kickButton = Assert.IsType(UiElement.FindDescendant(allegiancePage, 0x10000265u));
+ Assert.False(kickButton.Enabled); // nothing selected yet
+
+ UiElement row = Assert.Single(listBox.ViewportForTest!.Children);
+ var rowName = Assert.IsType(UiElement.FindDescendant(row, 0x10000268u));
+ rowName.OnClick!();
+ controller!.Tick();
+ Assert.True(kickButton.Enabled);
+
+ kickButton.OnClick!();
+ Assert.NotNull(capturedCallback);
+ capturedCallback!(true);
+ Assert.Contains($"allegiance-kick:{vassalGuid:X8}", calls);
+ }
+
+ // ── CF-1: 0x001F subscription arming points ─────────────────────────────
+
+ /// CF-1's visible-branch arm, mirroring
+ /// FellowshipPageVisible_Declares0x00A6_OnlyWhenWindowShownANDFellowshipActive.
+ /// Allegiance is the DEFAULT tab, so the conjunction is already true the
+ /// instant the window is shown (no tab switch needed).
+ [Fact]
+ public void AllegiancePageVisible_DeclaresSubscription_OnWindowShown_AndClearsOnHidden()
+ {
+ var calls = new List();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(calls));
+ Assert.NotNull(controller);
+ controller!.ActivateTabs();
+ calls.Clear();
+
+ Assert.DoesNotContain(calls, c => c.StartsWith("allegiance-set-subscription"));
+
+ controller.OnShown();
+ Assert.Contains("allegiance-set-subscription:True", calls);
+
+ calls.Clear();
+ controller.OnHidden();
+ Assert.Contains("allegiance-set-subscription:False", calls);
+ }
+
+ ///
+ /// The exact bug class MF-3-REOPEN caught for Fellowship's 0x00A6,
+ /// re-verified here for Allegiance's 0x001F: a reconnect must
+ /// re-subscribe from the POST-world seam, UNCONDITIONALLY — even while
+ /// the panel is closed, because retail's own RecvNotice_PlayerDescReceived
+ /// arm does not check panel visibility either (§1.2).
+ ///
+ [Fact]
+ public void Reconnect_ReDeclaresSubscription_AfterWorldEntry_EvenWhilePanelClosed()
+ {
+ var calls = new List();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(calls));
+ Assert.NotNull(controller);
+ controller!.ActivateTabs(); // Allegiance is the default tab, window still closed
+ calls.Clear();
+
+ controller.ResetSessionDeclaration();
+ Assert.DoesNotContain(calls, c => c.StartsWith("allegiance-set-subscription"));
+
+ controller.RedeclareAfterWorldEntry();
+ Assert.Contains("allegiance-set-subscription:True", calls);
}
// ── Friends/Squelch read-only lists (item 5) ────────────────────────────
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
index c53a8fe3..c62fcf16 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
@@ -276,6 +276,134 @@ public sealed class SocialPanelLiveMountProbeTests
// 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)