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 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. /// /// /// 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. /// /// /// /// 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. /// /// /// /// 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 IReadOnlyList BlankLine = [new UiText.Line(" ", TextColor)]; private static readonly Func> BlankLineProvider = () => BlankLine; /// 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, // (templateKey, playerName) -> the composed confirmation sentence via // StringTable::GetString's fragment/PLAYER-variable interleave // (DatStringResolver.ResolveTemplate). Null delegate or null result → // the caller falls back to the bare name, never invented English. Func? ResolvePlayerTemplate = null); 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 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 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. /// The swear/break/kick confirmation SENTENCES are not latched here: /// they need the target's name, so they compose at click time through /// (retail's own /// MakeXxxConfirmationDialog shape — 2026-08-13 AD-85 fix). private readonly string? _monarchLabelCaption; private readonly string? _patronSlashMonarchLabelCaption; 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, UiText? monarchLabel, UiText? monarchName, UiText? monarchFollowers, UiElement? monarchIsPatronSubBlock, UiText? monarchExperiencePassedUp, UiElement patronField, UiText? patronName, UiText? patronExperiencePassedUp, UiTemplateListBox? vassalListBox, UiButton? ignoreRequestsCheckbox, UiButton? swearButton, UiButton? breakButton, UiButton? kickButton, string? monarchLabelCaption, string? patronSlashMonarchLabelCaption) { _bindings = bindings; _selfName = selfName; _selfFollowers = selfFollowers; _selfRank = selfRank; _monarchField = monarchField; _monarchLabel = monarchLabel; _monarchName = monarchName; _monarchFollowers = monarchFollowers; _monarchIsPatronSubBlock = monarchIsPatronSubBlock; _monarchExperiencePassedUp = monarchExperiencePassedUp; _patronField = patronField; _patronName = patronName; _patronExperiencePassedUp = patronExperiencePassedUp; _vassalListBox = vassalListBox; _ignoreRequestsCheckbox = ignoreRequestsCheckbox; _swearButton = swearButton; _breakButton = breakButton; _kickButton = kickButton; _monarchLabelCaption = monarchLabelCaption; _patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption; } public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings) { ArgumentNullException.ThrowIfNull(pageRoot); ArgumentNullException.ThrowIfNull(bindings); if (UiElement.FindDescendant(pageRoot, MonarchFieldId) is not { } monarchField || UiElement.FindDescendant(pageRoot, PatronFieldId) is not { } patronField) { Console.WriteLine( "[D.2b] SocialAllegiancePageController: monarch/patron field " + $"containers (0x{MonarchFieldId:X8}/0x{PatronFieldId:X8}) not found — " + "allegiance page will not present."); return null; } // 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")); var controller = new SocialAllegiancePageController( bindings, selfName, selfFollowers, selfRank, monarchField, monarchLabel, monarchName, monarchFollowers, monarchIsPatronSubBlock, monarchExperiencePassedUp, patronField, patronName, patronExperiencePassedUp, vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton, monarchLabelCaption, patronSlashMonarchLabelCaption); 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; // 2026-08-13 gate fix ("can't press Ignore Allegiance requests — // always checked"): identical double-toggle to the fellowship page's // checkboxes — the authored ToggleBehavior self-flip made the old // `!Selected` read the flipped value and write the ORIGINAL back. // Same CH6a/b mirror discipline: suppress the self-flip, derive the // next value from the STORE, per-tick seeding mirrors the outcome. _ignoreRequestsCheckbox.SuppressSelfToggle = true; _ignoreRequestsCheckbox.OnClick = () => { bool next = !_bindings.CurrentCharacterOption(CharacterOptionId.IgnoreAllegianceRequests); _bindings.SetCharacterOption(CharacterOptionId.IgnoreAllegianceRequests, next); }; } /// /// 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; // "Do you wish to swear to ?" — StringTable template // ID_Allegiance_SwearConfirmation with the PLAYER slot filled at // click time (retail's MakeSwearConfirmationDialog). Null resolve → // the bare name, never invented English. string message = _bindings.ResolvePlayerTemplate?.Invoke( "ID_Allegiance_SwearConfirmation", name) ?? 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 = _bindings.ResolvePlayerTemplate?.Invoke( "ID_Allegiance_BreakConfirmation", patron.Name) ?? 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 = _bindings.ResolvePlayerTemplate?.Invoke( "ID_Allegiance_KickConfirmation", vassal.Name) ?? 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() { RuntimeAllegianceSnapshot snapshot = _bindings.Snapshot(); uint selfGuid = _bindings.LocalPlayerGuid(); RuntimeAllegianceMemberSnapshot? monarch = snapshot.HasProfile ? _bindings.Monarch() : null; RuntimeAllegianceMemberSnapshot? patron = snapshot.HasProfile ? _bindings.Patron(selfGuid) : null; 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); // 2026-08-13 gate ("On top it just says 0 then 0"): the bare numbers // gain their labels in the user-specified format — the full retail // StringInfo composition for these fields remains AD-85's gap; this // is its user-directed partial fill. SetLine(_selfFollowers, ref _lastSelfFollowers, $"Followers: {snapshot.TotalVassals}", TextColor); SetLine(_selfRank, ref _lastSelfRank, $"Rank: [{snapshot.Rank}]", 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); // 2026-08-13 gate: same label treatment as the self block's followers. SetLine( _monarchFollowers, ref _lastMonarchFollowers, $"Followers: {(snapshot.TotalMembers >= 1u ? snapshot.TotalMembers - 1u : 0u)}", 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) { // FA5 mechanism-review SF-1 (2026-08-12): retail's // UpdateVassalsData @004924c3 writes the vassal name with NO // colour change for offline — the offline cue is EXCLUSIVELY the // authored 0x100004AA marker (toggled below). The vassal name // always renders in the normal TextColor; greying it was an // invented visual and is removed. string name = vassal.Name; nameText.LinesProvider = () => [new UiText.Line(name, TextColor)]; } 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; } }