fix(net,runtime): FA2 fix-round MUST-FIX -- allegiance clears at reset, 0x027C stops seeding

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>
This commit is contained in:
Erik 2026-08-12 02:17:04 +02:00
parent 63649c8053
commit 4272ad0ea4
9 changed files with 425 additions and 229 deletions

View file

@ -36,19 +36,32 @@ public enum RuntimeGenerationResetStage
/// Campaign FA slice FA2 (2026-08-12): the fellowship roster is
/// session-scoped (a disconnect drops you from the fellowship
/// server-side) — clear it here, alongside the other social-list
/// stages. Allegiance is deliberately NOT a reset stage — it survives
/// reconnect (see <see cref="RuntimeAllegianceState"/>'s class doc).
/// stages.
/// </summary>
Fellowship = 12,
BeginEntityRetirement = 13,
RetireEntities = 14,
DrainHostProjection = 15,
CompleteCanonicalEntities = 16,
CompleteHostProjection = 17,
ChatIdentity = 18,
PlayerSnapshots = 19,
PlayerIdentity = 20,
Complete = 21,
/// <summary>
/// FA2 fix-round MUST-FIX 1 (2026-08-12,
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1): the
/// allegiance profile is ALSO cleared here — retail's
/// <c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
/// tail-calls <c>AllegianceProfile::Clear</c> at exactly this
/// per-character-session boundary, and the precedent this owner cites
/// (<see cref="RuntimeCharacterOptionsState.ResetSession"/>) clears AND
/// re-latches, it does not persist. See
/// <see cref="RuntimeAllegianceState"/>'s class doc for the full
/// correction (the original "survives reconnect" design was inverted
/// from the precedent it named).
/// </summary>
Allegiance = 13,
BeginEntityRetirement = 14,
RetireEntities = 15,
DrainHostProjection = 16,
CompleteCanonicalEntities = 17,
CompleteHostProjection = 18,
ChatIdentity = 19,
PlayerSnapshots = 20,
PlayerIdentity = 21,
Complete = 22,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@ -97,6 +110,7 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeCharacterState _character;
private readonly RuntimeLocalPlayerIdentityState _identity;
private readonly RuntimeFellowshipState _fellowship;
private readonly RuntimeAllegianceState _allegiance;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
private bool _hasCompletedGeneration;
@ -112,7 +126,8 @@ public sealed class RuntimeGenerationReset
RuntimeEntityObjectLifetime entityObjects,
RuntimeCharacterState character,
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship)
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@ -130,6 +145,8 @@ public sealed class RuntimeGenerationReset
?? throw new ArgumentNullException(nameof(identity));
_fellowship = fellowship
?? throw new ArgumentNullException(nameof(fellowship));
_allegiance = allegiance
?? throw new ArgumentNullException(nameof(allegiance));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@ -300,6 +317,9 @@ public sealed class RuntimeGenerationReset
case RuntimeGenerationResetStage.Fellowship:
Advance(state, _fellowship.ResetSession);
break;
case RuntimeGenerationResetStage.Allegiance:
Advance(state, _allegiance.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects