refused-drop yellow notice Item 4 (confirmation dialogs missing text + names): the missing retail mechanism was StringTable template substitution - an entry is N+1 literal fragments interleaved with N named variables, composed by StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7). ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2, the exact compute_str_hash space; Chorizite stores the variable hashes directly): - Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 -> ID_Allegiance_AcceptSwearConfirmation, injected into GameplayConfirmationController; null resolve falls back to the bare wire message, never invented English. The 2/3/5/6 " Continue?" family never consults the composer. - Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed the dangling "Do you wish to swear to ") is replaced by click-time ResolveTemplate with the target's name. All five templates verified token-free in the installed DAT - this is NOT a StringTableMetaLanguage port (AD-81's engine caveat stands). Item 5 (refused drop shows nothing; retail shows yellow top-center text): the prevRequest latch was ALREADY ported (InventoryTransactionState); what was missing was the consumer. InventoryTransactionState now raises RequestFailed(request, weenieError) when a 0x00A0 clears the latch; ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's "The <item> can't be <verb>" (verb table + suffix map ported verbatim in Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow top-center line. The dispatcher's second leg (@0x0055B342) also runs: outside the 7-code exclusion set, WeenieErrorMessages resolves per-code text/destination; 0x426 AttunedItem has no row in either place beyond the verb line - faithful single-line output. Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the token-free interleave is now ported; meta-token engine + FormatName remain), AD-93 filed (wire-guid-match vs retail's latched-guid preference; no Move/Wield latch kinds). Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate (constructed StringTable fixtures), +1 composer injection, +1 end-to-end refused-drop line. Core 4,697/1 skip, App 4,983/3 skips. Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
794 lines
36 KiB
C#
794 lines
36 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign FA slice FA5: the Allegiance page fully live — the <c>0x001F</c>
|
|
/// 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
|
|
/// <see cref="RuntimeAllegianceSnapshot.HasProfile"/> gate — see that
|
|
/// slice's own class doc, now superseded by this one.
|
|
///
|
|
/// <para>
|
|
/// <b>CF-1 — the data subscription.</b>
|
|
/// <c>docs/research/2026-08-11-fa-allegiance-wire.md</c> §1.2 pins THREE
|
|
/// retail arming points for <c>CM_Allegiance::Event_UpdateRequest(u32)</c>
|
|
/// (opcode <c>0x001F</c>), all sending <c>1</c> except the last:
|
|
/// <c>gmAllegianceUI::PostInit @0x004911C6</c>, <c>RecvNotice_PlayerDescReceived
|
|
/// @0x00490D40</c>, and <c>OnVisibilityChanged</c>'s visible/hidden branches
|
|
/// (<c>@0x004912DD</c>/<c>@0x00491311</c>). <see cref="Bind"/> attempts the
|
|
/// PostInit arm (almost always a pre-world no-op in this campaign's
|
|
/// process-lifetime mount — see <see cref="RedeclareAfterWorldEntry"/>'s own
|
|
/// doc for why that is not a gap); <see cref="RedeclareAfterWorldEntry"/> is
|
|
/// the PlayerDescReceived arm, wired to the LiveSession <c>EnteredWorld</c>
|
|
/// seam exactly like FA4's <c>0x00A6</c>; <see cref="SetPageVisible"/> is the
|
|
/// visible-branch arm, called by <see cref="SocialPanelController"/> on every
|
|
/// "window shown AND Allegiance active" transition. Without this,
|
|
/// <c>0x0020 AllegianceUpdate</c> never arrives on demand (lane C §2 row 4/5)
|
|
/// and the panel shows nothing — <c>0x027B AllegianceInfoResponse</c> is
|
|
/// text-only chat (FA2 MF-2's correction) and is NOT wired here.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>The FA4 MF-3-REOPEN lesson, applied here.</b> A reconnect's generation
|
|
/// reset runs BEFORE the new session is in world, where every Runtime
|
|
/// command is world-gated (<c>Inactive</c>, nothing sent).
|
|
/// <see cref="ResetPageVisibleLatch"/> (wired to the pre-world reset seam)
|
|
/// ONLY clears the local latch — it must never itself declare.
|
|
/// <see cref="RedeclareAfterWorldEntry"/> (wired to the POST-world seam)
|
|
/// does the actual send, and — unlike <see cref="SetPageVisible"/> — is
|
|
/// UNCONDITIONAL: it does not compare against the current latch first,
|
|
/// because retail's own <c>RecvNotice_PlayerDescReceived</c> arm does not
|
|
/// check current visibility either (§1.2's table: it always sends <c>1</c>).
|
|
/// 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Per-relationship empty-state gate (fix-round SF-7).</b>
|
|
/// <c>gmAllegianceUI::UpdateMonarchData @0x00491B40</c> hides the monarch
|
|
/// block when there is no monarch OR the monarch IS the viewer;
|
|
/// <c>UpdatePatronData @0x004917C0</c> hides the patron block when there is
|
|
/// no patron OR the patron IS the monarch (in which case the monarch block
|
|
/// relabels via <c>ID_Allegiance_PatronSlashMonarchLabel</c> and reveals its
|
|
/// <c>0x10000490</c> sub-block, byte-verified at the same two functions).
|
|
/// Both hidden branches blank their name text to a literal single space —
|
|
/// <c>UpdateMonarchData</c> ALSO blanks the monarch-followers text in its
|
|
/// hidden branch (<c>@0x00491FCD</c>), a detail the coarser FA3 gate did not
|
|
/// need to model.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Numeric-only text, no invented English (register row AD-85).</b> Every
|
|
/// retail string site beyond a bare name or a variable-free caption
|
|
/// (<c>ID_Allegiance_MonarchLabel</c>/<c>PatronSlashMonarchLabel</c>, which
|
|
/// carry NO <c>StringInfo</c> variables and so resolve and render exactly as
|
|
/// authored) needs <c>StringInfo</c> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Field sources, decompiled (`gmAllegianceUI::UpdatePlayerData
|
|
/// @0x00491330`, <c>UpdateMonarchData</c>, <c>UpdatePatronData</c>).</b>
|
|
/// <c>0x10000251</c> = the ALLEGIANCE's own name (<c>_allegiance.m_AllegianceName</c>
|
|
/// — "CharacterName" in the id is a misnomer), not the viewer's own name.
|
|
/// <c>0x10000252</c> ("your followers") = <c>_total_vassals</c> directly.
|
|
/// <c>0x10000258</c> (monarch followers) = <c>_total_members - 1</c>. The
|
|
/// self-rank field (<c>0x10000253</c>) queries a LIVE buffed quality
|
|
/// (<c>CBaseQualities::InqInt(qualities, 0x1e)</c>, i.e.
|
|
/// <c>PropertyInt.AllegianceRank</c>) plus <c>AllegianceData::GetTitle</c>'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 <see cref="RuntimeAllegianceSnapshot.Rank"/>
|
|
/// (same <c>0x0020</c> message, same underlying stat in every observed case)
|
|
/// rendered bare. The "experience passed up" text (<c>0x10000492</c>, doubled
|
|
/// under <c>0x10000490</c> and the patron field) and the vassal row's own
|
|
/// <c>0x10000269</c> both source <c>AllegianceData::_cp_tithed</c> — the
|
|
/// VIEWER's own tithed amount under the monarch/patron blocks, the VASSAL's
|
|
/// own tithed amount in each row.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Child of <see cref="MonarchFieldId"/> — shown only when the
|
|
/// viewer's patron IS the monarch (SF-7).</summary>
|
|
private const uint MonarchIsPatronSubBlockId = 0x10000490u;
|
|
|
|
/// <summary>Authored TWICE inside this page (§6 DISCIPLINE) — once under
|
|
/// <see cref="MonarchIsPatronSubBlockId"/>, once directly under
|
|
/// <see cref="PatronFieldId"/>. Every lookup MUST be scoped to the
|
|
/// correct parent; a flat <c>FindDescendant</c> from the page root would
|
|
/// non-deterministically pick one instance for both roles.</summary>
|
|
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<UiText.Line> BlankLine =
|
|
[new UiText.Line(" ", TextColor)];
|
|
private static readonly Func<IReadOnlyList<UiText.Line>> BlankLineProvider = () => BlankLine;
|
|
|
|
/// <summary>Sentinel passed to <see cref="SetProvider"/> for the "hidden,
|
|
/// blanked" state — deliberately NOT <see langword="null"/>, since the
|
|
/// backing <c>_lastXxx</c> fields also start at <see langword="null"/>
|
|
/// (uninitialized). Using <see langword="null"/> as both the sentinel
|
|
/// AND the initial value would make the very first blank-state
|
|
/// <see cref="Tick"/> a false "unchanged" no-op, leaving the widget at
|
|
/// its import-time (non-blank) text instead of retail's literal single
|
|
/// space.</summary>
|
|
private const string BlankSentinel = " blank ";
|
|
|
|
/// <summary>The live read/write seam this page binds to — mirrors
|
|
/// <see cref="SocialFellowshipPageController.Bindings"/>'s shape.
|
|
/// <see cref="Monarch"/>/<see cref="Patron"/>/<see cref="Member"/>/
|
|
/// <see cref="Vassals"/> project <see cref="IRuntimeAllegianceView"/>'s
|
|
/// <c>out</c>-param accessors into plain nullable-returning delegates.
|
|
/// <see cref="ResolveWorldObjectName"/> 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
|
|
/// <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)</c>, the SAME
|
|
/// resolver <c>ToolbarRuntimeBindings.ResolveName</c> already wires from
|
|
/// <c>ClientObjectTable</c>).</summary>
|
|
public sealed record Bindings(
|
|
Func<RuntimeAllegianceSnapshot> Snapshot,
|
|
Func<RuntimeAllegianceMemberSnapshot?> Monarch,
|
|
Func<uint, RuntimeAllegianceMemberSnapshot?> Patron,
|
|
Func<uint, RuntimeAllegianceMemberSnapshot?> Member,
|
|
Func<uint, IEnumerable<RuntimeAllegianceMemberSnapshot>> Vassals,
|
|
Func<uint, RuntimeCommandResult> Swear,
|
|
Func<uint, RuntimeCommandResult> Break,
|
|
Func<uint, RuntimeCommandResult> Kick,
|
|
Func<bool, RuntimeCommandResult> SetUpdateSubscription,
|
|
AcDream.Core.Selection.SelectionState Selection,
|
|
Func<uint> LocalPlayerGuid,
|
|
Func<CharacterOptionId, bool> CurrentCharacterOption,
|
|
Action<CharacterOptionId, bool> SetCharacterOption,
|
|
Func<uint, uint, UiElement?> TemplateResolver,
|
|
Func<uint, uint, string?> ResolveString,
|
|
Func<uint, string?> ResolveWorldObjectName,
|
|
Func<string, Action<bool>, 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<string, string, string?>? 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;
|
|
|
|
/// <summary>Bind-time-resolved (never per-tick — same discipline every
|
|
/// other <c>DatStringResolver</c> 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
|
|
/// <see cref="Bindings.ResolvePlayerTemplate"/> (retail's own
|
|
/// <c>MakeXxxConfirmationDialog</c> shape — 2026-08-13 AD-85 fix).</summary>
|
|
private readonly string? _monarchLabelCaption;
|
|
private readonly string? _patronSlashMonarchLabelCaption;
|
|
|
|
private readonly Dictionary<uint, VassalRowWidgets> _rows = new();
|
|
private readonly HashSet<uint> _vassalGuids = new();
|
|
|
|
private uint _selectedVassalGuid;
|
|
private long _lastRosterRevision = long.MinValue;
|
|
|
|
/// <summary>CF-1's local latch — see the class doc's CF-1 section.
|
|
/// Advances only on <see cref="RuntimeCommandStatus.Accepted"/>.</summary>
|
|
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);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>gmAllegianceUI::UpdateSwearButton @0x004908E0</c>'s dialog half —
|
|
/// the TARGET is latched here, at click time (retail's own
|
|
/// <c>m_iidPossibleNewPatron</c> 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).
|
|
/// </summary>
|
|
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 <target>?" — 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);
|
|
});
|
|
}
|
|
|
|
/// <summary><c>gmAllegianceUI::UpdateBreakButton @0x004909D0</c> +
|
|
/// <c>MakeBreakConfirmationDialog @0x00492BF0</c>: target = the viewer's
|
|
/// current patron, latched at click time.</summary>
|
|
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);
|
|
});
|
|
}
|
|
|
|
/// <summary><c>MakeKickConfirmationDialog @0x00492E10</c>: target = the
|
|
/// currently-selected vassal ROW (panel-local selection,
|
|
/// <c>m_iidSelectedVassal</c> — NOT the world selection; lane A §6.2
|
|
/// confirms Allegiance's list-selection message has no
|
|
/// <c>ACCWeenieObject::SetSelectedObject</c> call, unlike Fellowship's).</summary>
|
|
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);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-reads live state and applies retail's per-relationship gate,
|
|
/// roster diff, and button-enable rules. Called every frame from
|
|
/// <see cref="SocialPanelController.Tick"/> unconditionally (same
|
|
/// reasoning FA3 established: cheap even while the panel is hidden — no
|
|
/// DAT access outside the revision-gated roster rebuild branch).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diffs the live vassal GUID set against the roster — unchanged set
|
|
/// updates rows in place; a real join/leave/kick rebuilds via
|
|
/// <see cref="UiTemplateListBox.FlushPreservingScroll"/> (the FA3/FA4
|
|
/// carry-forward — do NOT reintroduce the FA3 <c>Flush()</c> scroll
|
|
/// reset here).
|
|
/// </summary>
|
|
private void RefreshRoster(uint selfGuid)
|
|
{
|
|
if (_vassalListBox is null) return;
|
|
|
|
var vassals = new List<RuntimeAllegianceMemberSnapshot>(_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<RuntimeAllegianceMemberSnapshot> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// CF-1's visible-branch arm — edge-triggered exactly like FA4's
|
|
/// <c>SocialFellowshipPageController.SetPageVisible</c>. Called by
|
|
/// <see cref="SocialPanelController"/> on every "window shown AND
|
|
/// Allegiance active" transition, and once from <see cref="Bind"/> as
|
|
/// the PostInit attempt (see class doc).
|
|
/// </summary>
|
|
public void SetPageVisible(bool visible)
|
|
{
|
|
if (_subscribed == visible) return;
|
|
if (_bindings.SetUpdateSubscription(visible).Status == RuntimeCommandStatus.Accepted)
|
|
_subscribed = visible;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pre-world generation-reset seam — clears the latch WITHOUT sending
|
|
/// anything, mirroring <c>SocialFellowshipPageController.ResetPageVisibleLatch</c>.
|
|
/// </summary>
|
|
public void ResetPageVisibleLatch() => _subscribed = false;
|
|
|
|
/// <summary>
|
|
/// CF-1's PlayerDescReceived arm — the post-world <c>EnteredWorld</c>
|
|
/// 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
|
|
/// <see cref="SetPageVisible"/>.
|
|
/// </summary>
|
|
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<IReadOnlyList<UiText.Line>> provider)
|
|
{
|
|
if (text is null) return;
|
|
if (lastValue == sentinelValue) return;
|
|
lastValue = sentinelValue;
|
|
text.LinesProvider = provider;
|
|
}
|
|
}
|