diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index 32ee8252..bb348818 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -110,11 +110,6 @@ public static class GameEventWiring
Action? onFellowshipDismiss = null,
Action? onFellowshipDisband = null,
Action? onAllegianceUpdate = null,
- // Self-gated: fires only when the response's TargetGuid is the
- // local player's own guid (see the registration below) — a
- // by-name @allegiance info query on ANOTHER player must not
- // overwrite the Runtime allegiance owner's own-tree snapshot.
- Action? onAllegianceInfoResponseSelf = null,
Action? onAllegianceUpdateDone = null,
Action? onAllegianceUpdateAborted = null,
Action? onAllegianceLoginNotification = null)
@@ -218,28 +213,31 @@ public static class GameEventWiring
// superseded handler only comes back if the newer registration's
// token is later disposed). The seam doc's "the dispatcher supports
// multiple owned handlers per type — both fire" claim
- // (docs/research/2026-08-11-fa-acdream-seams.md §2.3) does not hold
- // against the actual dispatcher; a literal second Register call
- // here would have silently killed the already-live `@allegiance
- // info` chat-text output the moment a caller supplied
- // onAllegianceInfoResponseSelf. Both behaviors are folded into this
- // ONE registration instead. Self-gated on TargetGuid == playerGuid()
- // so a by-name query against ANOTHER player's allegiance never
- // overwrites the Runtime owner's own-tree snapshot; skipped
- // entirely when no playerGuid resolver was supplied (matches every
- // other playerGuid-gated site above).
+ // (docs/research/2026-08-11-fa-acdream-seams.md §2.3, dated addendum
+ // added) does not hold against the actual dispatcher — a second
+ // Register call here would silently kill this already-live
+ // `@allegiance info` chat-text output.
+ //
+ // FA2 fix-round MUST-FIX 2 (2026-08-12,
+ // docs/research/2026-08-12-fa2-review-mechanism.md /
+ // docs/research/2026-08-12-fa2-review-blast.md): this handler
+ // previously ALSO forwarded to a Runtime allegiance-owner callback
+ // (self-gated on TargetGuid == playerGuid()). Retail's own handler
+ // for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent
+ // @0x006a7470) unpacks into a STACK-LOCAL profile destroyed on
+ // return and is consumed only by
+ // Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0's
+ // AddTextToScroll calls — retail's allegiance panel is fed
+ // EXCLUSIVELY by 0x0020 AllegianceUpdate. Forwarding also fabricated
+ // RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on
+ // any client whose first allegiance message was a self
+ // `@allegiance info` query. Restored to text-only, matching retail.
registrar.Register(GameEventType.AllegianceInfoResponse, e =>
{
var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span);
if (info is null) return;
foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value))
chat.OnSystemMessage(line, chatType: 0u);
- if (onAllegianceInfoResponseSelf is not null
- && playerGuid is not null
- && info.Value.TargetGuid == playerGuid())
- {
- onAllegianceInfoResponseSelf(info.Value);
- }
});
// ── Fellowship (Campaign FA slice FA2, 2026-08-12) ──────────────
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 293cc12c..da5f474a 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -223,10 +223,18 @@ public sealed class GameRuntime
faultInjection);
// Campaign FA slice FA2 (2026-08-12): two sibling J-owners, not
- // children of Communication — the reset semantics differ
- // (fellowship is session-scoped, allegiance survives reconnect;
- // docs/research/2026-08-11-fa-acdream-seams.md §1.3).
- context.Fellowship = new RuntimeFellowshipState();
+ // children of Communication. Originally documented as differing
+ // in reset semantics (fellowship session-scoped, allegiance
+ // surviving reconnect) — the FA2 fix-round MUST-FIX 1
+ // (docs/research/2026-08-12-fa2-review-mechanism.md) found that
+ // citation inverted (retail clears BOTH at
+ // OnEndCharacterSession) and corrected both owners to clear at
+ // every generation reset. They remain two owners because
+ // fellowship and allegiance are still independent retail
+ // systems with independent wire families — not because their
+ // lifetimes differ anymore.
+ context.Fellowship = new RuntimeFellowshipState(
+ timeProvider: dependencies.TimeProvider);
construction.Own(context.Fellowship);
Fault(
GameRuntimeConstructionPoint.FellowshipCreated,
@@ -293,7 +301,8 @@ public sealed class GameRuntime
context.EntityObjects,
context.Character,
context.PlayerIdentity,
- context.Fellowship);
+ context.Fellowship,
+ context.Allegiance);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@@ -713,16 +722,19 @@ public sealed class GameRuntime
| GameRuntimeTeardownStage.MovementDisposed
| GameRuntimeTeardownStage.CharacterDisposed
| GameRuntimeTeardownStage.InventoryDisposed,
- 9 => GameRuntimeTeardownStage.HostLeasesReleased
- | GameRuntimeTeardownStage.EventsDetached
- | GameRuntimeTeardownStage.SessionDisposed
- | GameRuntimeTeardownStage.TransitReset
- | GameRuntimeTeardownStage.ActionsDisposed
- | GameRuntimeTeardownStage.MovementDisposed
- | GameRuntimeTeardownStage.CharacterDisposed
- | GameRuntimeTeardownStage.InventoryDisposed
- | GameRuntimeTeardownStage.CommunicationDisposed
- | GameRuntimeTeardownStage.FellowshipDisposed,
+ // Blast MF-1 (docs/research/2026-08-12-fa2-review-blast.md,
+ // 2026-08-12 fix round): stage 9 disposes Fellowship (see
+ // DrainCurrentStage/case 9 below), so at _disposeStage == 9
+ // only stages 0..8 have COMPLETED — the flag set must end at
+ // CommunicationDisposed (9 flags), not include
+ // FellowshipDisposed. The original longhand spelling had one
+ // flag too many; restored to the self-checking subtraction form
+ // every other case already uses.
+ 9 => GameRuntimeTeardownStage.Complete
+ & ~GameRuntimeTeardownStage.FellowshipDisposed
+ & ~GameRuntimeTeardownStage.AllegianceDisposed
+ & ~GameRuntimeTeardownStage.IdentityDisposed
+ & ~GameRuntimeTeardownStage.EntityObjectsDisposed,
10 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs b/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
index ca059010..b8140a4c 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
@@ -15,29 +15,77 @@ public readonly record struct RuntimeAllegianceOwnershipSnapshot(
///
/// Canonical presentation-independent owner for the local player's
-/// allegiance profile — Campaign FA slice FA2 (2026-08-12). Survives
-/// reconnect (unlike , which is
-/// session-scoped): it is NOT a
-/// stage, and its data persists across
-/// a generation boundary the same way a re-login does not sever your
-/// character's allegiance membership. The
-/// latch is a -
-/// style one-way flag distinguishing "no profile has ever arrived" from
-/// "genuinely no allegiance" (a real push with a null monarch) — it only
-/// clears at terminal .
+/// 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.
+///
///
///
-/// Seeded by 0x0020 AllegianceUpdate (the unsolicited/subscribed
-/// push — always about the local player's own tree) and, self-gated, by
-/// 0x027C AllegianceInfoResponse when the response's TargetGuid is
-/// the local player's own guid (an explicit @allegiance info
-/// self-query; a by-name query against another player's tree is NOT
-/// applied here — see 's registration).
/// 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).
+/// 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
@@ -65,21 +113,22 @@ public sealed class RuntimeAllegianceState : IDisposable
}
///
- /// Has any real allegiance push landed since construction? A one-way
- /// latch — see the class doc. Never cleared by
- /// wiring (this owner has none); only clears it.
+ /// 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.
+ /// 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)
{
- ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
+ ObjectDisposedException.ThrowIf(_disposed, this);
_monarch = update.Monarch;
_records = update.Records;
_allegianceName = update.AllegianceName;
@@ -92,29 +141,6 @@ public sealed class RuntimeAllegianceState : IDisposable
}
}
- ///
- /// 0x027C AllegianceInfoResponse, self-gated by the caller
- /// ('s registration checks
- /// TargetGuid == playerGuid() before invoking this). Carries no
- /// rank field on the wire — the last known rank (if any) is retained.
- ///
- public void ApplyInfoResponseSelf(
- ClientCommandResponses.AllegianceInfoResponse response)
- {
- ObjectDisposedException.ThrowIf(IsDisposed, this);
- lock (_gate)
- {
- _monarch = response.Monarch;
- _records = response.Records;
- _allegianceName = response.AllegianceName;
- _totalMembers = response.TotalMembers;
- _totalVassals = response.TotalVassals;
- _hasProfile = true;
- _hasServerSeed = true;
- Bump();
- }
- }
-
///
/// 0x027A AllegianceLoginNotification — bumps the revision so a
/// polling consumer can observe the event happened; the retail-faithful
@@ -130,22 +156,31 @@ public sealed class RuntimeAllegianceState : IDisposable
///
public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn)
{
- ObjectDisposedException.ThrowIf(IsDisposed, this);
- lock (_gate) Bump();
+ 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)
{
- ObjectDisposedException.ThrowIf(IsDisposed, this);
- lock (_gate) Bump();
+ 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)
{
- ObjectDisposedException.ThrowIf(IsDisposed, this);
- lock (_gate) Bump();
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ Bump();
+ }
}
public RuntimeAllegianceOwnershipSnapshot CaptureOwnership()
@@ -157,23 +192,57 @@ public sealed class RuntimeAllegianceState : IDisposable
_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;
- _monarch = null;
- _records = [];
- _allegianceName = string.Empty;
- _totalMembers = 0u;
- _totalVassals = 0u;
- _rank = 0u;
- _hasProfile = false;
- _hasServerSeed = false;
+ 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)
@@ -211,23 +280,25 @@ public sealed class RuntimeAllegianceState : IDisposable
}
}
+ // 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)
{
- if (owner._monarch is { } monarch && monarch.CharacterId == guid)
+ ClientCommandResponses.AllegianceMemberRecord? record =
+ ClientCommandResponses.AllegianceProfileLookups.FindData(
+ owner._monarch, owner._records, guid);
+ if (record is not { } found)
{
- member = ToSnapshot(monarch);
- return true;
+ member = default;
+ return false;
}
- foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
- {
- if (record.CharacterId != guid) continue;
- member = ToSnapshot(record);
- return true;
- }
- member = default;
- return false;
+ member = ToSnapshot(found);
+ return true;
}
}
@@ -235,19 +306,16 @@ public sealed class RuntimeAllegianceState : IDisposable
{
lock (owner._gate)
{
- if (owner._monarch is { } monarch && monarch.CharacterId == guid)
+ ClientCommandResponses.AllegianceMemberRecord? record =
+ ClientCommandResponses.AllegianceProfileLookups.FindPatron(
+ owner._monarch, owner._records, guid);
+ if (record is not { } found)
{
- // Port of AllegianceProfile::GetPatron: the monarch has no patron.
patron = default;
return false;
}
- foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
- {
- if (record.CharacterId != guid) continue;
- return TryGetMemberLocked(record.ParentGuid, out patron);
- }
- patron = default;
- return false;
+ patron = ToSnapshot(found);
+ return true;
}
}
@@ -256,39 +324,25 @@ public sealed class RuntimeAllegianceState : IDisposable
List result;
lock (owner._gate)
{
- // Lane C §4.4 point 3: each new record is PREPENDED to its
- // parent's vassal list on assembly, so the walk visits
- // siblings in REVERSE wire order.
+ // 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();
- for (int i = owner._records.Count - 1; i >= 0; i--)
+ foreach (ClientCommandResponses.AllegianceMemberRecord record in
+ ClientCommandResponses.AllegianceProfileLookups.FindVassals(owner._records, guid))
{
- ClientCommandResponses.AllegianceMemberRecord record = owner._records[i];
- if (record.ParentGuid == guid)
- result.Add(ToSnapshot(record));
+ result.Add(ToSnapshot(record));
}
}
return result;
}
- private bool TryGetMemberLocked(
- uint guid,
- out RuntimeAllegianceMemberSnapshot member)
- {
- if (owner._monarch is { } monarch && monarch.CharacterId == guid)
- {
- member = ToSnapshot(monarch);
- return true;
- }
- foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
- {
- if (record.CharacterId != guid) continue;
- member = ToSnapshot(record);
- return true;
- }
- member = default;
- return false;
- }
-
private static RuntimeAllegianceMemberSnapshot ToSnapshot(
ClientCommandResponses.AllegianceMemberRecord record) =>
new(
diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs
index c2f1d055..cc17697e 100644
--- a/src/AcDream.Runtime/RuntimeGenerationReset.cs
+++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs
@@ -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 's class doc).
+ /// stages.
///
Fellowship = 12,
- BeginEntityRetirement = 13,
- RetireEntities = 14,
- DrainHostProjection = 15,
- CompleteCanonicalEntities = 16,
- CompleteHostProjection = 17,
- ChatIdentity = 18,
- PlayerSnapshots = 19,
- PlayerIdentity = 20,
- Complete = 21,
+ ///
+ /// 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
+ /// ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0
+ /// tail-calls AllegianceProfile::Clear at exactly this
+ /// per-character-session boundary, and the precedent this owner cites
+ /// () clears AND
+ /// re-latches, it does not persist. See
+ /// 's class doc for the full
+ /// correction (the original "survives reconnect" design was inverted
+ /// from the precedent it named).
+ ///
+ 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
diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
index 9427eb87..6182a01b 100644
--- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
@@ -241,27 +241,47 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
// reached identically by both hosts since LiveSessionEventRouter
// itself is shared (LiveSessionEventRouter.Attach, K-slice
// unification).
- onFellowshipFullUpdate: update =>
- social.Fellowship?.ApplyFullUpdate(update),
- onFellowshipUpdateFellow: update =>
- social.Fellowship?.ApplyUpdateFellow(update),
- onFellowshipQuit: quitterGuid =>
- social.Fellowship?.ApplyQuit(quitterGuid, inventory.PlayerGuid()),
- onFellowshipDismiss: dismissedGuid =>
- social.Fellowship?.ApplyDismiss(dismissedGuid, inventory.PlayerGuid()),
- onFellowshipDisband: () => social.Fellowship?.ApplyDisband(),
- onAllegianceUpdate: update =>
- social.Allegiance?.ApplyUpdate(update),
- onAllegianceInfoResponseSelf: response =>
- social.Allegiance?.ApplyInfoResponseSelf(response),
- onAllegianceUpdateDone: weenieError =>
- social.Allegiance?.ApplyUpdateDone(weenieError),
- onAllegianceUpdateAborted: weenieError =>
- social.Allegiance?.ApplyUpdateAborted(weenieError),
- onAllegianceLoginNotification: notice =>
- social.Allegiance?.ApplyLoginNotification(
+ //
+ // FA2 fix-round SHOULD-FIX 1 (2026-08-12,
+ // docs/research/2026-08-12-fa2-review-mechanism.md): pass
+ // each delegate hole CONDITIONALLY on the matching owner
+ // being supplied, rather than an always-non-null lambda that
+ // no-ops through `?.`. GameEventWiring only registers a
+ // handler (and so only counts toward
+ // GameEventDispatcher.GetUnhandledCount) when its delegate
+ // hole is non-null — an always-non-null lambda made every
+ // caller without an owner (bare-ChatLog tests, a future
+ // partial host) silently read 0 unhandled events for these
+ // 9 types even though the parse result was discarded.
+ onFellowshipFullUpdate: social.Fellowship is { } fellowshipFull
+ ? fellowshipFull.ApplyFullUpdate
+ : null,
+ onFellowshipUpdateFellow: social.Fellowship is { } fellowshipUpdate
+ ? fellowshipUpdate.ApplyUpdateFellow
+ : null,
+ onFellowshipQuit: social.Fellowship is { } fellowshipQuit
+ ? quitterGuid => fellowshipQuit.ApplyQuit(quitterGuid, inventory.PlayerGuid())
+ : null,
+ onFellowshipDismiss: social.Fellowship is { } fellowshipDismiss
+ ? dismissedGuid => fellowshipDismiss.ApplyDismiss(dismissedGuid, inventory.PlayerGuid())
+ : null,
+ onFellowshipDisband: social.Fellowship is { } fellowshipDisband
+ ? fellowshipDisband.ApplyDisband
+ : null,
+ onAllegianceUpdate: social.Allegiance is { } allegianceUpdate
+ ? allegianceUpdate.ApplyUpdate
+ : null,
+ onAllegianceUpdateDone: social.Allegiance is { } allegianceUpdateDone
+ ? allegianceUpdateDone.ApplyUpdateDone
+ : null,
+ onAllegianceUpdateAborted: social.Allegiance is { } allegianceUpdateAborted
+ ? allegianceUpdateAborted.ApplyUpdateAborted
+ : null,
+ onAllegianceLoginNotification: social.Allegiance is { } allegianceLogin
+ ? notice => allegianceLogin.ApplyLoginNotification(
notice.CharacterGuid,
- notice.IsLoggedIn)));
+ notice.IsLoggedIn)
+ : null));
ConstructionCheckpoint();
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
index 30982524..5f7fd456 100644
--- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -1698,36 +1698,36 @@ public sealed class GameEventWiringTests
}
[Fact]
- public void WireAll_AllegianceInfoResponse_SelfGated_FiresOnlyForOwnGuid()
+ public void WireAll_AllegianceInfoResponse_IsTextOnly_ForSelfAndOtherGuidsAlike()
{
+ // FA2 fix-round MUST-FIX 2 (docs/research/2026-08-12-fa2-review-mechanism.md):
+ // 0x027C AllegianceInfoResponse no longer forwards to a Runtime
+ // allegiance-owner callback at all — retail's own handler for this
+ // response is print-only over a stack-local profile
+ // (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent
+ // @0x006a7470 / Handle_Allegiance__AllegianceInfoResponseEvent
+ // @0x0056a1d0). The chat-text output must fire unconditionally for
+ // BOTH a self query and a by-name query about another player —
+ // there is no self-gate left to test.
const uint self = 0x50000001u;
const uint other = 0x50000002u;
var dispatcher = new GameEventDispatcher();
- ClientCommandResponses.AllegianceInfoResponse? observed = null;
int chatLines = 0;
var chat = new ChatLog();
chat.EntryAppended += _ => chatLines++;
GameEventWiring.WireAll(
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat,
- playerGuid: () => self,
- onAllegianceInfoResponseSelf: response => observed = response);
+ playerGuid: () => self);
- // A response about ANOTHER player: the self-gated Runtime callback
- // must NOT fire, but the pre-existing `@allegiance info` chat-text
- // handler (unconditional) must still fire unchanged.
byte[] otherWire = BuildMinimalAllegianceProfile(leadingField: other, monarchGuid: other, monarchName: "Other");
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.AllegianceInfoResponse, otherWire))!.Value);
- Assert.Null(observed);
Assert.True(chatLines > 0);
chatLines = 0;
byte[] selfWire = BuildMinimalAllegianceProfile(leadingField: self, monarchGuid: self, monarchName: "Self");
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.AllegianceInfoResponse, selfWire))!.Value);
- Assert.NotNull(observed);
- Assert.Equal(self, observed.Value.TargetGuid);
- // The chat-text handler fires for EVERY response, self or not.
Assert.True(chatLines > 0);
}
diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
index e2bdc415..8bf08678 100644
--- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
+++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Selection;
@@ -207,6 +208,54 @@ public sealed class GameRuntimeTests
Assert.Equal(0u, ownership.PlayerIdentity.ServerGuid);
}
+ [Fact]
+ public void CompletedTeardownStagesAccumulatesExactlyOneFlagPerStage()
+ {
+ // Blast MF-1 (docs/research/2026-08-12-fa2-review-blast.md): the
+ // FA2 rewrite of case 9 in the private CompletedTeardownStages
+ // switch claimed FellowshipDisposed one stage early (10 flags
+ // instead of 9) — invisible to both existing tests, which only
+ // sample the endpoints (stage 0 and Complete). This test walks
+ // every intermediate stage directly via reflection so the next
+ // owner insertion cannot repeat the class of bug: at
+ // _disposeStage == N, EXACTLY the first N stages' flags must be
+ // set, in DrainCurrentStage's disposal order.
+ using var runtime = Create();
+ FieldInfo stageField = typeof(GameRuntime).GetField(
+ "_disposeStage", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ PropertyInfo completedProperty = typeof(GameRuntime).GetProperty(
+ "CompletedTeardownStages", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ GameRuntimeTeardownStage[] orderedFlags =
+ [
+ GameRuntimeTeardownStage.HostLeasesReleased,
+ GameRuntimeTeardownStage.EventsDetached,
+ GameRuntimeTeardownStage.SessionDisposed,
+ GameRuntimeTeardownStage.TransitReset,
+ GameRuntimeTeardownStage.ActionsDisposed,
+ GameRuntimeTeardownStage.MovementDisposed,
+ GameRuntimeTeardownStage.CharacterDisposed,
+ GameRuntimeTeardownStage.InventoryDisposed,
+ GameRuntimeTeardownStage.CommunicationDisposed,
+ GameRuntimeTeardownStage.FellowshipDisposed,
+ GameRuntimeTeardownStage.AllegianceDisposed,
+ GameRuntimeTeardownStage.IdentityDisposed,
+ GameRuntimeTeardownStage.EntityObjectsDisposed,
+ ];
+
+ GameRuntimeTeardownStage expected = GameRuntimeTeardownStage.None;
+ for (int stage = 0; stage <= orderedFlags.Length; stage++)
+ {
+ stageField.SetValue(runtime, stage);
+ var actual = (GameRuntimeTeardownStage)completedProperty.GetValue(runtime)!;
+ Assert.Equal(expected, actual);
+ if (stage < orderedFlags.Length)
+ expected |= orderedFlags[stage];
+ }
+
+ Assert.Equal(GameRuntimeTeardownStage.Complete, expected);
+ }
+
private static GameRuntime Create() => new(Dependencies());
private static GameRuntimeDependencies Dependencies()
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs
index 588a7d1a..1a336269 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs
@@ -6,12 +6,19 @@ namespace AcDream.Runtime.Tests.Gameplay;
///
/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for
/// — seeding from the unsolicited
-/// AllegianceUpdate push and from a self-gated
-/// AllegianceInfoResponse, the HasServerSeed-style latch, the
-/// monarch/patron/vassal walk, revision monotonicity, and the "survives
-/// reconnect" ownership contract (no RuntimeGenerationReset stage —
-/// see for the sibling assertion
-/// that Fellowship IS a reset stage and Allegiance is not).
+/// AllegianceUpdate push, the HasServerSeed-style latch, the
+/// monarch/patron/vassal walk, revision monotonicity, and the
+/// generation-reset ownership contract.
+///
+///
+/// FA2 fix-round correction (2026-08-12,
+/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1/MF-2): this owner
+/// is now a RuntimeGenerationReset stage (see
+/// for the sibling assertion that
+/// BOTH Fellowship and Allegiance clear at reset), and no longer seeds from
+/// 0x027C AllegianceInfoResponse — retail's own handler for that
+/// response is print-only over a stack-local profile.
+///
///
public sealed class RuntimeAllegianceStateTests
{
@@ -63,35 +70,6 @@ public sealed class RuntimeAllegianceStateTests
Assert.Equal(4, snapshot.RecordCount);
}
- [Fact]
- public void ApplyInfoResponseSelf_SeedsTheProfileButLeavesRankUntouched()
- {
- var state = new RuntimeAllegianceState();
- state.ApplyUpdate(Update(rank: 9u));
-
- var response = new ClientCommandResponses.AllegianceInfoResponse(
- TargetGuid: SelfGuid,
- TotalMembers: 5u,
- TotalVassals: 3u,
- RecordCount: 5,
- AllegianceName: "The Order",
- Monarch: Record(MonarchGuid, 0u, "Monarch"),
- Records:
- [
- Record(PatronGuid, MonarchGuid, "Patron"),
- Record(SelfGuid, PatronGuid, "Self"),
- ]);
-
- state.ApplyInfoResponseSelf(response);
-
- RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
- Assert.True(snapshot.HasProfile);
- // AllegianceInfoResponse carries no rank field on the wire — the
- // last known rank from the earlier AllegianceUpdate is retained.
- Assert.Equal(9u, snapshot.Rank);
- Assert.Equal(2, snapshot.RecordCount);
- }
-
[Fact]
public void TryGetMonarchPatronAndVassals_WalkTheFlatRecordList()
{
@@ -182,4 +160,60 @@ public sealed class RuntimeAllegianceStateTests
Assert.Throws(
() => state.ApplyLoginNotification(SelfGuid, true));
}
+
+ [Fact]
+ public void ResetSession_ClearsProfileAndDropsHasServerSeed_MatchingOnEndCharacterSession()
+ {
+ // MF-1 (docs/research/2026-08-12-fa2-review-mechanism.md): retail's
+ // ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0
+ // tail-calls AllegianceProfile::Clear at exactly this boundary —
+ // the fix-round replaces the prior (inverted) "survives reconnect"
+ // test with this one.
+ var state = new RuntimeAllegianceState();
+ state.ApplyUpdate(Update(rank: 7u));
+ Assert.True(state.HasServerSeed);
+ Assert.True(state.View.Snapshot.HasProfile);
+
+ state.ResetSession();
+
+ RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
+ Assert.False(state.HasServerSeed);
+ Assert.False(snapshot.HasServerSeed);
+ Assert.False(snapshot.HasProfile);
+ Assert.Equal(0u, snapshot.Rank);
+ Assert.Equal(string.Empty, snapshot.AllegianceName);
+ Assert.Equal(0u, snapshot.MonarchGuid);
+ Assert.Equal(0, snapshot.RecordCount);
+ Assert.False(state.IsDisposed);
+ }
+
+ [Fact]
+ public void ResetSession_OnAnUnseededOwner_IsANoOpThatDoesNotBumpRevision()
+ {
+ var state = new RuntimeAllegianceState();
+ long before = state.View.Snapshot.Revision;
+
+ state.ResetSession();
+
+ Assert.Equal(before, state.View.Snapshot.Revision);
+ }
+
+ [Fact]
+ public void ResetSession_AfterDispose_IsANoOpAndDoesNotThrow()
+ {
+ // Blast SF-5: RuntimeFellowshipState.ResetSession has no disposed
+ // guard (matching RuntimeInventoryState.ResetExternalContainer /
+ // RuntimeCommunicationState.ResetNegotiatedChannels) because the
+ // reset transaction is retryable and disposal is terminal — a
+ // throwing guard could never converge on retry. Allegiance's new
+ // ResetSession matches that shape.
+ var state = new RuntimeAllegianceState();
+ state.ApplyUpdate(Update());
+ state.Dispose();
+
+ Exception? thrown = Xunit.Record.Exception(state.ResetSession);
+
+ Assert.Null(thrown);
+ Assert.False(state.View.Snapshot.HasProfile);
+ }
}
diff --git a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
index a13eebed..0878e08f 100644
--- a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
+++ b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
@@ -200,13 +200,20 @@ public sealed class RuntimeGenerationResetTests
}
[Fact]
- public void FellowshipClearsAtResetButAllegianceSurvivesReconnect()
+ public void FellowshipAndAllegianceBothClearAtGenerationReset()
{
- // Campaign FA slice FA2 (2026-08-12), D2: fellowship is
- // session-scoped (cleared at every generation reset, matching the
- // ExternalContainer precedent); allegiance is NOT a reset stage at
- // all — it survives reconnect exactly like a real disconnect does
- // not sever your character's allegiance membership.
+ // Campaign FA slice FA2 (2026-08-12), D2, CORRECTED by the FA2
+ // fix-round MUST-FIX 1 (2026-08-12,
+ // docs/research/2026-08-12-fa2-review-mechanism.md): this test
+ // previously asserted the opposite of retail's behavior —
+ // "allegiance survives reconnect". Retail's
+ // ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0
+ // tail-calls AllegianceProfile::Clear at exactly this boundary,
+ // mirroring the sibling ClientFellowshipSystem::
+ // OnEndCharacterSession @0x005690A0 fellowship already honored, and
+ // the cited precedent (RuntimeCharacterOptionsState.ResetSession)
+ // CLEARS and re-latches, it does not persist. Both owners are now
+ // RuntimeGenerationReset stages and both clear here.
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
@@ -231,16 +238,18 @@ public sealed class RuntimeGenerationResetTests
Assert.True(runtime.Fellowship.Snapshot.IsInFellowship);
Assert.True(runtime.Allegiance.Snapshot.HasProfile);
+ Assert.True(runtime.AllegianceOwner.HasServerSeed);
var host = new RecordingResetHost(runtime);
runtime.ResetGeneration(new RuntimeGenerationToken(3), host);
Assert.False(runtime.Fellowship.Snapshot.IsInFellowship);
Assert.Equal(0, runtime.Fellowship.Snapshot.MemberCount);
- // Allegiance is untouched by the reset — the data survives.
- Assert.True(runtime.Allegiance.Snapshot.HasProfile);
- Assert.True(runtime.AllegianceOwner.HasServerSeed);
- Assert.Equal("The Order", runtime.Allegiance.Snapshot.AllegianceName);
+ Assert.False(runtime.Allegiance.Snapshot.HasProfile);
+ Assert.False(runtime.AllegianceOwner.HasServerSeed);
+ Assert.Equal(string.Empty, runtime.Allegiance.Snapshot.AllegianceName);
+ Assert.Equal(0u, runtime.Allegiance.Snapshot.MonarchGuid);
+ Assert.Equal(0, runtime.Allegiance.Snapshot.RecordCount);
}
private static GameRuntime Create()