refactor(runtime): unify generation reset for direct hosts

Move canonical per-session teardown into one retryable Runtime transaction, reduce App reset to projection acknowledgements, and prove the same GameRuntime graph through deterministic no-window lifecycle, gameplay, portal, fault, reconnect, and isolation gates.\n\nCo-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-27 00:43:26 +02:00
parent 7818494116
commit a9a822f206
28 changed files with 2707 additions and 345 deletions

View file

@ -815,6 +815,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerController,
d.WorldOrigin),
new LiveSessionDomainRuntime(
d.Runtime,
d.EntityObjects,
d.Character,
d.Actions,

View file

@ -476,6 +476,29 @@ internal sealed class SelectionInteractionController
failures);
}
/// <summary>
/// Retires only App approach and retained item-interaction presentation.
/// Runtime resets the canonical interaction and selection owners once at
/// the shared generation boundary.
/// </summary>
internal void ResetGenerationPresentation()
{
List<Exception> 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))

View file

@ -1,6 +1,8 @@
namespace AcDream.App.Net;
using AcDream.App.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
/// <summary>
/// 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<RuntimeGenerationToken> RuntimeGeneration { get; init; }
public required Action<RuntimeGenerationToken> 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
}
}
/// <summary>Shared convergence gate for canonical live runtime teardown.</summary>
internal static class LiveSessionEntityRuntimeReset
/// <summary>
/// App's narrow borrowed projection acknowledgement for Runtime's canonical
/// generation-reset transaction. It owns no reset cursor or entity identity.
/// </summary>
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();
}

View file

@ -1,7 +1,19 @@
namespace AcDream.App.Net;
/// <summary>One named owner operation in the live-session reset transaction.</summary>
internal sealed record LiveSessionResetStage(string Name, Action Reset);
using AcDream.Runtime;
/// <summary>One named host operation around Runtime's generation reset.</summary>
internal sealed record LiveSessionResetStage(
string Name,
Action<RuntimeGenerationToken> Reset)
{
public LiveSessionResetStage(string name, Action reset)
: this(
name,
_ => (reset ?? throw new ArgumentNullException(nameof(reset)))())
{
}
}
/// <summary>
/// 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)
{

View file

@ -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

View file

@ -23,6 +23,8 @@ internal interface ILocalPlayerTeleportNetworkSink
bool teleportTimestampAdvanced);
void ResetSession();
void ResetGenerationPresentation();
}
/// <summary>
@ -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();

View file

@ -286,12 +286,22 @@ internal sealed class WorldRevealCoordinator
}
public void ResetSession()
{
ResetHostSession();
_transit.ResetSession();
}
/// <summary>
/// Retires only App streaming/presentation acknowledgements. The shared
/// Runtime generation-reset transaction clears canonical transit state
/// after this host boundary has converged.
/// </summary>
internal void ResetHostSession()
{
long generation = _transit.Snapshot.Generation;
if (generation != 0)
_transit.Cancel(generation);
RetryPendingHostWork();
_transit.ResetSession();
}
/// <summary>

View file

@ -1182,27 +1182,44 @@ public sealed class ItemInteractionController : IDisposable
/// same numeric GUID can identify a different object.
/// </summary>
public void ResetSession()
=> ResetSessionCore(resetRuntime: true);
/// <summary>
/// Clears only retained UI transaction presentation. Runtime's canonical
/// transaction, interaction, and busy owners are reset once by the shared
/// generation-reset transaction.
/// </summary>
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<Exception> 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);

View file

@ -2230,6 +2230,76 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
IsCurrentRecord(expectedRecord)
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// Completes the App-only projection boundary after Runtime has retired
/// every exact canonical incarnation. No canonical owner is mutated here.
/// </summary>
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;

View file

@ -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<RuntimeEntityRecord>
CaptureSessionClearRetirements()
{
EnsureNotDisposed();
if (!_sessionClearInProgress)
{
throw new InvalidOperationException(
"Session-clear retirements are only available while the "
+ "canonical clear transaction is active.");
}
return Entities.TeardownRecords.ToArray();
}
/// <summary>
/// Completes one exact incarnation retained by
/// <see cref="BeginSessionClear"/> 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.
/// </summary>
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()

View file

@ -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:

View file

@ -134,6 +134,26 @@ public sealed class RuntimeLocalPlayerMovementState
Interlocked.Increment(ref _revision);
}
/// <summary>
/// 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.
/// </summary>
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,

View file

@ -0,0 +1,387 @@
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
namespace AcDream.Runtime;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// One persisted, retryable reset transaction for every canonical owner in a
/// reusable <see cref="GameRuntime"/>. A failed call retains the exact suffix:
/// completed stages and exact-incarnation retirements never replay.
/// </summary>
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,
};
}
}

View file

@ -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)

View file

@ -24,7 +24,7 @@ public sealed record LiveSessionEnteredWorldBindings(
public sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
Action Reset,
Action<RuntimeGenerationToken> Reset,
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> 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<RuntimeGenerationToken> _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)

View file

@ -4,7 +4,7 @@ namespace AcDream.Runtime.Session;
public sealed record LiveSessionLifecycleBindings(
Func<WorldSession, LiveSessionBinding> Bind,
Action Reset,
Action<RuntimeGenerationToken> Reset,
Action<string, int, string> Connecting,
Action Connected,
Action<LiveSessionCharacterSelection> 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);