acdream/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
Erik 4272ad0ea4 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>
2026-08-12 02:17:04 +02:00

426 lines
16 KiB
C#

using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests;
public sealed class RuntimeGenerationResetTests
{
[Fact]
public void PopulatedResetConvergesEveryCanonicalOwnerAndStampsRetiringGeneration()
{
using var runtime = Create();
const uint player = 0x50000001u;
const uint creature = 0x70000001u;
const uint item = 0x80000001u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.RegisterEntity(Spawn(creature, 3));
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "item",
ContainerId = player,
});
runtime.InventoryOwner.ExternalContainers.RequestOpen(creature);
runtime.InventoryOwner.ExternalContainers.ApplyViewContents(creature);
runtime.InventoryOwner.ItemMana.OnQueryItemManaResponse(
item,
0.5f,
valid: true);
runtime.CharacterOwner.Spellbook.OnSpellLearned(123u, 1f);
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
7u,
1u,
100u,
5u,
80u);
runtime.ActionOwner.Selection.Select(
creature,
SelectionChangeSource.System);
runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Missile);
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid(player);
runtime.CommunicationOwner.Chat.OnSystemMessage("retained", 1u);
runtime.CommunicationOwner.SpewBox.Enqueue("about to be torn down");
_ = runtime.MovementOwner.Execute(
RuntimeMovementCommand.ToggleRunLock);
var observer = new RecordingObserver();
using IDisposable subscription = runtime.Subscribe(observer);
var host = new RecordingResetHost(runtime);
var retiring = new RuntimeGenerationToken(9);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(creature, host.Retired[0].ServerGuid);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
Assert.All(
observer.Combat
.Concat(observer.Entity)
.Concat(observer.Inventory),
stamp => Assert.Equal(retiring, stamp.Generation));
Assert.Equal(
[1UL, 2UL, 3UL, 4UL, 5UL],
observer.Combat
.Concat(observer.Inventory)
.Concat(observer.Entity)
.OrderBy(static stamp => stamp.Sequence)
.Select(static stamp => stamp.Sequence));
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, runtime.Entities.Count);
Assert.Equal(0, runtime.Inventory.ObjectCount);
Assert.Equal(0, runtime.CharacterOwner.Spellbook.LearnedCount);
Assert.Null(runtime.CharacterOwner.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
Assert.Equal(0u, runtime.Actions.Snapshot.SelectedObjectId);
Assert.Equal(CombatMode.NonCombat, runtime.Actions.Snapshot.CombatMode);
Assert.False(runtime.MovementOwner.AutoRunActive);
Assert.Equal(1, runtime.CommunicationOwner.Chat.Count);
// SHOULD-FIX 1 (docs/research/2026-08-09-ch2-review-findings.md):
// ResetSpewBox was dead code — a fresh generation must not
// resurrect a stale refusal line. Assert BOTH that the pending
// enqueue never surfaces (no leftover Tick drains it into
// visibility) and that Reset itself converges Count to zero.
runtime.CommunicationOwner.SpewBox.Tick(0d);
Assert.Equal(0, runtime.CommunicationOwner.SpewBox.Count);
Assert.Null(
runtime.CommunicationOwner.CommandTargets.LastIncomingTellSender);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
}
[Fact]
public void FailedHostDrainRetriesOnlyTheExactUnfinishedSuffix()
{
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
runtime.EntityObjects.RegisterEntity(Spawn(0x70000002u, 2));
var host = new RecordingResetHost(runtime)
{
FailDrainOnce = true,
};
var retiring = new RuntimeGenerationToken(12);
RuntimeGenerationResetStageException failure =
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
RuntimeGenerationResetStage.DrainHostProjection,
failure.Stage);
Assert.Equal(2, host.Retired.Count);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(0, host.CompleteCalls);
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
RuntimeGenerationResetSnapshot pending =
runtime.GenerationReset.CaptureSnapshot();
Assert.True(pending.IsActive);
Assert.Equal(2, pending.RetirementCursor);
runtime.ResetGeneration(retiring, host);
Assert.Equal(2, host.Retired.Count);
Assert.Equal(2, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
[Fact]
public void FailedHostCompletionKeepsOldIdentityAndDoesNotReplayDrainOrEntities()
{
using var runtime = Create();
const uint player = 0x50000001u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
var host = new RecordingResetHost(runtime)
{
FailCompleteOnce = true,
};
var retiring = new RuntimeGenerationToken(17);
RuntimeGenerationResetStageException failure =
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
RuntimeGenerationResetStage.CompleteHostProjection,
failure.Stage);
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, runtime.EntityObjects.Entities.Count);
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(2, host.CompleteCalls);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
}
[Fact]
public void PendingResetRejectsHostReplacementAndReentrantReset()
{
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
var retiring = new RuntimeGenerationToken(4);
var host = new RecordingResetHost(runtime)
{
Reenter = true,
FailDrainOnce = true,
};
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.IsType<InvalidOperationException>(host.ReentrantFailure);
Assert.Throws<InvalidOperationException>(() =>
runtime.ResetGeneration(
retiring,
new RecordingResetHost(runtime)));
runtime.ResetGeneration(retiring, host);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
[Fact]
public void FellowshipAndAllegianceBothClearAtGenerationReset()
{
// 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(
[new GameEvents.FellowMember(
0x50000001u, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Self")],
"The Fellows",
LeaderGuid: 0x50000001u,
ShareXp: true,
EvenXpSplit: false,
OpenFellow: true,
Locked: false,
Departed: []));
runtime.AllegianceOwner.ApplyUpdate(new ClientCommandResponses.AllegianceUpdate(
Rank: 2u,
TotalMembers: 1u,
TotalVassals: 0u,
RecordCount: 1,
AllegianceName: "The Order",
Monarch: new ClientCommandResponses.AllegianceMemberRecord(
0x50000001u, 0u, true, "Self"),
Records: []));
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);
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()
{
var operations = new Operations();
return new GameRuntime(new GameRuntimeDependencies(
operations,
operations,
operations,
operations));
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort incarnation)
{
var position = new CreateObject.ServerPosition(
0x01010001u,
10f,
10f,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
1,
1,
1,
1,
0,
1,
0,
1,
incarnation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
guid.ToString("X8"),
null,
null,
null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class RecordingResetHost(GameRuntime runtime)
: IRuntimeGenerationResetHost
{
public List<RuntimeEntityRecord> Retired { get; } = [];
public int DrainCalls { get; private set; }
public int CompleteCalls { get; private set; }
public bool FailDrainOnce { get; set; }
public bool FailCompleteOnce { get; set; }
public bool Reenter { get; set; }
public Exception? ReentrantFailure { get; private set; }
public void RetireEntityProjection(RuntimeEntityRecord entity)
{
Retired.Add(entity);
if (!Reenter)
return;
Reenter = false;
ReentrantFailure = Record.Exception(() =>
runtime.ResetGeneration(
runtime.GenerationReset
.CaptureSnapshot()
.RetiringGeneration,
this));
}
public void DrainEntityProjectionBoundary()
{
DrainCalls++;
if (!FailDrainOnce)
return;
FailDrainOnce = false;
throw new InvalidOperationException("injected drain failure");
}
public void CompleteEntityProjectionRetirement()
{
CompleteCalls++;
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
if (!FailCompleteOnce)
return;
FailCompleteOnce = false;
throw new InvalidOperationException("injected completion failure");
}
}
private sealed class RecordingObserver : IRuntimeEventObserver
{
public List<RuntimeEventStamp> Entity { get; } = [];
public List<RuntimeEventStamp> Inventory { get; } = [];
public List<RuntimeEventStamp> Combat { get; } = [];
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
public void OnCommand(in RuntimeCommandDelta delta) { }
public void OnEntity(in RuntimeEntityDelta delta) =>
Entity.Add(delta.Stamp);
public void OnInventory(in RuntimeInventoryDelta delta) =>
Inventory.Add(delta.Stamp);
public void OnChat(in RuntimeChatDelta delta) { }
public void OnMovement(in RuntimeMovementDelta delta) { }
public void OnPortal(in RuntimePortalDelta delta) { }
public void OnCombat(in RuntimeCombatDelta delta) =>
Combat.Add(delta.Stamp);
}
private sealed class Operations :
IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
public bool CanStartAttack() => false;
public void PrepareAttackRequest() { }
public bool SendAttack(AttackHeight height, float power) => false;
public void SendCancelAttack() { }
public bool IsDualWield => false;
public bool PlayerReadyForAttack => false;
public bool AutoRepeatAttack => false;
public bool AutoTarget => false;
public uint? SelectClosestTarget() => null;
public bool IsInWorld => false;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(CombatMode mode) { }
public uint LocalPlayerId => 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
}