using AcDream.Core.Net.Messages; namespace AcDream.Runtime.Gameplay; public readonly record struct RuntimeAllegianceOwnershipSnapshot( bool IsDisposed, bool HasProfile, int RecordCount) { public bool IsConverged => IsDisposed && !HasProfile && RecordCount == 0; } /// /// Canonical presentation-independent owner for the local player's /// allegiance profile — Campaign FA slice FA2 (2026-08-12). /// /// /// FA2 fix-round correction (2026-08-12, MUST-FIX 1 in /// docs/research/2026-08-12-fa2-review-mechanism.md): originally /// documented (and tested) as surviving reconnect, unlike /// . That was wrong on three counts — /// retail clears the profile at exactly this boundary /// (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 /// tail-calls AllegianceProfile::Clear, mirroring the sibling /// ClientFellowshipSystem::OnEndCharacterSession @0x005690A0 this /// owner's own fellowship sibling already honors); the cited precedent /// () CLEARS and /// re-latches on ResetSession, it does not persist; and the /// graphical host's connect path constructs /// LiveSessionConnectOptions with no character selector /// (SessionPlayerComposition.cs:1127), so a reconnect genuinely can /// select a different character at generation N+1 with nothing keying this /// owner's cached tree to a character identity. This owner is now a /// stage /// () exactly like /// : clears /// the profile AND drops , matching /// 's own /// clear-and-relatch shape precisely. The two owners remain separate /// classes because fellowship and allegiance are independent retail /// systems with independent wire families, not because their lifetimes /// differ. /// /// /// /// is a /// -style one-way /// latch distinguishing "no profile has arrived THIS generation" from /// "genuinely no allegiance" (a real push with a null monarch) — it clears /// at every generation reset (see above) and at terminal /// . /// /// /// /// Seeded ONLY by 0x0020 AllegianceUpdate (the unsolicited/ /// subscribed push — always about the local player's own tree). /// FA2 fix-round correction (MUST-FIX 2, same doc): this owner was /// previously also seeded, self-gated, by 0x027C /// AllegianceInfoResponse. Retail's own handler for that response /// (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470 /// unpacks into a STACK-LOCAL CAllegianceProfile that is destroyed on /// return; ClientAllegianceSystem:: /// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0 reads it /// only to print AddTextToScroll lines — retail's allegiance panel /// is fed exclusively by 0x0020. Seeding from 0x027C also /// fabricated (that wire /// carries no rank field) on any client whose first allegiance message was /// a self @allegiance info query. The chat-text output for /// @allegiance info is unaffected — see /// 's AllegianceInfoResponse /// registration, which still parses and prints it, just no longer forwards /// it to this owner. /// /// /// /// Wraps ClientCommandResponses.AllegianceMemberRecord — the /// FA1-assembled flat vassal list + monarch/patron/self blocks /// (AllegianceTree was DELETED at FA1; there is nothing left to /// wrap — see the seam-map addendum, /// docs/research/2026-08-11-fa-acdream-seams.md §8). The parent-index /// lookups (, /// , /// ) reuse /// ClientCommandResponses.AllegianceProfileLookups rather than /// re-implementing the walk a second time (FA2 fix-round SHOULD-FIX 5). /// /// public sealed class RuntimeAllegianceState : IDisposable { private readonly object _gate = new(); private ClientCommandResponses.AllegianceMemberRecord? _monarch; private IReadOnlyList _records = []; private string _allegianceName = string.Empty; private uint _totalMembers; private uint _totalVassals; private uint _rank; private bool _hasProfile; private bool _hasServerSeed; private long _revision; private bool _disposed; public RuntimeAllegianceState() => View = new AllegianceView(this); public IRuntimeAllegianceView View { get; } public bool IsDisposed { get { lock (_gate) return _disposed; } } /// /// Has any real allegiance push landed THIS generation? A one-way /// latch within a generation — see the class doc. Cleared by /// (every generation reset) and by terminal /// . /// public bool HasServerSeed { get { lock (_gate) return _hasServerSeed; } } /// 0x0020 AllegianceUpdate — the unsolicited/subscribed profile push; the ONLY inbound writer of this owner's profile (see the class doc's MUST-FIX 2 correction). public void ApplyUpdate(ClientCommandResponses.AllegianceUpdate update) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); _monarch = update.Monarch; _records = update.Records; _allegianceName = update.AllegianceName; _totalMembers = update.TotalMembers; _totalVassals = update.TotalVassals; _rank = update.Rank; _hasProfile = true; _hasServerSeed = true; Bump(); } } /// /// 0x027A AllegianceLoginNotification — bumps the revision so a /// polling consumer can observe the event happened; the retail-faithful /// two-line chat text this notice carries is NOT emitted here. Its /// literal retail string could not be verified from primary source /// (gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220 /// resolves its two candidate strings through Binary Ninja symbols that /// collide with unrelated vtable slot names — a DAT string-table /// lookup is needed before this can be added faithfully; see FA2's /// final report). Deliberately does not gate on the "already known" /// filter retail itself applies (lane C §1.6) — that is a display-time /// concern for the text this owner does not yet produce. /// public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); Bump(); } } /// 0x01C8 AllegianceUpdateDone — clears the panel busy latch; carries the WeenieError for a failed swear/break. public void ApplyUpdateDone(uint weenieError) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); Bump(); } } /// 0x0003 AllegianceUpdateAborted — declared by retail but never actually sent by ACE; parsed for forward-compat. public void ApplyUpdateAborted(uint weenieError) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); Bump(); } } public RuntimeAllegianceOwnershipSnapshot CaptureOwnership() { lock (_gate) return new RuntimeAllegianceOwnershipSnapshot( _disposed, _hasProfile, _records.Count); } /// /// FA2 fix-round MUST-FIX 1: the generation-reset stage /// () — clears the /// profile AND drops , mirroring /// 's /// clear-and-relatch exactly, and matching /// 's no-disposed-guard /// shape (blast SHOULD-FIX 5's precedent — /// and /// are /// bare delegations with no disposal guard; the reset transaction is /// retryable and disposal is terminal, so a guard here could never /// converge on retry). A no-op after — the fields /// are already cleared. /// public void ResetSession() { lock (_gate) ClearLocked(); } public void Dispose() { lock (_gate) { if (_disposed) return; ClearLocked(); _disposed = true; } } private void ClearLocked() { bool changed = _monarch is not null || _records.Count != 0 || _allegianceName.Length != 0 || _totalMembers != 0u || _totalVassals != 0u || _rank != 0u || _hasProfile || _hasServerSeed; _monarch = null; _records = []; _allegianceName = string.Empty; _totalMembers = 0u; _totalVassals = 0u; _rank = 0u; _hasProfile = false; _hasServerSeed = false; if (changed) Bump(); } private void Bump() => _revision++; private sealed class AllegianceView(RuntimeAllegianceState owner) : IRuntimeAllegianceView { public RuntimeAllegianceSnapshot Snapshot { get { lock (owner._gate) return new RuntimeAllegianceSnapshot( owner._revision, owner._hasServerSeed, owner._hasProfile, owner._rank, owner._totalMembers, owner._totalVassals, owner._allegianceName, owner._monarch?.CharacterId ?? 0u, owner._records.Count); } } public bool TryGetMonarch(out RuntimeAllegianceMemberSnapshot monarch) { lock (owner._gate) { if (owner._monarch is not { } record) { monarch = default; return false; } monarch = ToSnapshot(record); return true; } } // FA2 fix-round SHOULD-FIX 5 (blast review): reuse // ClientCommandResponses.AllegianceProfileLookups (promoted to // internal + AcDream.Runtime granted InternalsVisibleTo) instead of // re-implementing the FindData/GetPatron/FindVassals walk a second // time — two copies of a retail walk is the shape that drifts. public bool TryGetMember(uint guid, out RuntimeAllegianceMemberSnapshot member) { lock (owner._gate) { ClientCommandResponses.AllegianceMemberRecord? record = ClientCommandResponses.AllegianceProfileLookups.FindData( owner._monarch, owner._records, guid); if (record is not { } found) { member = default; return false; } member = ToSnapshot(found); return true; } } public bool TryGetPatron(uint guid, out RuntimeAllegianceMemberSnapshot patron) { lock (owner._gate) { ClientCommandResponses.AllegianceMemberRecord? record = ClientCommandResponses.AllegianceProfileLookups.FindPatron( owner._monarch, owner._records, guid); if (record is not { } found) { patron = default; return false; } patron = ToSnapshot(found); return true; } } public IEnumerable GetVassals(uint guid) { List result; lock (owner._gate) { // Lane C §4.4 point 3 (via AllegianceProfileLookups. // FindVassals): each new record is PREPENDED to its parent's // vassal list on assembly, so the walk visits siblings in // REVERSE wire order. Materialized into a List while holding // the lock — C# cannot `yield return` from inside a lock // block, so this is an intentional exception to this file's // "no allocation" view convention (blast SHOULD-FIX 7); a // per-frame panel poll should cache the result rather than // re-invoke every frame. result = new List(); foreach (ClientCommandResponses.AllegianceMemberRecord record in ClientCommandResponses.AllegianceProfileLookups.FindVassals(owner._records, guid)) { result.Add(ToSnapshot(record)); } } return result; } private static RuntimeAllegianceMemberSnapshot ToSnapshot( ClientCommandResponses.AllegianceMemberRecord record) => new( record.CharacterId, record.ParentGuid, record.IsLoggedIn, record.Name, record.Rank, record.Level, record.Loyalty, record.Leadership, record.CpCached, record.CpTithed, record.Gender, record.HeritageGroup, record.MayPassupExperience); } }