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;