Two MUST-FIX findings from the FA2 mechanism/blast reviews (docs/research/2026-08-12-fa2-review-mechanism.md, docs/research/2026-08-12-fa2-review-blast.md): MF-1 (mechanism) -- RuntimeAllegianceState survived a generation reset, contradicting retail (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 tail-calls AllegianceProfile::Clear at the same boundary Fellowship already clears at), contradicting the precedent it cited (RuntimeCharacterOptionsState.ResetSession clears-and-relatches, it does not persist), and pinned by a test asserting the wrong behavior. Fixed: RuntimeAllegianceState.ResetSession() clears the profile and drops HasServerSeed; a new RuntimeGenerationResetStage.Allegiance stage runs it on every generation reset, mirroring RuntimeFellowshipState exactly. RuntimeGenerationResetTests' FellowshipClearsAtResetButAllegianceSurvivesReconnect inverted to FellowshipAndAllegianceBothClearAtGenerationReset. MF-2 (mechanism) / blast MF-2 -- 0x027C AllegianceInfoResponse fed the Runtime allegiance owner (self-gated). Retail's own handler for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470) unpacks into a stack-local profile destroyed on return; the consumer (Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0) only prints AddTextToScroll lines. Retail's panel is fed exclusively by 0x0020 AllegianceUpdate. The removed seeding also fabricated RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on any client whose first allegiance message was a self @allegiance info query. Fixed: dropped ApplyInfoResponseSelf, the onAllegianceInfoResponseSelf delegate hole, and the self-gate; 0x027C is text-only again, matching retail and the pre-FA2 shape. Also covers blast SHOULD-FIX 1 in the same edit to LiveSessionEventRouter.cs: the fellowship/allegiance delegate holes are now passed conditionally on the owner being supplied, so GameEventDispatcher.GetUnhandledCount reads correctly for callers without an owner (bare-ChatLog tests, a future partial host) instead of silently reading 0 for 9 event types whose parse result was discarded. RuntimeAllegianceState.cs and the two owners' Apply* mutators also move their ObjectDisposedException.ThrowIf checks inside the lock they already take (mechanism SHOULD-FIX 2) -- the prior check-then-lock shape let an inbound event on the decode thread race Dispose on the host thread and repopulate state after _disposed = true, permanently falsifying CaptureOwnership().IsConverged at teardown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
363 lines
14 KiB
C#
363 lines
14 KiB
C#
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical presentation-independent owner for the local player's
|
|
/// allegiance profile — Campaign FA slice FA2 (2026-08-12).
|
|
///
|
|
/// <para>
|
|
/// <b>FA2 fix-round correction (2026-08-12, MUST-FIX 1 in
|
|
/// docs/research/2026-08-12-fa2-review-mechanism.md):</b> originally
|
|
/// documented (and tested) as surviving reconnect, unlike
|
|
/// <see cref="RuntimeFellowshipState"/>. That was wrong on three counts —
|
|
/// retail clears the profile at exactly this boundary
|
|
/// (<c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
|
|
/// tail-calls <c>AllegianceProfile::Clear</c>, mirroring the sibling
|
|
/// <c>ClientFellowshipSystem::OnEndCharacterSession @0x005690A0</c> this
|
|
/// owner's own fellowship sibling already honors); the cited precedent
|
|
/// (<see cref="RuntimeCharacterOptionsState.HasServerSeed"/>) CLEARS and
|
|
/// re-latches on <c>ResetSession</c>, it does not persist; and the
|
|
/// graphical host's connect path constructs
|
|
/// <c>LiveSessionConnectOptions</c> with no character selector
|
|
/// (<c>SessionPlayerComposition.cs:1127</c>), 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
|
|
/// <see cref="RuntimeGenerationReset"/> stage
|
|
/// (<see cref="RuntimeGenerationResetStage.Allegiance"/>) exactly like
|
|
/// <see cref="RuntimeFellowshipState"/>: <see cref="ResetSession"/> clears
|
|
/// the profile AND drops <see cref="HasServerSeed"/>, matching
|
|
/// <see cref="RuntimeCharacterOptionsState.ResetSession"/>'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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <see cref="HasServerSeed"/> is a
|
|
/// <see cref="RuntimeCharacterOptionsState.HasServerSeed"/>-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
|
|
/// <see cref="Dispose"/>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Seeded ONLY by <c>0x0020 AllegianceUpdate</c> (the unsolicited/
|
|
/// subscribed push — always about the local player's own tree).
|
|
/// <b>FA2 fix-round correction (MUST-FIX 2, same doc):</b> this owner was
|
|
/// previously also seeded, self-gated, by <c>0x027C
|
|
/// AllegianceInfoResponse</c>. Retail's own handler for that response
|
|
/// (<c>CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470</c>
|
|
/// unpacks into a STACK-LOCAL <c>CAllegianceProfile</c> that is destroyed on
|
|
/// return; <c>ClientAllegianceSystem::
|
|
/// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0</c> reads it
|
|
/// only to print <c>AddTextToScroll</c> lines — retail's allegiance panel
|
|
/// is fed exclusively by <c>0x0020</c>. Seeding from <c>0x027C</c> also
|
|
/// fabricated <see cref="RuntimeAllegianceSnapshot.Rank"/> (that wire
|
|
/// carries no rank field) on any client whose first allegiance message was
|
|
/// a self <c>@allegiance info</c> query. The chat-text output for
|
|
/// <c>@allegiance info</c> is unaffected — see
|
|
/// <see cref="GameEventWiring"/>'s <c>AllegianceInfoResponse</c>
|
|
/// registration, which still parses and prints it, just no longer forwards
|
|
/// it to this owner.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Wraps <c>ClientCommandResponses.AllegianceMemberRecord</c> — the
|
|
/// FA1-assembled flat vassal list + monarch/patron/self blocks
|
|
/// (<c>AllegianceTree</c> 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 (<see cref="AllegianceView.TryGetMember"/>,
|
|
/// <see cref="AllegianceView.TryGetPatron"/>,
|
|
/// <see cref="AllegianceView.GetVassals"/>) reuse
|
|
/// <c>ClientCommandResponses.AllegianceProfileLookups</c> rather than
|
|
/// re-implementing the walk a second time (FA2 fix-round SHOULD-FIX 5).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class RuntimeAllegianceState : IDisposable
|
|
{
|
|
private readonly object _gate = new();
|
|
private ClientCommandResponses.AllegianceMemberRecord? _monarch;
|
|
private IReadOnlyList<ClientCommandResponses.AllegianceMemberRecord> _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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Has any real allegiance push landed THIS generation? A one-way
|
|
/// latch within a generation — see the class doc. Cleared by
|
|
/// <see cref="ResetSession"/> (every generation reset) and by terminal
|
|
/// <see cref="Dispose"/>.
|
|
/// </summary>
|
|
public bool HasServerSeed
|
|
{
|
|
get { lock (_gate) return _hasServerSeed; }
|
|
}
|
|
|
|
/// <summary><c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile push; the ONLY inbound writer of this owner's profile (see the class doc's MUST-FIX 2 correction).</summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>0x027A AllegianceLoginNotification</c> — 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
|
|
/// (<c>gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220</c>
|
|
/// 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.
|
|
/// </summary>
|
|
public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Bump();
|
|
}
|
|
}
|
|
|
|
/// <summary><c>0x01C8 AllegianceUpdateDone</c> — clears the panel busy latch; carries the WeenieError for a failed swear/break.</summary>
|
|
public void ApplyUpdateDone(uint weenieError)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Bump();
|
|
}
|
|
}
|
|
|
|
/// <summary><c>0x0003 AllegianceUpdateAborted</c> — declared by retail but never actually sent by ACE; parsed for forward-compat.</summary>
|
|
public void ApplyUpdateAborted(uint weenieError)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Bump();
|
|
}
|
|
}
|
|
|
|
public RuntimeAllegianceOwnershipSnapshot CaptureOwnership()
|
|
{
|
|
lock (_gate)
|
|
return new RuntimeAllegianceOwnershipSnapshot(
|
|
_disposed,
|
|
_hasProfile,
|
|
_records.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// FA2 fix-round MUST-FIX 1: the generation-reset stage
|
|
/// (<see cref="RuntimeGenerationResetStage.Allegiance"/>) — clears the
|
|
/// profile AND drops <see cref="HasServerSeed"/>, mirroring
|
|
/// <see cref="RuntimeCharacterOptionsState.ResetSession"/>'s
|
|
/// clear-and-relatch exactly, and matching
|
|
/// <see cref="RuntimeFellowshipState.ResetSession"/>'s no-disposed-guard
|
|
/// shape (blast SHOULD-FIX 5's precedent —
|
|
/// <see cref="RuntimeInventoryState.ResetExternalContainer"/> and
|
|
/// <see cref="RuntimeCommunicationState.ResetNegotiatedChannels"/> 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 <see cref="Dispose"/> — the fields
|
|
/// are already cleared.
|
|
/// </summary>
|
|
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<RuntimeAllegianceMemberSnapshot> GetVassals(uint guid)
|
|
{
|
|
List<RuntimeAllegianceMemberSnapshot> 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<RuntimeAllegianceMemberSnapshot>();
|
|
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);
|
|
}
|
|
}
|