diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index e9fbc226..7ffe2f50 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -815,6 +815,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerController,
d.WorldOrigin),
new LiveSessionDomainRuntime(
+ d.Runtime,
d.EntityObjects,
d.Character,
d.Actions,
diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs
index ca95ec1e..8085d957 100644
--- a/src/AcDream.App/Interaction/SelectionInteractionController.cs
+++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs
@@ -476,6 +476,29 @@ internal sealed class SelectionInteractionController
failures);
}
+ ///
+ /// Retires only App approach and retained item-interaction presentation.
+ /// Runtime resets the canonical interaction and selection owners once at
+ /// the shared generation boundary.
+ ///
+ internal void ResetGenerationPresentation()
+ {
+ List failures = [];
+ try { CancelPendingApproach(); }
+ catch (Exception error) { failures.Add(error); }
+ try { _items.ResetGenerationPresentation(); }
+ catch (Exception error) { failures.Add(error); }
+ try { _approachCompletions.Clear(); }
+ catch (Exception error) { failures.Add(error); }
+
+ if (failures.Count != 0)
+ {
+ throw new AggregateException(
+ "One or more selection-interaction presentation reset stages failed.",
+ failures);
+ }
+ }
+
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
{
if (_query.IsCreature(serverGuid))
diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs
index 39688f34..2cb99001 100644
--- a/src/AcDream.App/Net/LiveSessionResetManifest.cs
+++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs
@@ -1,6 +1,8 @@
namespace AcDream.App.Net;
using AcDream.App.World;
+using AcDream.Runtime;
+using AcDream.Runtime.Entities;
///
/// Focused owner operations composed by the App host for one character-session
@@ -11,31 +13,18 @@ internal sealed class LiveSessionResetBindings
{
public required Action MouseCapture { get; init; }
public required Action PlayerPresentation { get; init; }
- public required Action TeleportTransit { get; init; }
+ public required Action TeleportPresentation { get; init; }
public required Action SessionDialogs { get; init; }
- public required Action ChatCommandTargets { get; init; }
public required Action SettingsCharacterContext { get; init; }
public required Action EquippedChildren { get; init; }
- public required Action ExternalContainer { get; init; }
- public required Action InteractionAndSelection { get; init; }
- public required Action InventoryTransactions { get; init; }
+ public required Action InteractionPresentation { get; init; }
public required Action SelectionPresentation { get; init; }
- public required Action ObjectTable { get; init; }
- public required Action Spellbook { get; init; }
- public required Action MagicRuntime { get; init; }
- public required Action CombatAttack { get; init; }
- public required Action CombatState { get; init; }
- public required Action ItemMana { get; init; }
- public required Action LocalPlayer { get; init; }
- public required Action Friends { get; init; }
- public required Action Squelch { get; init; }
- public required Action TurbineChat { get; init; }
public required Action ParticleVisibility { get; init; }
public required Action InboundEventFifo { get; init; }
public required Action LiveLiveness { get; init; }
- public required Action LiveRuntime { get; init; }
- public required Action RenderSceneProjection { get; init; }
- public required Action SessionIdentity { get; init; }
+ public required Action RuntimeGeneration { get; init; }
+ public required Action SessionIdentityPresentation
+ { get; init; }
public required Action RemoteTeleport { get; init; }
public required Action NetworkEffects { get; init; }
public required Action AnimationHookFrames { get; init; }
@@ -59,38 +48,23 @@ internal static class LiveSessionResetManifest
[
new("mouse capture", bindings.MouseCapture),
new("player presentation", bindings.PlayerPresentation),
- new("teleport transit", bindings.TeleportTransit),
+ new("teleport presentation", bindings.TeleportPresentation),
new("session dialogs", bindings.SessionDialogs),
- new("chat command targets", bindings.ChatCommandTargets),
new("settings character context", bindings.SettingsCharacterContext),
// Attachment projections own GL-backed registrations and must leave
// before canonical live GUID records are released.
new("equipped children", bindings.EquippedChildren),
- new("external container", bindings.ExternalContainer),
- new("interaction and selection", bindings.InteractionAndSelection),
- new("inventory transactions", bindings.InventoryTransactions),
+ new("interaction presentation", bindings.InteractionPresentation),
new("selection presentation", bindings.SelectionPresentation),
- new("object table", bindings.ObjectTable),
- new("spellbook", bindings.Spellbook),
- new("magic runtime", bindings.MagicRuntime),
- new("combat attack", bindings.CombatAttack),
- new("combat state", bindings.CombatState),
- new("item mana", bindings.ItemMana),
- new("local player", bindings.LocalPlayer),
- new("friends", bindings.Friends),
- new("squelch", bindings.Squelch),
- new("turbine chat", bindings.TurbineChat),
new("particle visibility", bindings.ParticleVisibility),
new("inbound event fifo", bindings.InboundEventFifo),
new("live liveness", bindings.LiveLiveness),
- new("live runtime", bindings.LiveRuntime),
- // Canonical teardown appends exact projection removals. Drain them
- // before identity changes so no prior-session scene record or
- // journal entry can carry into the next character generation.
- new("render scene projection", bindings.RenderSceneProjection),
+ new("runtime generation", bindings.RuntimeGeneration),
// Identity must remain A until live teardown has converged so
// player-specific teardown can still classify the old record.
- new("session identity", bindings.SessionIdentity),
+ new(
+ "session identity presentation",
+ bindings.SessionIdentityPresentation),
new("remote teleport", bindings.RemoteTeleport),
// F754/F755 can precede CreateObject, so pending network effects
// must clear even when no LiveEntityRecord was constructed.
@@ -102,31 +76,33 @@ internal static class LiveSessionResetManifest
}
}
-/// Shared convergence gate for canonical live runtime teardown.
-internal static class LiveSessionEntityRuntimeReset
+///
+/// App's narrow borrowed projection acknowledgement for Runtime's canonical
+/// generation-reset transaction. It owns no reset cursor or entity identity.
+///
+internal sealed class GraphicalRuntimeGenerationResetHost
+ : IRuntimeGenerationResetHost
{
- public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime)
+ private readonly LiveEntityRuntime _entities;
+ private readonly Action _drainRenderProjection;
+
+ public GraphicalRuntimeGenerationResetHost(
+ LiveEntityRuntime entities,
+ Action drainRenderProjection)
{
- if (runtime is null)
- return;
- runtime.Clear();
- RequireConvergence(runtime);
+ _entities = entities
+ ?? throw new ArgumentNullException(nameof(entities));
+ _drainRenderProjection = drainRenderProjection
+ ?? throw new ArgumentNullException(nameof(drainRenderProjection));
}
- public static void RequireConvergence(LiveEntityRuntime? runtime)
- {
- if (runtime is null)
- return;
- if (runtime.Count == 0
- && runtime.PendingTeardownCount == 0
- && runtime.MaterializedCount == 0)
- {
- return;
- }
+ public void RetireEntityProjection(
+ RuntimeEntityRecord entity) =>
+ _entities.RetireGenerationProjection(entity);
- throw new InvalidOperationException(
- "The live-entity runtime has not converged after session clear " +
- $"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " +
- $"materialized={runtime.MaterializedCount}).");
- }
+ public void DrainEntityProjectionBoundary() =>
+ _drainRenderProjection();
+
+ public void CompleteEntityProjectionRetirement() =>
+ _entities.CompleteGenerationProjectionRetirement();
}
diff --git a/src/AcDream.App/Net/LiveSessionResetPlan.cs b/src/AcDream.App/Net/LiveSessionResetPlan.cs
index dbfba133..a7e61dbf 100644
--- a/src/AcDream.App/Net/LiveSessionResetPlan.cs
+++ b/src/AcDream.App/Net/LiveSessionResetPlan.cs
@@ -1,7 +1,19 @@
namespace AcDream.App.Net;
-/// One named owner operation in the live-session reset transaction.
-internal sealed record LiveSessionResetStage(string Name, Action Reset);
+using AcDream.Runtime;
+
+/// One named host operation around Runtime's generation reset.
+internal sealed record LiveSessionResetStage(
+ string Name,
+ Action Reset)
+{
+ public LiveSessionResetStage(string name, Action reset)
+ : this(
+ name,
+ _ => (reset ?? throw new ArgumentNullException(nameof(reset)))())
+ {
+ }
+}
///
/// Executes every session-state reset stage even when an earlier owner fails.
@@ -35,6 +47,9 @@ internal sealed class LiveSessionResetPlan
Array.ConvertAll(_stages, static stage => stage.Name);
public void Execute()
+ => Execute(default);
+
+ public void Execute(RuntimeGenerationToken retiringGeneration)
{
if (Interlocked.Exchange(ref _executing, 1) != 0)
throw new InvalidOperationException(
@@ -47,7 +62,7 @@ internal sealed class LiveSessionResetPlan
{
try
{
- stage.Reset();
+ stage.Reset(retiringGeneration);
}
catch (Exception error)
{
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index bacaabc3..d2bb6fc9 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -23,6 +23,7 @@ using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Content;
+using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
@@ -39,6 +40,7 @@ internal sealed record LiveSessionPlayerRuntime(
LiveWorldOriginState WorldOrigin);
internal sealed record LiveSessionDomainRuntime(
+ GameRuntime Runtime,
RuntimeEntityObjectLifetime EntityObjects,
RuntimeCharacterState Character,
RuntimeActionState Actions,
@@ -122,12 +124,18 @@ internal sealed class LiveSessionRuntimeFactory
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(connectOptions);
+ var resetHost = new GraphicalRuntimeGenerationResetHost(
+ _world.LiveEntities,
+ () => _world.RenderSceneShadow?.DrainUpdateBoundary());
+ LiveSessionResetPlan reset =
+ LiveSessionResetManifest.Create(
+ CreateResetBindings(resetHost));
return new LiveSessionHost(controller, new LiveSessionHostBindings(
Routing: new(
CreateEventRouter,
session => _commands.Attach(new LiveSessionCommandRouter(
CreateCommandBindings(session)))),
- Reset: LiveSessionResetManifest.Create(CreateResetBindings()).Execute,
+ Reset: reset.Execute,
Selection: new(
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
@@ -152,37 +160,26 @@ internal sealed class LiveSessionRuntimeFactory
connectOptions);
}
- private LiveSessionResetBindings CreateResetBindings() => new()
+ private LiveSessionResetBindings CreateResetBindings(
+ IRuntimeGenerationResetHost resetHost) => new()
{
MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation,
- TeleportTransit = _world.Teleport.ResetSession,
+ TeleportPresentation =
+ _world.Teleport.ResetGenerationPresentation,
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
- ChatCommandTargets = _domain.Communication.ResetCommandTargets,
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
EquippedChildren = _world.EquippedChildren.Clear,
- ExternalContainer = _domain.Inventory.ResetExternalContainer,
- InteractionAndSelection = _interaction.SelectionInteractions.ResetSession,
- InventoryTransactions = _domain.Inventory.ResetTransactions,
+ InteractionPresentation =
+ _interaction.SelectionInteractions.ResetGenerationPresentation,
SelectionPresentation = _world.SelectionScene.Reset,
- ObjectTable = _domain.EntityObjects.ClearObjects,
- Spellbook = _domain.Character.ResetSpellbook,
- MagicRuntime = () => _ui.Magic?.Reset(),
- CombatAttack = _interaction.CombatAttack.ResetSession,
- CombatState = _domain.Actions.Combat.Clear,
- ItemMana = _domain.Inventory.ResetItemMana,
- LocalPlayer = _domain.Character.ResetLocalPlayer,
- Friends = _domain.Communication.ResetFriends,
- Squelch = _domain.Communication.ResetSquelch,
- TurbineChat = _domain.Communication.ResetNegotiatedChannels,
ParticleVisibility = _world.ParticleVisibility.Reset,
InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear,
- LiveRuntime = ResetLiveRuntime,
- RenderSceneProjection =
- () => _world.RenderSceneShadow?.DrainUpdateBoundary(),
- SessionIdentity = ResetIdentity,
+ RuntimeGeneration = generation =>
+ _domain.Runtime.ResetGeneration(generation, resetHost),
+ SessionIdentityPresentation = ResetIdentityPresentation,
RemoteTeleport = _world.RemoteTeleport.Clear,
NetworkEffects = _world.EntityEffects.ClearNetworkState,
AnimationHookFrames = _world.AnimationHookFrames.Clear,
@@ -196,28 +193,28 @@ internal sealed class LiveSessionRuntimeFactory
_world.SpawnClaims.Reset();
}
- private void ResetLiveRuntime() =>
- LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
- _world.LiveEntities);
-
- private void ResetIdentity()
+ private void ResetIdentityPresentation(
+ RuntimeGenerationToken retiringGeneration)
{
- LiveSessionEntityRuntimeReset.RequireConvergence(_world.LiveEntities);
+ RuntimeGenerationResetSnapshot reset =
+ _domain.Runtime.GenerationReset.CaptureSnapshot();
+ if (reset.IsActive
+ || reset.LastCompletedGeneration != retiringGeneration)
+ {
+ throw new InvalidOperationException(
+ $"Runtime generation {retiringGeneration.Value} has not "
+ + "converged before App identity projection teardown.");
+ }
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
// components, and character option objects. CPlayerSystem::Begin
// @ 0x0055D410 resets player identity and session fields. The option
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
- _player.Identity.ServerGuid = 0u;
_ui.Vitals?.SetLocalPlayerGuid(0u);
- _domain.Communication.ResetChatIdentity();
EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey();
- _domain.Character.Options.ResetSession();
- _domain.Character.MovementSkills.ResetSession();
_world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState();
- _domain.Inventory.ResetPlayerSnapshots();
_ui.Paperdoll?.ResetSession();
// X/Y are ignored until the next logical player CreateObject claims a
diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
index 50a8b698..fc44888b 100644
--- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
+++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
@@ -23,6 +23,8 @@ internal interface ILocalPlayerTeleportNetworkSink
bool teleportTimestampAdvanced);
void ResetSession();
+
+ void ResetGenerationPresentation();
}
///
@@ -62,6 +64,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
public void ResetSession() => Required().ResetSession();
+ public void ResetGenerationPresentation() =>
+ Required().ResetGenerationPresentation();
+
private ILocalPlayerTeleportNetworkSink Required() =>
_inner ?? throw new InvalidOperationException(
"The local teleport sink was used before composition completed.");
@@ -567,7 +572,17 @@ internal sealed class LocalPlayerTeleportController
public void ResetSession()
{
ThrowIfDisposed();
- ResetTransit(clearSession: true);
+ ResetTransit(
+ clearSession: true,
+ resetCanonicalTransit: true);
+ }
+
+ public void ResetGenerationPresentation()
+ {
+ ThrowIfDisposed();
+ ResetTransit(
+ clearSession: true,
+ resetCanonicalTransit: false);
}
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
@@ -718,7 +733,9 @@ internal sealed class LocalPlayerTeleportController
return true;
}
- private long ResetTransit(bool clearSession)
+ private long ResetTransit(
+ bool clearSession,
+ bool resetCanonicalTransit = false)
{
long generation = checked(++_lifetimeGeneration);
@@ -733,7 +750,12 @@ internal sealed class LocalPlayerTeleportController
return generation;
if (clearSession)
- _worldReveal.ResetSession();
+ {
+ if (resetCanonicalTransit)
+ _worldReveal.ResetSession();
+ else
+ _worldReveal.ResetHostSession();
+ }
else
{
_transit.EndTeleport();
diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
index 6dd48fd6..e23bf3e0 100644
--- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
+++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
@@ -286,12 +286,22 @@ internal sealed class WorldRevealCoordinator
}
public void ResetSession()
+ {
+ ResetHostSession();
+ _transit.ResetSession();
+ }
+
+ ///
+ /// Retires only App streaming/presentation acknowledgements. The shared
+ /// Runtime generation-reset transaction clears canonical transit state
+ /// after this host boundary has converged.
+ ///
+ internal void ResetHostSession()
{
long generation = _transit.Snapshot.Generation;
if (generation != 0)
_transit.Cancel(generation);
RetryPendingHostWork();
- _transit.ResetSession();
}
///
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index 8ac8f607..db968d08 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -1182,27 +1182,44 @@ public sealed class ItemInteractionController : IDisposable
/// same numeric GUID can identify a different object.
///
public void ResetSession()
+ => ResetSessionCore(resetRuntime: true);
+
+ ///
+ /// Clears only retained UI transaction presentation. Runtime's canonical
+ /// transaction, interaction, and busy owners are reset once by the shared
+ /// generation-reset transaction.
+ ///
+ internal void ResetGenerationPresentation()
+ => ResetSessionCore(resetRuntime: false);
+
+ private void ResetSessionCore(bool resetRuntime)
{
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
- // Runtime owns the transaction reset. This controller preserves the
- // pre-J4 single UI notification emitted by InteractionState below.
- _transactions.StateChanged -= OnTransactionStateChanged;
- try
+ if (resetRuntime)
{
- _runtimeTransactions.ResetSession();
- }
- finally
- {
- if (!_disposed)
- _transactions.StateChanged += OnTransactionStateChanged;
+ // Runtime owns the transaction reset. This controller preserves
+ // the pre-J4 single UI notification emitted by InteractionState.
+ _transactions.StateChanged -= OnTransactionStateChanged;
+ try
+ {
+ _runtimeTransactions.ResetSession();
+ }
+ finally
+ {
+ if (!_disposed)
+ _transactions.StateChanged += OnTransactionStateChanged;
+ }
}
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;
List failures = [];
- try { _interactionState.ResetSession(); }
- catch (Exception error) { failures.Add(error); }
+ if (resetRuntime)
+ {
+ try { _interactionState.ResetSession(); }
+ catch (Exception error) { failures.Add(error); }
+ }
if (pendingPlacement is { } pending)
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index c9dbc111..378164ef 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -2230,6 +2230,76 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
IsCurrentRecord(expectedRecord)
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
+ ///
+ /// Retires only the graphical sidecar for an exact Runtime-owned
+ /// incarnation. The Runtime generation-reset transaction retains and
+ /// completes the canonical tombstone after this acknowledgement returns.
+ ///
+ internal void RetireGenerationProjection(
+ RuntimeEntityRecord canonical)
+ {
+ ArgumentNullException.ThrowIfNull(canonical);
+ if (_isClearing || _isRegisteringResources)
+ {
+ throw new InvalidOperationException(
+ _isClearing
+ ? "Live entity projection teardown is already in progress."
+ : "Live entity projection teardown cannot begin inside atomic resource registration.");
+ }
+
+ LiveEntityRecord? record = null;
+ if (_projections.TryGet(canonical, out LiveEntityRecord active))
+ {
+ if (!_projections.RemoveActive(active))
+ {
+ throw new InvalidOperationException(
+ $"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired during generation reset.");
+ }
+ _projections.RetainTeardown(active);
+ record = active;
+ }
+ else if (_projections.TryGetTeardown(
+ canonical,
+ out LiveEntityRecord retained))
+ {
+ record = retained;
+ }
+
+ if (record is null)
+ return;
+
+ _isClearing = true;
+ try
+ {
+ TearDownRecord(record, completeCanonical: false);
+ _projections.ReleaseTeardown(record);
+ }
+ finally
+ {
+ _isClearing = false;
+ }
+ }
+
+ ///
+ /// Completes the App-only projection boundary after Runtime has retired
+ /// every exact canonical incarnation. No canonical owner is mutated here.
+ ///
+ internal void CompleteGenerationProjectionRetirement()
+ {
+ if (_projections.ActiveCount != 0
+ || _projections.TeardownCount != 0)
+ {
+ throw new InvalidOperationException(
+ "Live entity projection storage has not converged at the generation-reset boundary.");
+ }
+
+ _spatialAnimations.Clear();
+ _physics.ClearSpatialWorksets();
+ _projections.ClearConverged();
+ _spatial.ClearLiveEntityLifetimeState();
+ _sessionClearPendingFinalization = false;
+ }
+
public void Clear()
{
if (_isClearing || _isRegisteringResources)
@@ -2670,7 +2740,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_sessionClearPendingFinalization = false;
}
- private void TearDownRecord(LiveEntityRecord record)
+ private void TearDownRecord(
+ LiveEntityRecord record,
+ bool completeCanonical = true)
{
if (record.TeardownInProgress)
throw new InvalidOperationException(
@@ -2730,8 +2802,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (record.WorldEntity is not null)
_ = RequireProjectionKey(record);
- _entityObjects.CompleteProjectionRetirement(
- record.Canonical);
+ if (completeCanonical)
+ {
+ _entityObjects.CompleteProjectionRetirement(
+ record.Canonical);
+ }
record.AnimationRuntime = null;
record.EffectProfile = null;
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
index 19b8dedd..c1444257 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
@@ -968,15 +968,55 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
_sessionClearInProgress = true;
Entities.BeginSessionClear();
- RuntimeEntityRecord[] retired = Entities.ActiveRecords.ToArray();
- foreach (RuntimeEntityRecord canonical in retired)
+ RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
+ foreach (RuntimeEntityRecord canonical in active)
{
if (!Entities.RemoveActive(canonical))
continue;
Entities.RetainTeardown(canonical);
PublishEntity(RuntimeEntityChange.Deleted, canonical);
}
- return retired;
+ return active;
+ }
+
+ public IReadOnlyList
+ CaptureSessionClearRetirements()
+ {
+ EnsureNotDisposed();
+ if (!_sessionClearInProgress)
+ {
+ throw new InvalidOperationException(
+ "Session-clear retirements are only available while the "
+ + "canonical clear transaction is active.");
+ }
+ return Entities.TeardownRecords.ToArray();
+ }
+
+ ///
+ /// Completes one exact incarnation retained by
+ /// after the borrowed host has retired its
+ /// presentation projection. Failure keeps the tombstone and local
+ /// identity intact so the same transaction cursor can retry this exact
+ /// record without reconstructing the retirement set.
+ ///
+ public void CompleteSessionEntityRetirement(
+ RuntimeEntityRecord canonical)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(canonical);
+ if (!_sessionClearInProgress
+ || !Entities.TryGetTeardown(
+ canonical.ServerGuid,
+ canonical.Incarnation,
+ out RuntimeEntityRecord retained)
+ || !ReferenceEquals(retained, canonical))
+ {
+ throw new InvalidOperationException(
+ $"Live entity 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} is not retained by the active session-clear transaction.");
+ }
+
+ CompleteProjectionRetirement(canonical);
+ Entities.ReleaseTeardown(canonical);
}
public bool CompleteSessionClearIfConverged()
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 6c00445c..f6b5be4f 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -58,6 +58,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
RuntimeSimulationOwnershipSnapshot Simulation,
RuntimeWorldEnvironmentOwnershipSnapshot Environment,
RuntimeWorldTransitOwnershipSnapshot Transit,
+ RuntimeGenerationResetSnapshot GenerationReset,
GameRuntimeEventOwnershipSnapshot Events)
{
public bool IsConverged =>
@@ -70,6 +71,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
&& PlayerIdentity.IsConverged
&& Simulation.IsConverged
&& Transit.IsSessionIdle
+ && GenerationReset.IsConverged
&& Events.IsConverged;
}
@@ -241,8 +243,19 @@ public sealed class GameRuntime
context,
faultInjection);
+ var generationReset = new RuntimeGenerationReset(
+ transit,
+ context.Communication,
+ context.Inventory,
+ context.Actions,
+ context.Movement,
+ context.EntityObjects,
+ context.Character,
+ context.PlayerIdentity);
+
context.EntityObjects.BindEventContext(
- () => context.Session.Generation,
+ () => generationReset.ActiveRetiringGeneration
+ ?? context.Session.Generation,
() => clock.FrameNumber);
context.Events = new GameRuntimeEventHub(
context.EntityObjects,
@@ -264,6 +277,7 @@ public sealed class GameRuntime
ActionOwner = context.Actions;
EnvironmentOwner = environment;
TransitOwner = transit;
+ GenerationReset = generationReset;
_events = context.Events;
construction.Complete();
}
@@ -285,6 +299,7 @@ public sealed class GameRuntime
public RuntimeLocalPlayerMovementState MovementOwner { get; }
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
public RuntimeWorldTransitState TransitOwner { get; }
+ public RuntimeGenerationReset GenerationReset { get; }
public RuntimeGenerationToken Generation => Session.Generation;
@@ -347,6 +362,11 @@ public sealed class GameRuntime
Portal.Snapshot,
Portal.Ownership);
+ public void ResetGeneration(
+ RuntimeGenerationToken retiringGeneration,
+ IRuntimeGenerationResetHost host) =>
+ GenerationReset.Reset(retiringGeneration, host);
+
public IDisposable Subscribe(IRuntimeEventObserver observer)
{
lock (_lifetimeGate)
@@ -393,6 +413,7 @@ public sealed class GameRuntime
MovementOwner),
EnvironmentOwner.CaptureOwnership(),
TransitOwner.CaptureOwnership(),
+ GenerationReset.CaptureSnapshot(),
_events.CaptureOwnership());
}
}
@@ -534,6 +555,7 @@ public sealed class GameRuntime
StopSession();
return Session.CaptureOwnership().IsConverged;
case 3:
+ GenerationReset.DrainPending();
TransitOwner.ResetSession();
return TransitOwner.CaptureOwnership().IsSessionIdle;
case 4:
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
index e6f5463a..c7e59f2d 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
@@ -134,6 +134,26 @@ public sealed class RuntimeLocalPlayerMovementState
Interlocked.Increment(ref _revision);
}
+ ///
+ /// Retires every character-generation movement owner while keeping the
+ /// process-scoped Runtime root reusable for the next session.
+ /// Graphical hosts release their camera/physics projections first; direct
+ /// hosts have no parallel projection to coordinate.
+ ///
+ public void ResetSession()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ bool changed =
+ _autoRunActive
+ || _controller is not null
+ || _preparingMotionOwner is not null;
+ _autoRunActive = false;
+ _controller = null;
+ _preparingMotionOwner = null;
+ if (changed)
+ Interlocked.Increment(ref _revision);
+ }
+
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
new(
_disposed,
diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs
new file mode 100644
index 00000000..419ad8e9
--- /dev/null
+++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs
@@ -0,0 +1,387 @@
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.World;
+
+namespace AcDream.Runtime;
+
+///
+/// The only host-specific edge in a character-generation reset. The host
+/// retires presentation borrowed from an exact Runtime incarnation; Runtime
+/// retains the retirement set, cursor, canonical cleanup, and generation.
+///
+public interface IRuntimeGenerationResetHost
+{
+ void RetireEntityProjection(RuntimeEntityRecord entity);
+
+ void DrainEntityProjectionBoundary();
+
+ void CompleteEntityProjectionRetirement();
+}
+
+public enum RuntimeGenerationResetStage
+{
+ None = 0,
+ Transit = 1,
+ CommandTargets = 2,
+ ExternalContainer = 3,
+ Actions = 4,
+ Movement = 5,
+ ObjectTable = 6,
+ Character = 7,
+ ItemMana = 8,
+ Friends = 9,
+ Squelch = 10,
+ NegotiatedChannels = 11,
+ BeginEntityRetirement = 12,
+ RetireEntities = 13,
+ DrainHostProjection = 14,
+ CompleteCanonicalEntities = 15,
+ CompleteHostProjection = 16,
+ ChatIdentity = 17,
+ PlayerSnapshots = 18,
+ PlayerIdentity = 19,
+ Complete = 20,
+}
+
+public readonly record struct RuntimeGenerationResetSnapshot(
+ bool IsActive,
+ bool IsExecuting,
+ RuntimeGenerationToken RetiringGeneration,
+ RuntimeGenerationToken LastCompletedGeneration,
+ RuntimeGenerationResetStage Stage,
+ int RetirementCount,
+ int RetirementCursor,
+ bool CurrentProjectionAcknowledged,
+ long TransactionId)
+{
+ public bool IsConverged =>
+ !IsActive
+ && !IsExecuting;
+}
+
+public sealed class RuntimeGenerationResetStageException(
+ RuntimeGenerationToken retiringGeneration,
+ RuntimeGenerationResetStage stage,
+ Exception innerException) : Exception(
+ $"Runtime generation {retiringGeneration.Value} reset stage "
+ + $"'{stage}' did not converge.",
+ innerException)
+{
+ public RuntimeGenerationToken RetiringGeneration { get; } =
+ retiringGeneration;
+
+ public RuntimeGenerationResetStage Stage { get; } = stage;
+}
+
+///
+/// One persisted, retryable reset transaction for every canonical owner in a
+/// reusable . A failed call retains the exact suffix:
+/// completed stages and exact-incarnation retirements never replay.
+///
+public sealed class RuntimeGenerationReset
+{
+ private readonly RuntimeWorldTransitState _transit;
+ private readonly RuntimeCommunicationState _communication;
+ private readonly RuntimeInventoryState _inventory;
+ private readonly RuntimeActionState _actions;
+ private readonly RuntimeLocalPlayerMovementState _movement;
+ private readonly RuntimeEntityObjectLifetime _entityObjects;
+ private readonly RuntimeCharacterState _character;
+ private readonly RuntimeLocalPlayerIdentityState _identity;
+ private ResetState? _state;
+ private RuntimeGenerationToken _lastCompletedGeneration;
+ private bool _hasCompletedGeneration;
+ private bool _executing;
+ private long _nextTransactionId;
+
+ internal RuntimeGenerationReset(
+ RuntimeWorldTransitState transit,
+ RuntimeCommunicationState communication,
+ RuntimeInventoryState inventory,
+ RuntimeActionState actions,
+ RuntimeLocalPlayerMovementState movement,
+ RuntimeEntityObjectLifetime entityObjects,
+ RuntimeCharacterState character,
+ RuntimeLocalPlayerIdentityState identity)
+ {
+ _transit = transit ?? throw new ArgumentNullException(nameof(transit));
+ _communication = communication
+ ?? throw new ArgumentNullException(nameof(communication));
+ _inventory = inventory
+ ?? throw new ArgumentNullException(nameof(inventory));
+ _actions = actions ?? throw new ArgumentNullException(nameof(actions));
+ _movement = movement
+ ?? throw new ArgumentNullException(nameof(movement));
+ _entityObjects = entityObjects
+ ?? throw new ArgumentNullException(nameof(entityObjects));
+ _character = character
+ ?? throw new ArgumentNullException(nameof(character));
+ _identity = identity
+ ?? throw new ArgumentNullException(nameof(identity));
+ }
+
+ public RuntimeGenerationToken? ActiveRetiringGeneration =>
+ _state?.Generation;
+
+ public RuntimeGenerationResetSnapshot CaptureSnapshot()
+ {
+ ResetState? state = _state;
+ return state is null
+ ? new RuntimeGenerationResetSnapshot(
+ false,
+ _executing,
+ default,
+ _lastCompletedGeneration,
+ _hasCompletedGeneration
+ ? RuntimeGenerationResetStage.Complete
+ : RuntimeGenerationResetStage.None,
+ 0,
+ 0,
+ false,
+ 0)
+ : new RuntimeGenerationResetSnapshot(
+ true,
+ _executing,
+ state.Generation,
+ _lastCompletedGeneration,
+ state.Stage,
+ state.Retirements?.Length ?? 0,
+ state.RetirementCursor,
+ state.CurrentProjectionAcknowledged,
+ state.TransactionId);
+ }
+
+ public void Reset(
+ RuntimeGenerationToken retiringGeneration,
+ IRuntimeGenerationResetHost host)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ if (_executing)
+ {
+ throw new InvalidOperationException(
+ "Runtime generation reset cannot run concurrently or reentrantly.");
+ }
+
+ ResetState state = AcquireState(retiringGeneration, host);
+ if (state.Stage is RuntimeGenerationResetStage.Complete)
+ return;
+
+ _executing = true;
+ try
+ {
+ Drain(state);
+ }
+ catch (Exception error)
+ {
+ throw new RuntimeGenerationResetStageException(
+ state.Generation,
+ state.Stage,
+ error);
+ }
+ finally
+ {
+ _executing = false;
+ }
+ }
+
+ internal void DrainPending()
+ {
+ ResetState? state = _state;
+ if (state is null)
+ return;
+ Reset(state.Generation, state.Host);
+ }
+
+ private ResetState AcquireState(
+ RuntimeGenerationToken generation,
+ IRuntimeGenerationResetHost host)
+ {
+ if (_state is { } pending)
+ {
+ if (pending.Generation != generation)
+ {
+ throw new InvalidOperationException(
+ $"Runtime generation {pending.Generation.Value} reset "
+ + $"must converge before generation {generation.Value} can reset.");
+ }
+ if (!ReferenceEquals(pending.Host, host))
+ {
+ throw new InvalidOperationException(
+ "An in-progress Runtime generation reset cannot replace "
+ + "its borrowed projection host.");
+ }
+ return pending;
+ }
+
+ if (_hasCompletedGeneration)
+ {
+ if (generation == _lastCompletedGeneration)
+ return ResetState.Completed(generation, host);
+ if (generation.Value < _lastCompletedGeneration.Value)
+ {
+ throw new InvalidOperationException(
+ $"Runtime generation {generation.Value} reset is stale; "
+ + $"generation {_lastCompletedGeneration.Value} already converged.");
+ }
+ }
+
+ var created = new ResetState(
+ generation,
+ host,
+ checked(++_nextTransactionId));
+ _state = created;
+ return created;
+ }
+
+ private void Drain(ResetState state)
+ {
+ while (state.Stage is not RuntimeGenerationResetStage.Complete)
+ {
+ switch (state.Stage)
+ {
+ case RuntimeGenerationResetStage.Transit:
+ Advance(state, _transit.ResetSession);
+ break;
+ case RuntimeGenerationResetStage.CommandTargets:
+ Advance(state, _communication.ResetCommandTargets);
+ break;
+ case RuntimeGenerationResetStage.ExternalContainer:
+ Advance(state, _inventory.ResetExternalContainer);
+ break;
+ case RuntimeGenerationResetStage.Actions:
+ Advance(state, _actions.ResetSession);
+ break;
+ case RuntimeGenerationResetStage.Movement:
+ Advance(state, _movement.ResetSession);
+ break;
+ case RuntimeGenerationResetStage.ObjectTable:
+ Advance(state, _entityObjects.ClearObjects);
+ break;
+ case RuntimeGenerationResetStage.Character:
+ Advance(state, _character.ResetSession);
+ break;
+ case RuntimeGenerationResetStage.ItemMana:
+ Advance(state, _inventory.ResetItemMana);
+ break;
+ case RuntimeGenerationResetStage.Friends:
+ Advance(state, _communication.ResetFriends);
+ break;
+ case RuntimeGenerationResetStage.Squelch:
+ Advance(state, _communication.ResetSquelch);
+ break;
+ case RuntimeGenerationResetStage.NegotiatedChannels:
+ Advance(
+ state,
+ _communication.ResetNegotiatedChannels);
+ break;
+ case RuntimeGenerationResetStage.BeginEntityRetirement:
+ _ = _entityObjects.BeginSessionClear();
+ state.Retirements = _entityObjects
+ .CaptureSessionClearRetirements()
+ .ToArray();
+ state.Stage = RuntimeGenerationResetStage.RetireEntities;
+ break;
+ case RuntimeGenerationResetStage.RetireEntities:
+ RetireCurrentEntity(state);
+ break;
+ case RuntimeGenerationResetStage.DrainHostProjection:
+ state.Host.DrainEntityProjectionBoundary();
+ state.Stage =
+ RuntimeGenerationResetStage.CompleteCanonicalEntities;
+ break;
+ case RuntimeGenerationResetStage.CompleteCanonicalEntities:
+ if (!_entityObjects.CompleteSessionClearIfConverged())
+ {
+ throw new InvalidOperationException(
+ "Canonical entity/object lifetime still owns an "
+ + "unacknowledged session retirement.");
+ }
+ state.Stage =
+ RuntimeGenerationResetStage.CompleteHostProjection;
+ break;
+ case RuntimeGenerationResetStage.CompleteHostProjection:
+ state.Host.CompleteEntityProjectionRetirement();
+ state.Stage = RuntimeGenerationResetStage.ChatIdentity;
+ break;
+ case RuntimeGenerationResetStage.ChatIdentity:
+ Advance(state, _communication.ResetChatIdentity);
+ break;
+ case RuntimeGenerationResetStage.PlayerSnapshots:
+ Advance(state, _inventory.ResetPlayerSnapshots);
+ break;
+ case RuntimeGenerationResetStage.PlayerIdentity:
+ _identity.ResetSession();
+ Complete(state);
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unsupported Runtime generation reset stage {state.Stage}.");
+ }
+ }
+ }
+
+ private void RetireCurrentEntity(ResetState state)
+ {
+ RuntimeEntityRecord[] retirements = state.Retirements ?? [];
+ if (state.RetirementCursor >= retirements.Length)
+ {
+ state.Stage = RuntimeGenerationResetStage.DrainHostProjection;
+ return;
+ }
+
+ RuntimeEntityRecord current = retirements[state.RetirementCursor];
+ if (!state.CurrentProjectionAcknowledged)
+ {
+ state.Host.RetireEntityProjection(current);
+ state.CurrentProjectionAcknowledged = true;
+ }
+
+ _entityObjects.CompleteSessionEntityRetirement(current);
+ state.CurrentProjectionAcknowledged = false;
+ state.RetirementCursor++;
+ }
+
+ private static void Advance(ResetState state, Action action)
+ {
+ action();
+ state.Stage++;
+ }
+
+ private void Complete(ResetState state)
+ {
+ state.Stage = RuntimeGenerationResetStage.Complete;
+ _lastCompletedGeneration = state.Generation;
+ _hasCompletedGeneration = true;
+ _state = null;
+ }
+
+ private sealed class ResetState
+ {
+ public ResetState(
+ RuntimeGenerationToken generation,
+ IRuntimeGenerationResetHost host,
+ long transactionId)
+ {
+ Generation = generation;
+ Host = host;
+ TransactionId = transactionId;
+ Stage = RuntimeGenerationResetStage.Transit;
+ }
+
+ public RuntimeGenerationToken Generation { get; }
+ public IRuntimeGenerationResetHost Host { get; }
+ public long TransactionId { get; }
+ public RuntimeGenerationResetStage Stage { get; set; }
+ public RuntimeEntityRecord[]? Retirements { get; set; }
+ public int RetirementCursor { get; set; }
+ public bool CurrentProjectionAcknowledged { get; set; }
+
+ public static ResetState Completed(
+ RuntimeGenerationToken generation,
+ IRuntimeGenerationResetHost host) =>
+ new(generation, host, 0)
+ {
+ Stage = RuntimeGenerationResetStage.Complete,
+ };
+ }
+}
diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs
index d5292e76..04efe483 100644
--- a/src/AcDream.Runtime/Session/LiveSessionController.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionController.cs
@@ -58,7 +58,7 @@ public sealed record LiveSessionStartResult(
public interface ILiveSessionLifecycleHost
{
LiveSessionBinding BindSession(WorldSession session);
- void ResetSessionState();
+ void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user);
void ReportConnected();
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
@@ -185,12 +185,14 @@ public sealed class LiveSessionController
{
private sealed class SessionScope(
WorldSession session,
- ILiveSessionLifecycleHost host)
+ ILiveSessionLifecycleHost host,
+ RuntimeGenerationToken generation)
{
private int _teardownStage;
public WorldSession Session { get; } = session;
public ILiveSessionLifecycleHost Host { get; } = host;
+ public RuntimeGenerationToken Generation { get; } = generation;
public LiveSessionBinding? Binding { get; set; }
public bool HostAttached { get; set; }
public RuntimeTeardownStage CompletedStages { get; private set; }
@@ -226,7 +228,7 @@ public sealed class LiveSessionController
}
if (_teardownStage == 3)
{
- Host.ResetSessionState();
+ Host.ResetSessionState(Generation);
CompletedStages |= RuntimeTeardownStage.HostReset;
_teardownStage = 4;
}
@@ -247,11 +249,15 @@ public sealed class LiveSessionController
LiveSessionConnectOptions? Options = null,
ILiveSessionLifecycleHost? Host = null);
+ private sealed record PendingHostReset(
+ ILiveSessionLifecycleHost Host,
+ RuntimeGenerationToken Generation);
+
private readonly object _gate = new();
private readonly ILiveSessionOperations _operations;
private SessionScope? _scope;
private SessionScope? _retiredScope;
- private ILiveSessionLifecycleHost? _pendingInitialResetHost;
+ private PendingHostReset? _pendingInitialReset;
private PendingOperation? _pendingOperation;
private int _operationDepth;
private bool _inWorld;
@@ -306,7 +312,7 @@ public sealed class LiveSessionController
_inWorld,
_scope is not null,
_retiredScope is not null,
- _pendingInitialResetHost is not null,
+ _pendingInitialReset is not null,
_pendingOperation is not null,
_operationDepth,
_generation,
@@ -460,7 +466,9 @@ public sealed class LiveSessionController
ILiveSessionLifecycleHost host)
{
ILiveSessionLifecycleHost? oldHost =
- _scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost;
+ _scope?.Host
+ ?? _retiredScope?.Host
+ ?? _pendingInitialReset?.Host;
ulong generationBeforeStop = _generation;
try
{
@@ -480,6 +488,7 @@ public sealed class LiveSessionController
ILiveSessionLifecycleHost host,
bool resetHost)
{
+ RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation;
try
{
@@ -487,7 +496,12 @@ public sealed class LiveSessionController
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
if (resetHost)
- ResetHostBeforeStart(host, generation);
+ {
+ ResetHostBeforeStart(
+ host,
+ generation,
+ resetGeneration);
+ }
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
@@ -509,7 +523,10 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
Console.WriteLine($"live: connecting to {endpoint} as {options.User}");
WorldSession session = _operations.CreateSession(endpoint);
- scope = new SessionScope(session, host);
+ scope = new SessionScope(
+ session,
+ host,
+ new RuntimeGenerationToken(generation));
_scope = scope;
_inWorld = false;
if (!IsCurrent(scope, generation))
@@ -614,7 +631,7 @@ public sealed class LiveSessionController
}
DrainRetiredScope();
DrainPendingInitialReset();
- if (_retiredScope is null && _pendingInitialResetHost is null)
+ if (_retiredScope is null && _pendingInitialReset is null)
_lastTeardownStages = RuntimeTeardownStage.Complete;
}
@@ -632,35 +649,38 @@ public sealed class LiveSessionController
private void ResetHostBeforeStart(
ILiveSessionLifecycleHost host,
- ulong generation)
+ ulong generation,
+ RuntimeGenerationToken resetGeneration)
{
bool requestedHostAlreadyReset = false;
- if (_pendingInitialResetHost is { } pending)
+ if (_pendingInitialReset is { } pending)
{
- pending.ResetSessionState();
- _pendingInitialResetHost = null;
- requestedHostAlreadyReset = ReferenceEquals(pending, host);
+ pending.Host.ResetSessionState(pending.Generation);
+ _pendingInitialReset = null;
+ requestedHostAlreadyReset =
+ ReferenceEquals(pending.Host, host);
}
if (requestedHostAlreadyReset || _generation != generation)
return;
try
{
- host.ResetSessionState();
+ host.ResetSessionState(resetGeneration);
}
catch
{
- _pendingInitialResetHost = host;
+ _pendingInitialReset =
+ new PendingHostReset(host, resetGeneration);
throw;
}
}
private void DrainPendingInitialReset()
{
- if (_pendingInitialResetHost is not { } host)
+ if (_pendingInitialReset is not { } pending)
return;
- host.ResetSessionState();
- _pendingInitialResetHost = null;
+ pending.Host.ResetSessionState(pending.Generation);
+ _pendingInitialReset = null;
}
private void Schedule(PendingOperation operation)
diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs
index 1a1a9def..049b8286 100644
--- a/src/AcDream.Runtime/Session/LiveSessionHost.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs
@@ -24,7 +24,7 @@ public sealed record LiveSessionEnteredWorldBindings(
public sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
- Action Reset,
+ Action Reset,
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
Action Connecting,
@@ -84,7 +84,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
- private readonly Action _reset;
+ private readonly Action _reset;
private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback;
@@ -188,13 +188,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
}
}
- private void ResetSessionState()
+ private void ResetSessionState(
+ RuntimeGenerationToken retiringGeneration)
{
// An incompletely detached route can still deliver callbacks into the
// state below. Treat physical route convergence as the same hard
// barrier used by normal LiveSessionBinding teardown.
DrainPendingRouteRollback();
- _reset();
+ _reset(retiringGeneration);
}
private void ApplySelection(LiveSessionCharacterSelection selection)
diff --git a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs
index 2e34741e..b0134366 100644
--- a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs
@@ -4,7 +4,7 @@ namespace AcDream.Runtime.Session;
public sealed record LiveSessionLifecycleBindings(
Func Bind,
- Action Reset,
+ Action Reset,
Action Connecting,
Action Connected,
Action Selected,
@@ -42,7 +42,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
return binding;
}
- public void ResetSessionState() => _bindings.Reset();
+ public void ResetSessionState(
+ RuntimeGenerationToken retiringGeneration) =>
+ _bindings.Reset(retiringGeneration);
public void ReportConnecting(string host, int port, string user) =>
_bindings.Connecting(host, port, user);
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
index 6335b165..33f4c3a1 100644
--- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
@@ -13,6 +13,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
+using AcDream.Runtime;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Net;
@@ -199,190 +200,107 @@ public sealed class LiveSessionResetPlanTests
}
[Fact]
- public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld()
+ public void Manifest_PreservesHostOrderAndForwardsExactRetiringGeneration()
{
- const uint playerA = 0x50000001u;
- const uint objectA = 0x70000001u;
- const uint landblock = 0x0101FFFFu;
- var staticEntity = Entity(42u, 0u);
- var spatial = new GpuWorldState();
- spatial.AddLandblock(new LoadedLandblock(
- landblock,
- new LandBlock(),
- new List { staticEntity }));
- var live = LiveEntityRuntimeFixture.Create(spatial, new FailingOnceResources());
- WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u);
- live.RegisterLiveEntity(spawn);
- live.MaterializeLiveEntity(
- playerA,
- 0x01010001u,
- id => Entity(id, playerA));
- spatial.MarkPersistent(playerA);
-
- var objects = new ClientObjectTable();
- objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" });
- var spells = new Spellbook();
- spells.OnSpellLearned(123u, 1f);
- var combat = new CombatState();
- combat.OnUpdateHealth(objectA, 0.5f);
- combat.SetCombatMode(CombatMode.Melee);
- var mana = new ItemManaState();
- mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true);
- var localPlayer = new LocalPlayerState(spells);
- localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u);
- var friends = new FriendsState();
- friends.Apply(new FriendsUpdate(
- FriendsUpdateType.Full,
- [new FriendEntry(objectA, "A", true, false, [], [])]));
- var squelch = new SquelchState();
- squelch.Replace(new SquelchDatabase(
- new Dictionary { ["A"] = 1u },
- new Dictionary(),
- new SquelchInfo("A", false, new HashSet())));
- var turbine = new TurbineChatState();
- turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
- var selection = new SelectionState();
- selection.Select(objectA, SelectionChangeSource.World);
- var external = new ExternalContainerState();
- external.RequestOpen(objectA);
- external.ApplyViewContents(objectA);
- var chat = new ChatLog();
- chat.SetLocalPlayerGuid(playerA);
- chat.OnSystemMessage("retained transcript", 1u);
-
- uint sessionIdentity = playerA;
- int savedDefaultSetting = 73;
- int activeSetting = savedDefaultSetting;
- var calls = new Dictionary(StringComparer.Ordinal);
- var dirty = ExpectedManifestNames().ToDictionary(
- static name => name,
- static _ => true,
- StringComparer.Ordinal);
-
- Action Stage(string name, Action? reset = null) => () =>
- {
- calls[name] = calls.GetValueOrDefault(name) + 1;
- reset?.Invoke();
- dirty[name] = false;
- };
-
- LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new()
+ var calls = new List();
+ RuntimeGenerationToken observed = default;
+ Action Stage(string name) => () => calls.Add(name);
+ var plan = LiveSessionResetManifest.Create(new()
{
MouseCapture = Stage("mouse capture"),
PlayerPresentation = Stage("player presentation"),
- TeleportTransit = Stage("teleport transit"),
+ TeleportPresentation = Stage("teleport presentation"),
SessionDialogs = Stage("session dialogs"),
- ChatCommandTargets = Stage("chat command targets"),
- SettingsCharacterContext = Stage(
- "settings character context",
- () => activeSetting = savedDefaultSetting),
+ SettingsCharacterContext = Stage("settings character context"),
EquippedChildren = Stage("equipped children"),
- ExternalContainer = Stage("external container", () => external.Reset()),
- InteractionAndSelection = Stage(
- "interaction and selection",
- () => selection.Reset()),
- InventoryTransactions = Stage("inventory transactions"),
+ InteractionPresentation = Stage("interaction presentation"),
SelectionPresentation = Stage("selection presentation"),
- ObjectTable = Stage("object table", objects.Clear),
- Spellbook = Stage("spellbook", spells.Clear),
- MagicRuntime = Stage("magic runtime"),
- CombatAttack = Stage("combat attack"),
- CombatState = Stage("combat state", combat.Clear),
- ItemMana = Stage("item mana", mana.Clear),
- LocalPlayer = Stage("local player", localPlayer.Clear),
- Friends = Stage("friends", friends.Clear),
- Squelch = Stage("squelch", squelch.Clear),
- TurbineChat = Stage("turbine chat", turbine.Reset),
ParticleVisibility = Stage("particle visibility"),
InboundEventFifo = Stage("inbound event fifo"),
LiveLiveness = Stage("live liveness"),
- LiveRuntime = Stage(
- "live runtime",
- () => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)),
- RenderSceneProjection = Stage("render scene projection"),
- SessionIdentity = Stage("session identity", () =>
+ RuntimeGeneration = generation =>
{
- LiveSessionEntityRuntimeReset.RequireConvergence(live);
- sessionIdentity = 0u;
- chat.ResetSessionIdentity();
- }),
+ observed = generation;
+ calls.Add("runtime generation");
+ },
+ SessionIdentityPresentation = _ =>
+ calls.Add("session identity presentation"),
RemoteTeleport = Stage("remote teleport"),
NetworkEffects = Stage("network effects"),
AnimationHookFrames = Stage("animation hook frames"),
LivePresentation = Stage("live presentation"),
- RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
+ RemoteMovementDiagnostics =
+ Stage("remote movement diagnostics"),
});
+ var retiring = new RuntimeGenerationToken(73);
+ plan.Execute(retiring);
+
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
- AggregateException first = Assert.Throws(plan.Execute);
+ Assert.Equal(ExpectedManifestNames(), calls);
+ Assert.Equal(retiring, observed);
+ }
+
+ [Fact]
+ public void GraphicalResetHost_RetriesExactProjectionBeforeIdentityClears()
+ {
+ const uint player = 0x50000001u;
+ const uint landblock = 0x0101FFFFu;
+ using GameRuntime runtime = GameRuntimeTestFactory.Create();
+ runtime.PlayerIdentity.ServerGuid = player;
+ var spatial = new GpuWorldState();
+ spatial.AddLandblock(new LoadedLandblock(
+ landblock,
+ new LandBlock(),
+ Array.Empty()));
+ var live = new LiveEntityRuntime(
+ spatial,
+ new FailingOnceResources(),
+ runtime.EntityObjects);
+ live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u));
+ live.MaterializeLiveEntity(
+ player,
+ 0x01010001u,
+ id => Entity(id, player));
+ var host = new GraphicalRuntimeGenerationResetHost(
+ live,
+ static () => { });
+ var retiring = new RuntimeGenerationToken(3);
+
+ RuntimeGenerationResetStageException first =
+ Assert.Throws(
+ () => runtime.ResetGeneration(retiring, host));
Assert.Equal(
- ["live runtime", "session identity"],
- first.InnerExceptions
- .Cast()
- .Select(static error => error.StageName));
- Assert.Equal(playerA, sessionIdentity);
+ RuntimeGenerationResetStage.RetireEntities,
+ first.Stage);
+ Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(1, live.PendingTeardownCount);
- Assert.Contains(staticEntity, spatial.Entities);
- Assert.Equal(1, spatial.PersistentGuidCount);
+ Assert.Equal(1, live.MaterializedCount);
- plan.Execute();
+ runtime.ResetGeneration(retiring, host);
- Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name]));
- Assert.DoesNotContain(dirty, static pair => pair.Value);
- Assert.Equal(0u, sessionIdentity);
- Assert.Equal(savedDefaultSetting, activeSetting);
+ Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, live.Count);
Assert.Equal(0, live.PendingTeardownCount);
Assert.Equal(0, live.MaterializedCount);
- Assert.Equal(0, spatial.PersistentGuidCount);
- Assert.Contains(staticEntity, spatial.Entities);
- Assert.True(spatial.IsLoaded(landblock));
- Assert.Null(objects.Get(objectA));
- Assert.Equal(0, spells.LearnedCount);
- Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
- Assert.False(combat.HasHealth(objectA));
- Assert.False(mana.HasMana(objectA));
- Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health));
- Assert.Empty(friends.Snapshot());
- Assert.Same(SquelchDatabase.Empty, squelch.Snapshot());
- Assert.False(turbine.Enabled);
- Assert.Null(selection.SelectedObjectId);
- Assert.Equal(0u, external.CurrentContainerId);
- Assert.Equal(1, chat.Count);
- chat.OnLocalSpeech("A", "after", playerA, isRanged: false);
- Assert.Equal("A", chat.Snapshot()[1].Sender);
}
private static string[] ExpectedManifestNames() =>
[
"mouse capture",
"player presentation",
- "teleport transit",
+ "teleport presentation",
"session dialogs",
- "chat command targets",
"settings character context",
"equipped children",
- "external container",
- "interaction and selection",
- "inventory transactions",
+ "interaction presentation",
"selection presentation",
- "object table",
- "spellbook",
- "magic runtime",
- "combat attack",
- "combat state",
- "item mana",
- "local player",
- "friends",
- "squelch",
- "turbine chat",
"particle visibility",
"inbound event fifo",
"live liveness",
- "live runtime",
- "render scene projection",
- "session identity",
+ "runtime generation",
+ "session identity presentation",
"remote teleport",
"network effects",
"animation hook frames",
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs
index 408c019b..50852c52 100644
--- a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs
@@ -2,6 +2,7 @@ using System.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
+using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
@@ -92,7 +93,8 @@ public sealed class LiveSessionShutdownIntegrationTests
deactivateCommands: static () => { },
detachEvents: static () => { });
- public void ResetSessionState() { }
+ public void ResetSessionState(
+ RuntimeGenerationToken retiringGeneration) { }
public void ReportConnecting(string host, int port, string user) { }
public void ReportConnected() { }
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index 87025d8c..476bcf3a 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -945,31 +945,17 @@ public sealed class CurrentGameRuntimeAdapterTests
{
MouseCapture = noop,
PlayerPresentation = noop,
- TeleportTransit = noop,
+ TeleportPresentation = noop,
SessionDialogs = noop,
- ChatCommandTargets = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,
- ExternalContainer = noop,
- InteractionAndSelection = noop,
- InventoryTransactions = noop,
+ InteractionPresentation = noop,
SelectionPresentation = noop,
- ObjectTable = noop,
- Spellbook = noop,
- MagicRuntime = noop,
- CombatAttack = noop,
- CombatState = noop,
- ItemMana = noop,
- LocalPlayer = noop,
- Friends = noop,
- Squelch = noop,
- TurbineChat = noop,
ParticleVisibility = noop,
InboundEventFifo = noop,
LiveLiveness = noop,
- LiveRuntime = noop,
- RenderSceneProjection = noop,
- SessionIdentity = noop,
+ RuntimeGeneration = _ => { },
+ SessionIdentityPresentation = _ => { },
RemoteTeleport = noop,
NetworkEffects = noop,
AnimationHookFrames = noop,
diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
index 8cf8aefd..1b6c9fcc 100644
--- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
@@ -850,6 +850,9 @@ public sealed class LocalPlayerTeleportControllerTests
public void ResetSession()
{
}
+ public void ResetGenerationPresentation()
+ {
+ }
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
diff --git a/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs b/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
new file mode 100644
index 00000000..e087e0ee
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
@@ -0,0 +1,650 @@
+using System.Numerics;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Tests.Support;
+using AcDream.Runtime.World;
+
+namespace AcDream.Runtime.Tests;
+
+public sealed class NoWindowGameRuntimeHostTests
+{
+ [Fact]
+ public void RootHostRunsPopulatedGenerationWithoutPresentationAssembly()
+ {
+ var host = new NoWindowGameRuntimeHost();
+
+ RuntimeSessionStartResult started = host.Start();
+ Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
+ Assert.Equal(0x50000001u, host.Runtime.PlayerIdentity.ServerGuid);
+ Assert.True(host.Deliver(runtime =>
+ {
+ runtime.EntityObjects.RegisterEntity(
+ Spawn(0x70000001u, 1));
+ runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = 0x80000001u,
+ Name = "fixture",
+ ContainerId = runtime.PlayerIdentity.ServerGuid,
+ });
+ runtime.CommunicationOwner.Chat.OnSystemMessage(
+ "fixture",
+ 1u);
+ }));
+ host.Advance(1d / 30d);
+ RuntimeStateCheckpoint populated =
+ host.Runtime.CaptureCheckpoint();
+ Assert.Equal(1, populated.EntityCount);
+ Assert.Equal(1, populated.InventoryObjectCount);
+ Assert.Equal(1, populated.ChatCount);
+
+ RuntimeTeardownAcknowledgement stopped = host.Stop();
+
+ Assert.True(stopped.IsComplete);
+ Assert.Equal(0u, host.Runtime.PlayerIdentity.ServerGuid);
+ Assert.Equal(0, host.Runtime.Entities.Count);
+ Assert.Equal(0, host.Runtime.Inventory.ObjectCount);
+ Assert.False(host.Deliver(_ =>
+ throw new InvalidOperationException("old route leaked")));
+ Assert.False(host.Execute(_ =>
+ throw new InvalidOperationException("old command leaked")));
+ Assert.Equal(1, host.ProjectionRetirementCount);
+ Assert.Equal(2, host.ProjectionDrainCount);
+ Assert.Equal(2, host.ProjectionCompletionCount);
+
+ string[] loaded = AppDomain.CurrentDomain.GetAssemblies()
+ .Select(static assembly =>
+ assembly.GetName().Name ?? string.Empty)
+ .ToArray();
+ Assert.DoesNotContain(loaded, static name =>
+ name.StartsWith(
+ "AcDream.App",
+ StringComparison.OrdinalIgnoreCase)
+ || name.StartsWith(
+ "AcDream.UI.",
+ StringComparison.OrdinalIgnoreCase)
+ || name.StartsWith(
+ "Silk.NET",
+ StringComparison.OrdinalIgnoreCase)
+ || name.StartsWith(
+ "OpenAL",
+ StringComparison.OrdinalIgnoreCase)
+ || name.StartsWith(
+ "Arch",
+ StringComparison.OrdinalIgnoreCase)
+ || name.StartsWith(
+ "ImGui",
+ StringComparison.OrdinalIgnoreCase));
+
+ host.Dispose();
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged);
+ }
+
+ [Fact]
+ public void ReconnectReusesRootButNotPriorGuidIncarnationOrRoutes()
+ {
+ const uint guid = 0x70000001u;
+ var host = new NoWindowGameRuntimeHost();
+ RuntimeSessionStartResult first = host.Start();
+ RuntimeGenerationToken firstGeneration = first.Generation;
+ Assert.True(host.Deliver(runtime =>
+ runtime.EntityObjects.RegisterEntity(Spawn(guid, 1))));
+ Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
+ guid,
+ out RuntimeEntityRecord firstRecord));
+
+ RuntimeSessionStartResult second = host.Reconnect();
+ RuntimeGenerationToken secondGeneration = second.Generation;
+
+ Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
+ Assert.NotEqual(firstGeneration, secondGeneration);
+ Assert.False(host.Runtime.EntityObjects.Entities.TryGetActive(
+ guid,
+ out _));
+ Assert.True(host.Deliver(runtime =>
+ runtime.EntityObjects.RegisterEntity(Spawn(guid, 2))));
+ Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
+ guid,
+ out RuntimeEntityRecord secondRecord));
+ Assert.NotSame(firstRecord, secondRecord);
+ Assert.Equal((ushort)2, secondRecord.Incarnation);
+ Assert.Equal(0, host.Runtime.EntityObjects.Entities
+ .PendingTeardownCount);
+ Assert.Contains(
+ $"events-:{firstGeneration.Value}",
+ host.LifecycleTrace);
+ Assert.Contains(
+ $"commands-:{firstGeneration.Value}",
+ host.LifecycleTrace);
+
+ host.Dispose();
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged);
+ }
+
+ [Fact]
+ public void DeterministicLifecycleUsesOneRootAcrossGameplayPortalAndReconnect()
+ {
+ const uint localGuid = 0x50000001u;
+ const uint reusedGuid = 0x70000001u;
+ const uint usableGuid = 0x70000002u;
+ const uint containerGuid = 0x80000001u;
+ const uint itemGuid = 0x80000002u;
+ const string passwordMarker = "credential-must-not-appear";
+ var host = new NoWindowGameRuntimeHost(
+ password: passwordMarker);
+
+ RuntimeStateCheckpoint empty = host.CaptureCheckpoint();
+ Assert.Equal(RuntimeLifecycleState.Constructed, empty.Lifecycle);
+ Assert.Equal(0, empty.EntityCount);
+ Assert.Equal(0, empty.InventoryObjectCount);
+ Assert.Equal(0, empty.ChatCount);
+
+ RuntimeSessionStartResult first = host.Start();
+ RuntimeGenerationToken firstGeneration = first.Generation;
+ Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status);
+ Assert.True(host.Deliver(firstGeneration, runtime =>
+ {
+ runtime.EntityObjects.RegisterEntity(Spawn(localGuid, 1));
+ runtime.EntityObjects.RegisterEntity(Spawn(reusedGuid, 1));
+ runtime.EntityObjects.RegisterEntity(Spawn(usableGuid, 1));
+ runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = containerGuid,
+ Name = "fixture pack",
+ ContainerId = localGuid,
+ });
+ runtime.EntityObjects.Objects.AddContainer(new Container
+ {
+ ObjectId = containerGuid,
+ Capacity = 24,
+ });
+ runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = itemGuid,
+ Name = "fixture item",
+ ContainerId = localGuid,
+ StackSize = 3,
+ StackSizeMax = 10,
+ });
+ Assert.True(runtime.EntityObjects.Objects.ApplyServerMove(
+ itemGuid,
+ containerGuid,
+ newWielderId: 0u,
+ newSlot: 0));
+ runtime.CommunicationOwner.Chat.OnSystemMessage(
+ "fixture system",
+ 1u);
+ runtime.CommunicationOwner.Chat.OnLocalSpeech(
+ "Runtime",
+ "fixture speech",
+ localGuid,
+ isRanged: false);
+ runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
+ vitalId: 1u,
+ ranks: 100u,
+ start: 100u,
+ xp: 50u,
+ current: 95u);
+ runtime.EnvironmentOwner.Initialize(
+ new RuntimeWorldEnvironmentDefinition(
+ originOffsetTicks: 0d,
+ sourceTickSize: 1d,
+ lightTickSize: 1d,
+ dayGroups: null));
+ runtime.EnvironmentOwner.SynchronizeFromServer(1234d);
+ }));
+
+ Assert.True(host.Move());
+ Assert.True(host.Use(usableGuid));
+ Assert.True(host.Attack());
+ Assert.True(host.CastFixtureSpell());
+ Assert.True(host.AdvanceProjectile());
+ Assert.True(host.CompletePortal());
+ host.Advance(1d / 30d);
+
+ RuntimeStateCheckpoint populated = host.CaptureCheckpoint();
+ Assert.Equal(RuntimeLifecycleState.InWorld, populated.Lifecycle);
+ Assert.Equal(4, populated.EntityCount);
+ Assert.Equal(2, populated.InventoryObjectCount);
+ Assert.Equal(1, populated.InventoryContainerCount);
+ Assert.Equal(2, populated.ChatCount);
+ Assert.Equal(1, populated.Character.LearnedSpellCount);
+ Assert.True(populated.Movement.AutoRunActive);
+ Assert.True(populated.Portal.Completed);
+ Assert.True(populated.Portal.WorldViewportObserved);
+ Assert.Equal(0, populated.TransitOwnership.HostProjectionCount);
+
+ Assert.Equal(
+ [
+ "movement:run",
+ $"use:{usableGuid:X8}",
+ "attack:prepare",
+ "attack:Medium:0.5",
+ "cast:stop",
+ "cast:1",
+ "cast:busy",
+ "projectile:ack",
+ ],
+ host.GameplayTrace);
+ RuntimeTraceEntry[] firstTrace = host.Trace.Entries
+ .Where(entry =>
+ entry.Stamp.Generation == firstGeneration)
+ .ToArray();
+ Assert.Contains(
+ firstTrace,
+ static entry => entry.Kind == RuntimeTraceKind.Lifecycle);
+ Assert.Contains(
+ firstTrace,
+ static entry => entry.Kind == RuntimeTraceKind.Inventory
+ && entry.Code == (int)RuntimeInventoryChange.Moved);
+ Assert.Contains(
+ firstTrace,
+ static entry => entry.Kind == RuntimeTraceKind.Movement);
+ Assert.Contains(
+ firstTrace,
+ static entry => entry.Kind == RuntimeTraceKind.Command
+ && entry.Code >> 16
+ == (int)RuntimeCommandDomain.Magic);
+ Assert.Equal(
+ 5,
+ firstTrace.Count(static entry =>
+ entry.Kind == RuntimeTraceKind.Portal));
+ AssertStrictlyOrdered(firstTrace);
+
+ Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
+ reusedGuid,
+ out RuntimeEntityRecord firstRecord));
+ RuntimeSessionStartResult second = host.Reconnect();
+ RuntimeGenerationToken secondGeneration = second.Generation;
+ Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
+ Assert.NotEqual(firstGeneration, secondGeneration);
+ Assert.False(host.Deliver(
+ firstGeneration,
+ _ => throw new InvalidOperationException(
+ "retired inbound route ran")));
+ Assert.False(host.Execute(
+ firstGeneration,
+ _ => throw new InvalidOperationException(
+ "retired command route ran")));
+
+ RuntimeStateCheckpoint reset = host.Runtime.CaptureCheckpoint();
+ Assert.Equal(0, reset.EntityCount);
+ Assert.Equal(0, reset.InventoryObjectCount);
+ // Retail's end-character path resets active communication targets and
+ // identity but does not issue ClearChatBuffer; bounded chat history is
+ // therefore retained by this reusable root.
+ Assert.Equal(2, reset.ChatCount);
+ Assert.Equal(0, reset.Character.LearnedSpellCount);
+ Assert.Equal(0u, reset.Actions.SelectedObjectId);
+ Assert.Equal(RuntimePortalSnapshot.Idle, reset.Portal);
+ Assert.False(reset.Movement.AutoRunActive);
+ Assert.True(reset.EnvironmentOwnership.IsInitialized);
+
+ Assert.True(host.Deliver(secondGeneration, runtime =>
+ runtime.EntityObjects.RegisterEntity(
+ Spawn(reusedGuid, 2))));
+ Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
+ reusedGuid,
+ out RuntimeEntityRecord secondRecord));
+ Assert.NotSame(firstRecord, secondRecord);
+ Assert.Equal((ushort)2, secondRecord.Incarnation);
+ RuntimeStateCheckpoint secondCheckpoint =
+ host.CaptureCheckpoint();
+ Assert.Equal(1, secondCheckpoint.EntityCount);
+ Assert.Equal(0, secondCheckpoint.InventoryObjectCount);
+ Assert.Equal(2, secondCheckpoint.ChatCount);
+
+ Assert.True(host.Stop().IsComplete);
+ AssertStrictlyOrdered(
+ host.Trace.Entries
+ .Where(entry =>
+ entry.Stamp.Generation == secondGeneration)
+ .ToArray());
+ Assert.DoesNotContain(
+ host.LifecycleTrace.Concat(host.GameplayTrace),
+ line => line.Contains(
+ passwordMarker,
+ StringComparison.Ordinal));
+
+ host.Dispose();
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void PendingPortalCancellationRetiresHostAcknowledgementsBeforeReconnect(
+ bool destinationReady)
+ {
+ var host = new NoWindowGameRuntimeHost();
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+ RuntimeWorldTransitState transit = host.Runtime.TransitOwner;
+ const uint destinationCell = 0x8A020164u;
+ const ushort sequence = 7;
+
+ Assert.True(transit.TryQueueTeleportStart(sequence));
+ Assert.True(transit.ActivateQueuedTeleport());
+ Assert.True(transit.OfferTeleportDestination(
+ new RuntimeTeleportDestination(
+ host.Runtime.PlayerIdentity.ServerGuid,
+ InstanceSequence: 1,
+ PositionSequence: 1,
+ TeleportSequence: sequence,
+ ForcePositionSequence: 1,
+ Position: new Position(
+ destinationCell,
+ Vector3.Zero,
+ Quaternion.Identity)),
+ teleportTimestampAdvanced: true));
+ Assert.True(transit.TryBeginPortalReveal(
+ sequence,
+ destinationCell,
+ out long revealGeneration));
+ Assert.True(transit.TryRegisterHostProjection(
+ revealGeneration,
+ destinationCell,
+ out RuntimeWorldHostProjectionToken projection));
+ Assert.True(transit.AcknowledgeHostProjection(
+ new RuntimeWorldHostAcknowledgement(
+ projection,
+ RuntimeWorldHostAcknowledgementStage
+ .ProjectionRegistered)));
+ if (destinationReady)
+ {
+ Assert.True(transit.AcknowledgeDestinationReadiness(
+ new RuntimeDestinationReadiness(
+ revealGeneration,
+ destinationCell,
+ IsIndoor: true,
+ IsUnhydratable: false,
+ RequiredRenderRadius: 0,
+ IsRenderNeighborhoodReady: true,
+ AreCompositeTexturesReady: true,
+ IsCollisionReady: true)));
+ }
+
+ Assert.True(transit.Cancel(revealGeneration));
+ DrainPendingPortal(transit, projection);
+ Assert.Equal(0, transit.Ownership.HostProjectionCount);
+
+ RuntimeSessionStartResult reconnected = host.Reconnect();
+
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ reconnected.Status);
+ Assert.Equal(RuntimePortalSnapshot.Idle, transit.Snapshot);
+ host.Dispose();
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged);
+ }
+
+ [Fact]
+ public void ConcurrentRootsKeepCredentialsClockStateAndFailuresIsolated()
+ {
+ const string firstPassword = "first-password-marker";
+ const string secondPassword = "second-password-marker";
+ const uint sharedGuid = 0x70000055u;
+ var first = new NoWindowGameRuntimeHost(
+ user: "first-user",
+ password: firstPassword,
+ characterId: 0x50000011u,
+ characterName: "First");
+ var second = new NoWindowGameRuntimeHost(
+ user: "second-user",
+ password: secondPassword,
+ characterId: 0x50000022u,
+ characterName: "Second");
+
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ first.Start().Status);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ second.Start().Status);
+ Assert.True(first.Deliver(runtime =>
+ {
+ runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1));
+ runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = 0x80000011u,
+ Name = "first item",
+ ContainerId = runtime.PlayerIdentity.ServerGuid,
+ });
+ runtime.CommunicationOwner.Chat.OnSystemMessage(
+ "first chat",
+ 1u);
+ }));
+ Assert.True(second.Deliver(runtime =>
+ {
+ runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1));
+ runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = 0x80000022u,
+ Name = "second item",
+ ContainerId = runtime.PlayerIdentity.ServerGuid,
+ });
+ runtime.CommunicationOwner.Chat.OnSystemMessage(
+ "second chat",
+ 1u);
+ }));
+ Assert.True(first.CastFixtureSpell());
+ Assert.True(second.CastFixtureSpell());
+ Assert.True(first.AdvanceProjectile(0x70000011u));
+ Assert.True(second.AdvanceProjectile(0x70000022u));
+ Assert.True(first.CompletePortal(0x8A020164u, sequence: 1));
+ Assert.True(second.CompletePortal(0x11340021u, sequence: 2));
+ first.Advance(0.1d);
+ first.Advance(0.2d);
+ second.Advance(0.4d);
+
+ Assert.True(first.Runtime.EntityObjects.Entities.TryGetActive(
+ sharedGuid,
+ out RuntimeEntityRecord firstRecord));
+ Assert.True(second.Runtime.EntityObjects.Entities.TryGetActive(
+ sharedGuid,
+ out RuntimeEntityRecord secondRecord));
+ Assert.NotSame(firstRecord, secondRecord);
+ Assert.Null(first.Runtime.EntityObjects.Objects.Get(0x80000022u));
+ Assert.Null(second.Runtime.EntityObjects.Objects.Get(0x80000011u));
+ Assert.Equal(
+ "first chat",
+ Assert.Single(
+ first.Runtime.CommunicationOwner.Chat.Snapshot()).Text);
+ Assert.Equal(
+ "second chat",
+ Assert.Single(
+ second.Runtime.CommunicationOwner.Chat.Snapshot()).Text);
+ Assert.Equal(2UL, first.Runtime.Clock.FrameNumber);
+ Assert.Equal(1UL, second.Runtime.Clock.FrameNumber);
+ Assert.NotEqual(
+ first.Runtime.Clock.SimulationTimeSeconds,
+ second.Runtime.Clock.SimulationTimeSeconds);
+ Assert.Equal(
+ 0x8A020164u,
+ first.Runtime.TransitOwner.Snapshot.DestinationCell);
+ Assert.Equal(
+ 0x11340021u,
+ second.Runtime.TransitOwner.Snapshot.DestinationCell);
+ Assert.DoesNotContain(
+ first.LifecycleTrace.Concat(first.GameplayTrace),
+ line => line.Contains(
+ firstPassword,
+ StringComparison.Ordinal)
+ || line.Contains(
+ secondPassword,
+ StringComparison.Ordinal));
+ Assert.DoesNotContain(
+ second.LifecycleTrace.Concat(second.GameplayTrace),
+ line => line.Contains(
+ firstPassword,
+ StringComparison.Ordinal)
+ || line.Contains(
+ secondPassword,
+ StringComparison.Ordinal));
+
+ first.Dispose();
+ second.Dispose();
+ Assert.True(first.Runtime.CaptureOwnership().IsConverged);
+ Assert.True(second.Runtime.CaptureOwnership().IsConverged);
+ }
+
+ [Fact]
+ public void ThrowingRootObserverIsContainedAndDoesNotBlockTeardown()
+ {
+ var host = new NoWindowGameRuntimeHost();
+ using IDisposable failure =
+ host.Runtime.Subscribe(new ThrowingObserver());
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+
+ Assert.True(host.Deliver(runtime =>
+ {
+ runtime.EntityObjects.RegisterEntity(
+ Spawn(0x70000077u, 1));
+ runtime.CommunicationOwner.Chat.OnSystemMessage(
+ "observer fixture",
+ 1u);
+ }));
+ Assert.True(host.Move());
+ Assert.True(host.CompletePortal());
+ Assert.True(
+ host.Runtime.CaptureOwnership()
+ .Events.DispatchFailureCount >= 4);
+
+ Assert.True(host.Stop().IsComplete);
+ failure.Dispose();
+ host.Dispose();
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged);
+ }
+
+ private static void AssertStrictlyOrdered(
+ IReadOnlyList entries)
+ {
+ for (int index = 1; index < entries.Count; index++)
+ {
+ Assert.True(
+ entries[index].Stamp.Sequence
+ > entries[index - 1].Stamp.Sequence,
+ $"trace sequence {entries[index].Stamp.Sequence} "
+ + $"did not follow {entries[index - 1].Stamp.Sequence}");
+ }
+ }
+
+ private static void DrainPendingPortal(
+ RuntimeWorldTransitState transit,
+ RuntimeWorldHostProjectionToken projection)
+ {
+ while (transit.TryGetHostProjection(
+ projection,
+ out RuntimeWorldHostProjectionSnapshot pending))
+ {
+ RuntimeWorldHostAcknowledgementStage stages =
+ pending.PendingAcknowledgements;
+ RuntimeWorldHostAcknowledgementStage stage =
+ (stages & RuntimeWorldHostAcknowledgementStage
+ .SimulationReleaseProjected) != 0
+ ? RuntimeWorldHostAcknowledgementStage
+ .SimulationReleaseProjected
+ : (stages & RuntimeWorldHostAcknowledgementStage
+ .DestinationReservationReleased) != 0
+ ? RuntimeWorldHostAcknowledgementStage
+ .DestinationReservationReleased
+ : RuntimeWorldHostAcknowledgementStage
+ .TerminalProjected;
+ Assert.NotEqual(
+ RuntimeWorldHostAcknowledgementStage.None,
+ stages);
+ Assert.True(transit.AcknowledgeHostProjection(
+ new RuntimeWorldHostAcknowledgement(
+ projection,
+ stage)));
+ }
+ }
+
+ private sealed class ThrowingObserver : IRuntimeEventObserver
+ {
+ public void OnLifecycle(in RuntimeLifecycleDelta delta) =>
+ throw new InvalidOperationException("observer");
+ public void OnCommand(in RuntimeCommandDelta delta) =>
+ throw new InvalidOperationException("observer");
+ public void OnEntity(in RuntimeEntityDelta delta) =>
+ throw new InvalidOperationException("observer");
+ public void OnInventory(in RuntimeInventoryDelta delta) =>
+ throw new InvalidOperationException("observer");
+ public void OnChat(in RuntimeChatDelta delta) =>
+ throw new InvalidOperationException("observer");
+ public void OnMovement(in RuntimeMovementDelta delta) =>
+ throw new InvalidOperationException("observer");
+ public void OnPortal(in RuntimePortalDelta delta) =>
+ throw new InvalidOperationException("observer");
+ }
+
+ 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(
+ Position: 1,
+ Movement: 1,
+ State: 1,
+ Vector: 1,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: 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(),
+ Array.Empty(),
+ Array.Empty(),
+ null,
+ null,
+ guid.ToString("X8"),
+ null,
+ null,
+ null,
+ PhysicsState: physics.RawState,
+ InstanceSequence: incarnation,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
new file mode 100644
index 00000000..1b6c8d00
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
@@ -0,0 +1,359 @@
+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.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.Entity.Concat(observer.Inventory),
+ stamp => Assert.Equal(retiring, stamp.Generation));
+ Assert.Equal(
+ [1UL, 2UL],
+ 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);
+ 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(
+ () => 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(
+ () => 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(
+ () => runtime.ResetGeneration(retiring, host));
+ Assert.IsType(host.ReentrantFailure);
+ Assert.Throws(() =>
+ runtime.ResetGeneration(
+ retiring,
+ new RecordingResetHost(runtime)));
+
+ runtime.ResetGeneration(retiring, host);
+ Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
+ }
+
+ 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(),
+ Array.Empty(),
+ Array.Empty(),
+ 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 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 Entity { get; } = [];
+ public List Inventory { 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) { }
+ }
+
+ 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 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() { }
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs b/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs
index 145d32c4..e64c145f 100644
--- a/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs
+++ b/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs
@@ -94,29 +94,28 @@ public sealed class RuntimeSimulationFixtureHostTests
public RuntimeOnlySimulationFixtureHost()
{
- EntityObjects = new RuntimeEntityObjectLifetime();
- Inventory = new RuntimeInventoryState(EntityObjects);
- Character = new RuntimeCharacterState();
+ Runtime = new GameRuntime(new GameRuntimeDependencies(
+ this,
+ this,
+ this,
+ this,
+ CombatTime: () => _now));
Character.Spellbook.InstallMetadata(SpellMetadata());
Character.Spellbook.OnSpellLearned(1u);
- Communication = new RuntimeCommunicationState();
- Actions = new RuntimeActionState(
- Inventory.Transactions,
- Character.Spellbook,
- this,
- this,
- this,
- this,
- now: () => _now);
- Movement = new RuntimeLocalPlayerMovementState();
}
- public RuntimeEntityObjectLifetime EntityObjects { get; }
- public RuntimeInventoryState Inventory { get; }
- public RuntimeCharacterState Character { get; }
- public RuntimeCommunicationState Communication { get; }
- public RuntimeActionState Actions { get; }
- public RuntimeLocalPlayerMovementState Movement { get; }
+ public GameRuntime Runtime { get; }
+ public RuntimeEntityObjectLifetime EntityObjects =>
+ Runtime.EntityObjects;
+ public RuntimeInventoryState Inventory =>
+ Runtime.InventoryOwner;
+ public RuntimeCharacterState Character =>
+ Runtime.CharacterOwner;
+ public RuntimeCommunicationState Communication =>
+ Runtime.CommunicationOwner;
+ public RuntimeActionState Actions => Runtime.ActionOwner;
+ public RuntimeLocalPlayerMovementState Movement =>
+ Runtime.MovementOwner;
public List Trace { get; } = [];
public void Move()
@@ -219,13 +218,7 @@ public sealed class RuntimeSimulationFixtureHostTests
}
public RuntimeSimulationOwnershipSnapshot CaptureOwnership() =>
- RuntimeSimulationOwnership.Capture(
- EntityObjects,
- Inventory,
- Character,
- Communication,
- Actions,
- Movement);
+ Runtime.CaptureOwnership().Simulation;
public bool IsInWorld => true;
public uint LocalPlayerId => 0x50000001u;
@@ -297,12 +290,7 @@ public sealed class RuntimeSimulationFixtureHostTests
if (_disposed)
return;
_disposed = true;
- Movement.Dispose();
- Actions.Dispose();
- Communication.Dispose();
- Character.Dispose();
- Inventory.Dispose();
- EntityObjects.Dispose();
+ Runtime.Dispose();
}
private static SpellTable SpellMetadata()
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
index e51cbe62..e377ed31 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
@@ -157,6 +157,7 @@ public sealed class LiveSessionControllerTests
public int ActivateCount { get; private set; }
public List CommandBuses { get; } = [];
public List Selections { get; } = [];
+ public List ResetGenerations { get; } = [];
public LiveSessionBinding BindSession(WorldSession session)
{
@@ -200,10 +201,12 @@ public sealed class LiveSessionControllerTests
});
}
- public void ResetSessionState()
+ public void ResetSessionState(
+ RuntimeGenerationToken retiringGeneration)
{
calls.Add("reset");
ResetCount++;
+ ResetGenerations.Add(retiringGeneration);
OnReset?.Invoke();
if (FailResetOnce)
{
@@ -449,6 +452,34 @@ public sealed class LiveSessionControllerTests
Assert.Same(operations.Sessions[1], controller.CurrentSession);
}
+ [Fact]
+ public void ResetCallbacksCarryTheExactRetiringScopeGeneration()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls);
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+
+ LiveSessionStartResult first =
+ controller.Start(LiveOptions(), host);
+ RuntimeGenerationToken firstGeneration = controller.Generation;
+ LiveSessionStartResult second =
+ controller.Reconnect(LiveOptions(), host);
+ RuntimeGenerationToken secondGeneration = controller.Generation;
+ controller.Stop();
+
+ Assert.Equal(LiveSessionStartStatus.Connected, first.Status);
+ Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
+ Assert.Equal(
+ [
+ RuntimeGenerationToken.Initial,
+ firstGeneration,
+ secondGeneration,
+ ],
+ host.ResetGenerations);
+ Assert.NotEqual(firstGeneration, secondGeneration);
+ }
+
[Theory]
[InlineData("detach", "stop")]
[InlineData("detach", "dispose")]
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs
index 89efa166..1cda8f2f 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs
@@ -221,7 +221,7 @@ public sealed class LiveSessionHostTests
Func createCommands) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
- Reset: () => calls.Add("reset"),
+ Reset: _ => calls.Add("reset"),
Selection: new(
id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"),
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs
index bac7638e..74b36ae1 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs
@@ -15,7 +15,7 @@ public sealed class LiveSessionLifecycleHostTests
var host = CreateHost(calls);
LiveSessionBinding binding = host.BindSession(sessionA);
- host.ResetSessionState();
+ host.ResetSessionState(RuntimeGenerationToken.Initial);
host.ReportConnecting("host", 9000, "user");
host.ReportConnected();
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
@@ -67,7 +67,7 @@ public sealed class LiveSessionLifecycleHostTests
Func? bind = null) =>
new(new LiveSessionLifecycleBindings(
Bind: bind ?? (session => CreateBinding(session, calls)),
- Reset: () => calls.Add("reset"),
+ Reset: _ => calls.Add("reset"),
Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"),
diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs
index 411de51b..38fb9544 100644
--- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs
@@ -19,7 +19,7 @@ public sealed class RuntimeLiveSessionNoWindowTests
new LiveSessionRoutingFactories(
_ => new EventRoute(calls),
_ => new CommandRoute(calls)),
- () => calls.Add("reset"),
+ _ => calls.Add("reset"),
new LiveSessionSelectionBindings(
id => calls.Add($"player:{id}"),
_ => { },
diff --git a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs
new file mode 100644
index 00000000..0603b0b8
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs
@@ -0,0 +1,797 @@
+using System.Net;
+using System.Numerics;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.Physics;
+using AcDream.Runtime.Session;
+using AcDream.Runtime.World;
+
+namespace AcDream.Runtime.Tests.Support;
+
+internal sealed class NoWindowGameRuntimeHost : IDisposable
+{
+ private readonly FixtureOperations _operations;
+ private readonly GameplayOperations _gameplay;
+ private readonly ImmediateResetHost _resetHost = new();
+ private readonly IDisposable _hostLease;
+ private readonly IDisposable _traceSubscription;
+ private EventRoute? _events;
+ private CommandRoute? _commands;
+ private bool _disposed;
+
+ public NoWindowGameRuntimeHost(
+ string user = "runtime-user",
+ string password = "runtime-password",
+ uint characterId = 0x50000001u,
+ string characterName = "Runtime")
+ {
+ _operations = new FixtureOperations(
+ characterId,
+ characterName);
+ _gameplay = new GameplayOperations();
+ Runtime = new GameRuntime(new GameRuntimeDependencies(
+ _gameplay,
+ _gameplay,
+ _gameplay,
+ _gameplay,
+ SessionOperations: _operations,
+ CombatTime: () => _gameplay.Now));
+ _gameplay.Bind(Runtime);
+ _hostLease = Runtime.AcquireHostLease("no-window test host");
+ Trace = new RuntimeTraceRecorder();
+ _traceSubscription = Runtime.Subscribe(Trace);
+ Session = new LiveSessionHost(
+ Runtime.Session,
+ new LiveSessionHostBindings(
+ new LiveSessionRoutingFactories(
+ CreateEventRoute,
+ CreateCommandRoute),
+ (generation) =>
+ Runtime.ResetGeneration(generation, _resetHost),
+ new LiveSessionSelectionBindings(
+ id => Runtime.PlayerIdentity.ServerGuid = id,
+ _ => { },
+ Runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
+ _ => { },
+ _ => { },
+ Runtime.ActionOwner.Combat.Clear),
+ new LiveSessionEnteredWorldBindings(
+ _ => { },
+ () => { },
+ () => { },
+ _ => { },
+ () => { }),
+ (host, port, connectingUser) =>
+ _operations.RecordConnecting(
+ host,
+ port,
+ connectingUser),
+ _operations.RecordConnected),
+ new LiveSessionConnectOptions(
+ true,
+ "127.0.0.1",
+ 9000,
+ user,
+ password));
+ }
+
+ public GameRuntime Runtime { get; }
+ public LiveSessionHost Session { get; }
+ public RuntimeTraceRecorder Trace { get; }
+ public IReadOnlyList LifecycleTrace => _operations.Trace;
+ public IReadOnlyList GameplayTrace => _gameplay.Trace;
+ public int ProjectionRetirementCount =>
+ _resetHost.ProjectionRetirementCount;
+ public int ProjectionDrainCount => _resetHost.ProjectionDrainCount;
+ public int ProjectionCompletionCount =>
+ _resetHost.ProjectionCompletionCount;
+
+ public RuntimeStateCheckpoint CaptureCheckpoint()
+ {
+ RuntimeStateCheckpoint checkpoint =
+ Runtime.CaptureCheckpoint();
+ Trace.AddCheckpoint(
+ Runtime.EntityObjects.Events.NextStamp(),
+ checkpoint);
+ return checkpoint;
+ }
+
+ public RuntimeSessionStartResult Start()
+ {
+ RuntimeLifecycleState previous = Runtime.Lifecycle.State;
+ RuntimeSessionStartResult result =
+ Session.Start(Runtime.Generation);
+ Runtime.EventSink.EmitLifecycle(
+ previous,
+ Runtime.Lifecycle.State);
+ return result;
+ }
+
+ public RuntimeSessionStartResult Reconnect()
+ {
+ RuntimeLifecycleState previous = Runtime.Lifecycle.State;
+ RuntimeSessionStartResult result =
+ Session.Reconnect(Runtime.Generation);
+ Runtime.EventSink.EmitLifecycle(
+ previous,
+ Runtime.Lifecycle.State);
+ return result;
+ }
+
+ public RuntimeTeardownAcknowledgement Stop()
+ {
+ RuntimeLifecycleState previous = Runtime.Lifecycle.State;
+ RuntimeTeardownAcknowledgement result =
+ Session.Stop(Runtime.Generation);
+ Runtime.EventSink.EmitLifecycle(
+ previous,
+ Runtime.Lifecycle.State);
+ return result;
+ }
+
+ public bool Deliver(Action delivery) =>
+ Deliver(Runtime.Generation, delivery);
+
+ public bool Deliver(
+ RuntimeGenerationToken expectedGeneration,
+ Action delivery)
+ {
+ ArgumentNullException.ThrowIfNull(delivery);
+ EventRoute? route = _events;
+ if (route is null
+ || !route.IsActive
+ || route.Generation != expectedGeneration
+ || expectedGeneration != Runtime.Generation)
+ {
+ return false;
+ }
+ delivery(Runtime);
+ return true;
+ }
+
+ public bool Execute(Action command) =>
+ Execute(Runtime.Generation, command);
+
+ public bool Execute(
+ RuntimeGenerationToken expectedGeneration,
+ Action command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ CommandRoute? route = _commands;
+ if (route is null
+ || !route.IsActive
+ || route.Generation != expectedGeneration
+ || expectedGeneration != Runtime.Generation)
+ {
+ return false;
+ }
+ command(Runtime);
+ return true;
+ }
+
+ public bool Move()
+ {
+ return Execute(runtime =>
+ {
+ Require(
+ runtime.MovementOwner.Execute(
+ RuntimeMovementCommand.ToggleRunLock),
+ "movement command was rejected");
+ _gameplay.Trace.Add("movement:run");
+ runtime.EventSink.EmitMovement(
+ runtime.MovementOwner.Snapshot);
+ runtime.EventSink.EmitCommand(
+ RuntimeCommandDomain.Movement,
+ (int)RuntimeMovementCommand.ToggleRunLock,
+ RuntimeCommandStatus.Accepted);
+ });
+ }
+
+ public bool Use(uint serverGuid)
+ {
+ return Execute(runtime =>
+ {
+ ItemUseRequestReservation reservation =
+ runtime.ActionOwner.Transactions
+ .BeginUseRequestReservation();
+ RuntimeInteractionDispatchResult result =
+ runtime.ActionOwner.Transactions.TryDispatchUse(
+ serverGuid,
+ ownedByPlayer: false,
+ useable: true,
+ reservation,
+ _gameplay,
+ out _);
+ Require(
+ result == RuntimeInteractionDispatchResult.Dispatched,
+ $"use command was rejected: {result}");
+ runtime.ActionOwner.Transactions.CompleteUse(0u);
+ runtime.EventSink.EmitCommand(
+ RuntimeCommandDomain.Selection,
+ (int)RuntimeSelectionCommand.UseSelected,
+ RuntimeCommandStatus.Accepted,
+ serverGuid);
+ });
+ }
+
+ public bool Attack()
+ {
+ return Execute(runtime =>
+ {
+ runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Melee);
+ runtime.ActionOwner.CombatAttack.SetDesiredPower(0.5f);
+ runtime.ActionOwner.CombatAttack.PressAttack(
+ AttackHeight.Medium);
+ _gameplay.Now += 0.5d;
+ runtime.ActionOwner.CombatAttack.ReleaseAttack();
+ runtime.EventSink.EmitCommand(
+ RuntimeCommandDomain.Combat,
+ (int)RuntimeCombatCommand.ToggleMode,
+ RuntimeCommandStatus.Accepted);
+ });
+ }
+
+ public bool CastFixtureSpell()
+ {
+ return Execute(runtime =>
+ {
+ runtime.CharacterOwner.InstallSpellMetadata(
+ FixtureSpellMetadata());
+ runtime.CharacterOwner.Spellbook.OnSpellLearned(1u);
+ CastRequestResult result =
+ runtime.ActionOwner.SpellCast.Cast(1u);
+ Require(
+ result == CastRequestResult.Sent,
+ $"spell command was rejected: {result}");
+ runtime.EventSink.EmitCommand(
+ RuntimeCommandDomain.Magic,
+ operation: 1,
+ RuntimeCommandStatus.Accepted,
+ primaryObjectId: 1u);
+ });
+ }
+
+ public bool AdvanceProjectile(
+ uint serverGuid = 0x70000021u,
+ ushort incarnation = 1)
+ {
+ return Execute(runtime =>
+ {
+ RuntimeEntityRecord record =
+ runtime.EntityObjects.RegisterEntity(
+ ProjectileSpawn(serverGuid, incarnation)).Canonical
+ ?? throw new InvalidOperationException(
+ "projectile registration produced no canonical record");
+ PhysicsBody body =
+ runtime.EntityObjects.Physics.GetOrCreatePhysicsBody(
+ record,
+ static canonical =>
+ {
+ var created = new PhysicsBody
+ {
+ Position = new Vector3(10f, 20f, 5f),
+ Orientation = Quaternion.Identity,
+ InWorld = true,
+ TransientState =
+ TransientStateFlags.Active,
+ };
+ created.SnapToCell(
+ canonical.FullCellId,
+ created.Position,
+ created.Position);
+ return created;
+ });
+ _ = runtime.EntityObjects.Physics.BindProjectile(
+ record,
+ body,
+ new ProjectileCollisionSphere(
+ Vector3.Zero,
+ 0.25f));
+ runtime.EntityObjects.Physics.AcknowledgeSpatialProjection(
+ record,
+ spatial: true);
+
+ var updater = new RuntimeProjectilePhysicsUpdater(
+ runtime.EntityObjects.Physics);
+ Require(
+ updater.TryBegin(
+ record,
+ quantum: 0.05f,
+ record.ObjectClockEpoch,
+ externalOwnerValid: null,
+ out RuntimeProjectilePhysicsCommit commit),
+ "projectile update did not begin");
+ _ = updater.Complete(
+ commit,
+ liveCenterX: 1,
+ liveCenterY: 1,
+ _ =>
+ {
+ _gameplay.Trace.Add("projectile:ack");
+ return true;
+ });
+ });
+ }
+
+ public bool CompletePortal(
+ uint destinationCell = 0x8A020164u,
+ ushort sequence = 1)
+ {
+ return Execute(runtime =>
+ {
+ RuntimeWorldTransitState transit = runtime.TransitOwner;
+ Require(
+ transit.TryQueueTeleportStart(sequence),
+ "portal start was rejected");
+ Require(
+ transit.ActivateQueuedTeleport(),
+ "portal activation was rejected");
+ Require(
+ transit.OfferTeleportDestination(
+ new RuntimeTeleportDestination(
+ runtime.PlayerIdentity.ServerGuid,
+ InstanceSequence: 1,
+ PositionSequence: 1,
+ TeleportSequence: sequence,
+ ForcePositionSequence: 1,
+ Position: new Position(
+ destinationCell,
+ Vector3.Zero,
+ Quaternion.Identity)),
+ teleportTimestampAdvanced: true),
+ "portal destination was rejected");
+ Require(
+ transit.TryBeginPortalReveal(
+ sequence,
+ destinationCell,
+ out long revealGeneration),
+ "portal reveal was rejected");
+ EmitPortal(runtime);
+ Require(
+ transit.TryRegisterHostProjection(
+ revealGeneration,
+ destinationCell,
+ out RuntimeWorldHostProjectionToken projection),
+ "portal projection registration was rejected");
+ Acknowledge(
+ transit,
+ projection,
+ RuntimeWorldHostAcknowledgementStage
+ .ProjectionRegistered);
+ bool indoor = (destinationCell & 0xFFFFu) >= 0x0100u;
+ Require(
+ transit.AcknowledgeDestinationReadiness(
+ new RuntimeDestinationReadiness(
+ revealGeneration,
+ destinationCell,
+ indoor,
+ IsUnhydratable: false,
+ RequiredRenderRadius: indoor ? 0 : 1,
+ IsRenderNeighborhoodReady: true,
+ AreCompositeTexturesReady: true,
+ IsCollisionReady: true)),
+ "portal readiness was rejected");
+ EmitPortal(runtime);
+ Require(
+ transit.AcknowledgePortalMaterialized(
+ revealGeneration,
+ sequence,
+ destinationCell),
+ "portal materialization was rejected");
+ EmitPortal(runtime);
+ DrainPortalAcknowledgements(transit, projection);
+ Require(
+ transit.RequireDestinationReservationRelease(
+ projection),
+ "portal reservation release was rejected");
+ DrainPortalAcknowledgements(transit, projection);
+ Require(
+ transit.AcknowledgeWorldViewportVisible(
+ revealGeneration),
+ "portal viewport acknowledgement was rejected");
+ EmitPortal(runtime);
+ Require(
+ transit.Complete(revealGeneration),
+ "portal completion was rejected");
+ EmitPortal(runtime);
+ DrainPortalAcknowledgements(transit, projection);
+ });
+ }
+
+ public void Advance(double deltaSeconds)
+ {
+ _ = Runtime.Clock.Advance(deltaSeconds);
+ Runtime.Session.Tick();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ if (Runtime.Session.CurrentSession is not null)
+ _ = Stop();
+ _traceSubscription.Dispose();
+ _hostLease.Dispose();
+ Runtime.Dispose();
+ _disposed = true;
+ }
+
+ private ILiveSessionEventRouting CreateEventRoute(WorldSession session)
+ {
+ var route = new EventRoute(Runtime.Generation, _operations.Trace);
+ _events = route;
+ return route;
+ }
+
+ private static void Acknowledge(
+ RuntimeWorldTransitState transit,
+ RuntimeWorldHostProjectionToken projection,
+ RuntimeWorldHostAcknowledgementStage stage) =>
+ Require(
+ transit.AcknowledgeHostProjection(
+ new RuntimeWorldHostAcknowledgement(
+ projection,
+ stage)),
+ $"portal host acknowledgement was rejected: {stage}");
+
+ private static void DrainPortalAcknowledgements(
+ RuntimeWorldTransitState transit,
+ RuntimeWorldHostProjectionToken projection)
+ {
+ while (transit.TryGetHostProjection(
+ projection,
+ out RuntimeWorldHostProjectionSnapshot pending))
+ {
+ RuntimeWorldHostAcknowledgementStage stages =
+ pending.PendingAcknowledgements;
+ if ((stages & RuntimeWorldHostAcknowledgementStage
+ .SimulationReleaseProjected) != 0)
+ {
+ Acknowledge(
+ transit,
+ projection,
+ RuntimeWorldHostAcknowledgementStage
+ .SimulationReleaseProjected);
+ continue;
+ }
+ if ((stages & RuntimeWorldHostAcknowledgementStage
+ .DestinationReservationReleased) != 0)
+ {
+ Acknowledge(
+ transit,
+ projection,
+ RuntimeWorldHostAcknowledgementStage
+ .DestinationReservationReleased);
+ continue;
+ }
+ if ((stages & RuntimeWorldHostAcknowledgementStage
+ .TerminalProjected) != 0)
+ {
+ Acknowledge(
+ transit,
+ projection,
+ RuntimeWorldHostAcknowledgementStage
+ .TerminalProjected);
+ continue;
+ }
+ break;
+ }
+ }
+
+ private static void EmitPortal(GameRuntime runtime) =>
+ runtime.EventSink.EmitPortal(runtime.TransitOwner.Snapshot);
+
+ private static SpellTable FixtureSpellMetadata()
+ {
+ const string header =
+ "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
+ const string row = "1,Runtime Fixture,0x0,true,0x0";
+ return SpellTable.LoadFromReader(
+ new System.IO.StringReader($"{header}\n{row}"));
+ }
+
+ private static WorldSession.EntitySpawn ProjectileSpawn(
+ uint guid,
+ ushort incarnation)
+ {
+ var position = new CreateObject.ServerPosition(
+ 0x01010001u,
+ 10f,
+ 20f,
+ 5f,
+ 1f,
+ 0f,
+ 0f,
+ 0f);
+ var timestamps = new PhysicsTimestamps(
+ Position: 1,
+ Movement: 1,
+ State: 1,
+ Vector: 1,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: incarnation);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)(
+ PhysicsStateFlags.ReportCollisions
+ | PhysicsStateFlags.Missile),
+ 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: new Vector3(10f, 0f, 0f),
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ guid,
+ position,
+ 0x02000001u,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ null,
+ null,
+ "runtime projectile",
+ null,
+ null,
+ null,
+ PhysicsState: physics.RawState,
+ InstanceSequence: incarnation,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+
+ private static void Require(bool condition, string message)
+ {
+ if (!condition)
+ throw new InvalidOperationException(message);
+ }
+
+ private ILiveSessionCommandRouting CreateCommandRoute(
+ WorldSession session)
+ {
+ var route = new CommandRoute(Runtime.Generation, _operations.Trace);
+ _commands = route;
+ return route;
+ }
+
+ private sealed class ImmediateResetHost : IRuntimeGenerationResetHost
+ {
+ public int ProjectionRetirementCount { get; private set; }
+ public int ProjectionDrainCount { get; private set; }
+ public int ProjectionCompletionCount { get; private set; }
+
+ public void RetireEntityProjection(RuntimeEntityRecord entity) =>
+ ProjectionRetirementCount++;
+
+ public void DrainEntityProjectionBoundary() =>
+ ProjectionDrainCount++;
+
+ public void CompleteEntityProjectionRetirement() =>
+ ProjectionCompletionCount++;
+ }
+
+ private sealed class EventRoute(
+ RuntimeGenerationToken generation,
+ List trace) : ILiveSessionEventRouting
+ {
+ public RuntimeGenerationToken Generation { get; } = generation;
+ public bool IsActive { get; private set; }
+
+ public void Attach()
+ {
+ IsActive = true;
+ trace.Add($"events+:{Generation.Value}");
+ }
+
+ public void Dispose()
+ {
+ IsActive = false;
+ trace.Add($"events-:{Generation.Value}");
+ }
+ }
+
+ private sealed class CommandRoute(
+ RuntimeGenerationToken generation,
+ List trace) : ILiveSessionCommandRouting
+ {
+ public RuntimeGenerationToken Generation { get; } = generation;
+ public bool IsActive { get; private set; }
+
+ public void Activate()
+ {
+ IsActive = true;
+ trace.Add($"commands+:{Generation.Value}");
+ }
+
+ public void Dispose()
+ {
+ IsActive = false;
+ trace.Add($"commands-:{Generation.Value}");
+ }
+ }
+
+ private sealed class FixtureOperations(
+ uint characterId,
+ string characterName) : ILiveSessionOperations
+ {
+ public List Trace { get; } = [];
+
+ public IPEndPoint ResolveEndpoint(string host, int port)
+ {
+ Trace.Add($"resolve:{host}:{port}");
+ return new IPEndPoint(IPAddress.Loopback, port);
+ }
+
+ public WorldSession CreateSession(IPEndPoint endpoint)
+ {
+ Trace.Add("session+");
+ return new WorldSession(endpoint, new FixtureTransport());
+ }
+
+ public void Connect(
+ WorldSession session,
+ string user,
+ string password) =>
+ Trace.Add($"connect:{user}");
+
+ public CharacterList.Parsed GetCharacters(WorldSession session)
+ {
+ Trace.Add("characters");
+ return new CharacterList.Parsed(
+ 0u,
+ [new CharacterList.Character(
+ characterId,
+ characterName,
+ 0u)],
+ [],
+ 11,
+ "NoWindow",
+ true,
+ true);
+ }
+
+ public void EnterWorld(
+ WorldSession session,
+ int activeCharacterIndex) =>
+ Trace.Add($"enter:{activeCharacterIndex}");
+
+ public void Tick(WorldSession session) => Trace.Add("tick");
+
+ public void DisposeSession(WorldSession session)
+ {
+ Trace.Add("session-");
+ session.Dispose();
+ }
+
+ public void RecordConnecting(
+ string host,
+ int port,
+ string user) =>
+ Trace.Add($"connecting:{host}:{port}:{user}");
+
+ public void RecordConnected() => Trace.Add("connected");
+ }
+
+ private sealed class FixtureTransport : IWorldSessionTransport
+ {
+ public void Send(ReadOnlySpan datagram) { }
+ public void Send(IPEndPoint remote, ReadOnlySpan datagram) { }
+
+ public int Receive(
+ Span destination,
+ TimeSpan timeout,
+ out IPEndPoint? from)
+ {
+ from = null;
+ return -1;
+ }
+
+ public ValueTask ReceiveAsync(
+ Memory destination,
+ CancellationToken cancellationToken) =>
+ throw new OperationCanceledException(cancellationToken);
+
+ public void Dispose() { }
+ }
+
+ private sealed class GameplayOperations :
+ IRuntimeInteractionTransport,
+ IRuntimeCombatAttackOperations,
+ IRuntimeCombatTargetOperations,
+ IRuntimeCombatModeOperations,
+ IRuntimeSpellCastOperations
+ {
+ private GameRuntime? _runtime;
+ private uint _sequence;
+
+ public double Now { get; set; } = 10d;
+ public List Trace { get; } = [];
+
+ public void Bind(GameRuntime runtime) =>
+ _runtime = runtime
+ ?? throw new ArgumentNullException(nameof(runtime));
+
+ public bool CanStartAttack() => IsInWorld;
+ public void PrepareAttackRequest() =>
+ Trace.Add("attack:prepare");
+ public bool SendAttack(AttackHeight height, float power)
+ {
+ Trace.Add($"attack:{height}:{power:0.0}");
+ return true;
+ }
+ public void SendCancelAttack() { }
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => true;
+ public bool AutoRepeatAttack => false;
+ public bool AutoTarget => false;
+ public uint? SelectClosestTarget() => null;
+ public bool IsInWorld => _runtime?.Session.IsInWorld == true;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(CombatMode mode) =>
+ Trace.Add($"combat:{mode}");
+ public uint LocalPlayerId =>
+ _runtime?.PlayerIdentity.ServerGuid ?? 0u;
+ public bool CanSend => IsInWorld;
+ public bool HasRequiredComponents(uint spellId) => true;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => true;
+ public void StopCompletely() =>
+ Trace.Add("cast:stop");
+ public void SendUntargeted(uint spellId) =>
+ Trace.Add($"cast:{spellId}");
+ public void SendTargeted(uint targetId, uint spellId) =>
+ Trace.Add($"cast:{targetId}:{spellId}");
+ public void DisplayMessage(string message) =>
+ Trace.Add($"message:{message}");
+ public void IncrementBusy()
+ {
+ _runtime?.InventoryOwner.Transactions.IncrementBusyCount();
+ Trace.Add("cast:busy");
+ }
+
+ public bool TrySendUse(uint serverGuid, out uint sequence)
+ {
+ sequence = ++_sequence;
+ Trace.Add($"use:{serverGuid:X8}");
+ return true;
+ }
+
+ public bool TrySendPickup(
+ uint itemGuid,
+ uint destinationContainerId,
+ int placement,
+ out uint sequence)
+ {
+ sequence = ++_sequence;
+ Trace.Add(
+ $"pickup:{itemGuid:X8}:{destinationContainerId:X8}:{placement}");
+ return true;
+ }
+ }
+}