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);
}
}