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.PlayerController,
d.WorldOrigin), d.WorldOrigin),
new LiveSessionDomainRuntime( new LiveSessionDomainRuntime(
d.Runtime,
d.EntityObjects, d.EntityObjects,
d.Character, d.Character,
d.Actions, d.Actions,

View file

@ -476,6 +476,29 @@ internal sealed class SelectionInteractionController
failures); 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) private bool ValidatePickupTarget(uint serverGuid, bool showToast)
{ {
if (_query.IsCreature(serverGuid)) if (_query.IsCreature(serverGuid))

View file

@ -1,6 +1,8 @@
namespace AcDream.App.Net; namespace AcDream.App.Net;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
/// <summary> /// <summary>
/// Focused owner operations composed by the App host for one character-session /// 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 MouseCapture { get; init; }
public required Action PlayerPresentation { 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 SessionDialogs { get; init; }
public required Action ChatCommandTargets { get; init; }
public required Action SettingsCharacterContext { get; init; } public required Action SettingsCharacterContext { get; init; }
public required Action EquippedChildren { get; init; } public required Action EquippedChildren { get; init; }
public required Action ExternalContainer { get; init; } public required Action InteractionPresentation { get; init; }
public required Action InteractionAndSelection { get; init; }
public required Action InventoryTransactions { get; init; }
public required Action SelectionPresentation { 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 ParticleVisibility { get; init; }
public required Action InboundEventFifo { get; init; } public required Action InboundEventFifo { get; init; }
public required Action LiveLiveness { get; init; } public required Action LiveLiveness { get; init; }
public required Action LiveRuntime { get; init; } public required Action<RuntimeGenerationToken> RuntimeGeneration { get; init; }
public required Action RenderSceneProjection { get; init; } public required Action<RuntimeGenerationToken> SessionIdentityPresentation
public required Action SessionIdentity { get; init; } { get; init; }
public required Action RemoteTeleport { get; init; } public required Action RemoteTeleport { get; init; }
public required Action NetworkEffects { get; init; } public required Action NetworkEffects { get; init; }
public required Action AnimationHookFrames { get; init; } public required Action AnimationHookFrames { get; init; }
@ -59,38 +48,23 @@ internal static class LiveSessionResetManifest
[ [
new("mouse capture", bindings.MouseCapture), new("mouse capture", bindings.MouseCapture),
new("player presentation", bindings.PlayerPresentation), new("player presentation", bindings.PlayerPresentation),
new("teleport transit", bindings.TeleportTransit), new("teleport presentation", bindings.TeleportPresentation),
new("session dialogs", bindings.SessionDialogs), new("session dialogs", bindings.SessionDialogs),
new("chat command targets", bindings.ChatCommandTargets),
new("settings character context", bindings.SettingsCharacterContext), new("settings character context", bindings.SettingsCharacterContext),
// Attachment projections own GL-backed registrations and must leave // Attachment projections own GL-backed registrations and must leave
// before canonical live GUID records are released. // before canonical live GUID records are released.
new("equipped children", bindings.EquippedChildren), new("equipped children", bindings.EquippedChildren),
new("external container", bindings.ExternalContainer), new("interaction presentation", bindings.InteractionPresentation),
new("interaction and selection", bindings.InteractionAndSelection),
new("inventory transactions", bindings.InventoryTransactions),
new("selection presentation", bindings.SelectionPresentation), 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("particle visibility", bindings.ParticleVisibility),
new("inbound event fifo", bindings.InboundEventFifo), new("inbound event fifo", bindings.InboundEventFifo),
new("live liveness", bindings.LiveLiveness), new("live liveness", bindings.LiveLiveness),
new("live runtime", bindings.LiveRuntime), new("runtime generation", bindings.RuntimeGeneration),
// 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),
// Identity must remain A until live teardown has converged so // Identity must remain A until live teardown has converged so
// player-specific teardown can still classify the old record. // 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), new("remote teleport", bindings.RemoteTeleport),
// F754/F755 can precede CreateObject, so pending network effects // F754/F755 can precede CreateObject, so pending network effects
// must clear even when no LiveEntityRecord was constructed. // 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> /// <summary>
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.
/// </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) _entities = entities
return; ?? throw new ArgumentNullException(nameof(entities));
runtime.Clear(); _drainRenderProjection = drainRenderProjection
RequireConvergence(runtime); ?? throw new ArgumentNullException(nameof(drainRenderProjection));
} }
public static void RequireConvergence(LiveEntityRuntime? runtime) public void RetireEntityProjection(
{ RuntimeEntityRecord entity) =>
if (runtime is null) _entities.RetireGenerationProjection(entity);
return;
if (runtime.Count == 0
&& runtime.PendingTeardownCount == 0
&& runtime.MaterializedCount == 0)
{
return;
}
throw new InvalidOperationException( public void DrainEntityProjectionBoundary() =>
"The live-entity runtime has not converged after session clear " + _drainRenderProjection();
$"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " +
$"materialized={runtime.MaterializedCount})."); public void CompleteEntityProjectionRetirement() =>
} _entities.CompleteGenerationProjectionRetirement();
} }

View file

@ -1,7 +1,19 @@
namespace AcDream.App.Net; namespace AcDream.App.Net;
/// <summary>One named owner operation in the live-session reset transaction.</summary> using AcDream.Runtime;
internal sealed record LiveSessionResetStage(string Name, Action Reset);
/// <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> /// <summary>
/// Executes every session-state reset stage even when an earlier owner fails. /// 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); Array.ConvertAll(_stages, static stage => stage.Name);
public void Execute() public void Execute()
=> Execute(default);
public void Execute(RuntimeGenerationToken retiringGeneration)
{ {
if (Interlocked.Exchange(ref _executing, 1) != 0) if (Interlocked.Exchange(ref _executing, 1) != 0)
throw new InvalidOperationException( throw new InvalidOperationException(
@ -47,7 +62,7 @@ internal sealed class LiveSessionResetPlan
{ {
try try
{ {
stage.Reset(); stage.Reset(retiringGeneration);
} }
catch (Exception error) catch (Exception error)
{ {

View file

@ -23,6 +23,7 @@ using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -39,6 +40,7 @@ internal sealed record LiveSessionPlayerRuntime(
LiveWorldOriginState WorldOrigin); LiveWorldOriginState WorldOrigin);
internal sealed record LiveSessionDomainRuntime( internal sealed record LiveSessionDomainRuntime(
GameRuntime Runtime,
RuntimeEntityObjectLifetime EntityObjects, RuntimeEntityObjectLifetime EntityObjects,
RuntimeCharacterState Character, RuntimeCharacterState Character,
RuntimeActionState Actions, RuntimeActionState Actions,
@ -122,12 +124,18 @@ internal sealed class LiveSessionRuntimeFactory
{ {
ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(connectOptions); ArgumentNullException.ThrowIfNull(connectOptions);
var resetHost = new GraphicalRuntimeGenerationResetHost(
_world.LiveEntities,
() => _world.RenderSceneShadow?.DrainUpdateBoundary());
LiveSessionResetPlan reset =
LiveSessionResetManifest.Create(
CreateResetBindings(resetHost));
return new LiveSessionHost(controller, new LiveSessionHostBindings( return new LiveSessionHost(controller, new LiveSessionHostBindings(
Routing: new( Routing: new(
CreateEventRouter, CreateEventRouter,
session => _commands.Attach(new LiveSessionCommandRouter( session => _commands.Attach(new LiveSessionCommandRouter(
CreateCommandBindings(session)))), CreateCommandBindings(session)))),
Reset: LiveSessionResetManifest.Create(CreateResetBindings()).Execute, Reset: reset.Execute,
Selection: new( Selection: new(
SetPlayerIdentity: id => _player.Identity.ServerGuid = id, SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id), SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
@ -152,37 +160,26 @@ internal sealed class LiveSessionRuntimeFactory
connectOptions); connectOptions);
} }
private LiveSessionResetBindings CreateResetBindings() => new() private LiveSessionResetBindings CreateResetBindings(
IRuntimeGenerationResetHost resetHost) => new()
{ {
MouseCapture = _interaction.GameplayInput.ResetSession, MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation, PlayerPresentation = ResetPlayerPresentation,
TeleportTransit = _world.Teleport.ResetSession, TeleportPresentation =
_world.Teleport.ResetGenerationPresentation,
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(), SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
ChatCommandTargets = _domain.Communication.ResetCommandTargets,
SettingsCharacterContext = SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext, _interaction.Settings.RestoreDefaultCharacterContext,
EquippedChildren = _world.EquippedChildren.Clear, EquippedChildren = _world.EquippedChildren.Clear,
ExternalContainer = _domain.Inventory.ResetExternalContainer, InteractionPresentation =
InteractionAndSelection = _interaction.SelectionInteractions.ResetSession, _interaction.SelectionInteractions.ResetGenerationPresentation,
InventoryTransactions = _domain.Inventory.ResetTransactions,
SelectionPresentation = _world.SelectionScene.Reset, 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, ParticleVisibility = _world.ParticleVisibility.Reset,
InboundEventFifo = _world.InboundEvents.Clear, InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear, LiveLiveness = _world.Liveness.Clear,
LiveRuntime = ResetLiveRuntime, RuntimeGeneration = generation =>
RenderSceneProjection = _domain.Runtime.ResetGeneration(generation, resetHost),
() => _world.RenderSceneShadow?.DrainUpdateBoundary(), SessionIdentityPresentation = ResetIdentityPresentation,
SessionIdentity = ResetIdentity,
RemoteTeleport = _world.RemoteTeleport.Clear, RemoteTeleport = _world.RemoteTeleport.Clear,
NetworkEffects = _world.EntityEffects.ClearNetworkState, NetworkEffects = _world.EntityEffects.ClearNetworkState,
AnimationHookFrames = _world.AnimationHookFrames.Clear, AnimationHookFrames = _world.AnimationHookFrames.Clear,
@ -196,28 +193,28 @@ internal sealed class LiveSessionRuntimeFactory
_world.SpawnClaims.Reset(); _world.SpawnClaims.Reset();
} }
private void ResetLiveRuntime() => private void ResetIdentityPresentation(
LiveSessionEntityRuntimeReset.ClearAndRequireConvergence( RuntimeGenerationToken retiringGeneration)
_world.LiveEntities);
private void ResetIdentity()
{ {
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 // PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
// components, and character option objects. CPlayerSystem::Begin // components, and character option objects. CPlayerSystem::Begin
// @ 0x0055D410 resets player identity and session fields. The option // @ 0x0055D410 resets player identity and session fields. The option
// default comes from PlayerModule::PlayerModule @ 0x005D51F0. // default comes from PlayerModule::PlayerModule @ 0x005D51F0.
_player.Identity.ServerGuid = 0u;
_ui.Vitals?.SetLocalPlayerGuid(0u); _ui.Vitals?.SetLocalPlayerGuid(0u);
_domain.Communication.ResetChatIdentity();
EntityVanishProbe.PlayerGuid = 0u; EntityVanishProbe.PlayerGuid = 0u;
_interaction.Settings.ResetActiveCharacterKey(); _interaction.Settings.ResetActiveCharacterKey();
_domain.Character.Options.ResetSession();
_domain.Character.MovementSkills.ResetSession();
_world.NetworkUpdates.ResetSessionState(); _world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState(); _world.Hydration.ResetSessionState();
_domain.Inventory.ResetPlayerSnapshots();
_ui.Paperdoll?.ResetSession(); _ui.Paperdoll?.ResetSession();
// X/Y are ignored until the next logical player CreateObject claims a // X/Y are ignored until the next logical player CreateObject claims a

View file

@ -23,6 +23,8 @@ internal interface ILocalPlayerTeleportNetworkSink
bool teleportTimestampAdvanced); bool teleportTimestampAdvanced);
void ResetSession(); void ResetSession();
void ResetGenerationPresentation();
} }
/// <summary> /// <summary>
@ -62,6 +64,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
public void ResetSession() => Required().ResetSession(); public void ResetSession() => Required().ResetSession();
public void ResetGenerationPresentation() =>
Required().ResetGenerationPresentation();
private ILocalPlayerTeleportNetworkSink Required() => private ILocalPlayerTeleportNetworkSink Required() =>
_inner ?? throw new InvalidOperationException( _inner ?? throw new InvalidOperationException(
"The local teleport sink was used before composition completed."); "The local teleport sink was used before composition completed.");
@ -567,7 +572,17 @@ internal sealed class LocalPlayerTeleportController
public void ResetSession() public void ResetSession()
{ {
ThrowIfDisposed(); ThrowIfDisposed();
ResetTransit(clearSession: true); ResetTransit(
clearSession: true,
resetCanonicalTransit: true);
}
public void ResetGenerationPresentation()
{
ThrowIfDisposed();
ResetTransit(
clearSession: true,
resetCanonicalTransit: false);
} }
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
@ -718,7 +733,9 @@ internal sealed class LocalPlayerTeleportController
return true; return true;
} }
private long ResetTransit(bool clearSession) private long ResetTransit(
bool clearSession,
bool resetCanonicalTransit = false)
{ {
long generation = checked(++_lifetimeGeneration); long generation = checked(++_lifetimeGeneration);
@ -733,7 +750,12 @@ internal sealed class LocalPlayerTeleportController
return generation; return generation;
if (clearSession) if (clearSession)
{
if (resetCanonicalTransit)
_worldReveal.ResetSession(); _worldReveal.ResetSession();
else
_worldReveal.ResetHostSession();
}
else else
{ {
_transit.EndTeleport(); _transit.EndTeleport();

View file

@ -286,12 +286,22 @@ internal sealed class WorldRevealCoordinator
} }
public void ResetSession() 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; long generation = _transit.Snapshot.Generation;
if (generation != 0) if (generation != 0)
_transit.Cancel(generation); _transit.Cancel(generation);
RetryPendingHostWork(); RetryPendingHostWork();
_transit.ResetSession();
} }
/// <summary> /// <summary>

View file

@ -1182,11 +1182,24 @@ public sealed class ItemInteractionController : IDisposable
/// same numeric GUID can identify a different object. /// same numeric GUID can identify a different object.
/// </summary> /// </summary>
public void ResetSession() 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? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null; _pendingBackpackPlacement = null;
// Runtime owns the transaction reset. This controller preserves the if (resetRuntime)
// pre-J4 single UI notification emitted by InteractionState below. {
// Runtime owns the transaction reset. This controller preserves
// the pre-J4 single UI notification emitted by InteractionState.
_transactions.StateChanged -= OnTransactionStateChanged; _transactions.StateChanged -= OnTransactionStateChanged;
try try
{ {
@ -1197,12 +1210,16 @@ public sealed class ItemInteractionController : IDisposable
if (!_disposed) if (!_disposed)
_transactions.StateChanged += OnTransactionStateChanged; _transactions.StateChanged += OnTransactionStateChanged;
} }
}
_consumedPrimaryClickTarget = 0u; _consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2; _consumedPrimaryClickMs = long.MinValue / 2;
List<Exception> failures = []; List<Exception> failures = [];
if (resetRuntime)
{
try { _interactionState.ResetSession(); } try { _interactionState.ResetSession(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
}
if (pendingPlacement is { } pending) if (pendingPlacement is { } pending)
DispatchAll(PendingBackpackPlacementCancelled, pending, failures); DispatchAll(PendingBackpackPlacementCancelled, pending, failures);

View file

@ -2230,6 +2230,76 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
IsCurrentRecord(expectedRecord) IsCurrentRecord(expectedRecord)
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion; && 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() public void Clear()
{ {
if (_isClearing || _isRegisteringResources) if (_isClearing || _isRegisteringResources)
@ -2670,7 +2740,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_sessionClearPendingFinalization = false; _sessionClearPendingFinalization = false;
} }
private void TearDownRecord(LiveEntityRecord record) private void TearDownRecord(
LiveEntityRecord record,
bool completeCanonical = true)
{ {
if (record.TeardownInProgress) if (record.TeardownInProgress)
throw new InvalidOperationException( throw new InvalidOperationException(
@ -2730,8 +2802,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (record.WorldEntity is not null) if (record.WorldEntity is not null)
_ = RequireProjectionKey(record); _ = RequireProjectionKey(record);
if (completeCanonical)
{
_entityObjects.CompleteProjectionRetirement( _entityObjects.CompleteProjectionRetirement(
record.Canonical); record.Canonical);
}
record.AnimationRuntime = null; record.AnimationRuntime = null;
record.EffectProfile = null; record.EffectProfile = null;

View file

@ -968,15 +968,55 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
_sessionClearInProgress = true; _sessionClearInProgress = true;
Entities.BeginSessionClear(); Entities.BeginSessionClear();
RuntimeEntityRecord[] retired = Entities.ActiveRecords.ToArray(); RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
foreach (RuntimeEntityRecord canonical in retired) foreach (RuntimeEntityRecord canonical in active)
{ {
if (!Entities.RemoveActive(canonical)) if (!Entities.RemoveActive(canonical))
continue; continue;
Entities.RetainTeardown(canonical); Entities.RetainTeardown(canonical);
PublishEntity(RuntimeEntityChange.Deleted, 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() public bool CompleteSessionClearIfConverged()

View file

@ -58,6 +58,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
RuntimeSimulationOwnershipSnapshot Simulation, RuntimeSimulationOwnershipSnapshot Simulation,
RuntimeWorldEnvironmentOwnershipSnapshot Environment, RuntimeWorldEnvironmentOwnershipSnapshot Environment,
RuntimeWorldTransitOwnershipSnapshot Transit, RuntimeWorldTransitOwnershipSnapshot Transit,
RuntimeGenerationResetSnapshot GenerationReset,
GameRuntimeEventOwnershipSnapshot Events) GameRuntimeEventOwnershipSnapshot Events)
{ {
public bool IsConverged => public bool IsConverged =>
@ -70,6 +71,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
&& PlayerIdentity.IsConverged && PlayerIdentity.IsConverged
&& Simulation.IsConverged && Simulation.IsConverged
&& Transit.IsSessionIdle && Transit.IsSessionIdle
&& GenerationReset.IsConverged
&& Events.IsConverged; && Events.IsConverged;
} }
@ -241,8 +243,19 @@ public sealed class GameRuntime
context, context,
faultInjection); faultInjection);
var generationReset = new RuntimeGenerationReset(
transit,
context.Communication,
context.Inventory,
context.Actions,
context.Movement,
context.EntityObjects,
context.Character,
context.PlayerIdentity);
context.EntityObjects.BindEventContext( context.EntityObjects.BindEventContext(
() => context.Session.Generation, () => generationReset.ActiveRetiringGeneration
?? context.Session.Generation,
() => clock.FrameNumber); () => clock.FrameNumber);
context.Events = new GameRuntimeEventHub( context.Events = new GameRuntimeEventHub(
context.EntityObjects, context.EntityObjects,
@ -264,6 +277,7 @@ public sealed class GameRuntime
ActionOwner = context.Actions; ActionOwner = context.Actions;
EnvironmentOwner = environment; EnvironmentOwner = environment;
TransitOwner = transit; TransitOwner = transit;
GenerationReset = generationReset;
_events = context.Events; _events = context.Events;
construction.Complete(); construction.Complete();
} }
@ -285,6 +299,7 @@ public sealed class GameRuntime
public RuntimeLocalPlayerMovementState MovementOwner { get; } public RuntimeLocalPlayerMovementState MovementOwner { get; }
public RuntimeWorldEnvironmentState EnvironmentOwner { get; } public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
public RuntimeWorldTransitState TransitOwner { get; } public RuntimeWorldTransitState TransitOwner { get; }
public RuntimeGenerationReset GenerationReset { get; }
public RuntimeGenerationToken Generation => Session.Generation; public RuntimeGenerationToken Generation => Session.Generation;
@ -347,6 +362,11 @@ public sealed class GameRuntime
Portal.Snapshot, Portal.Snapshot,
Portal.Ownership); Portal.Ownership);
public void ResetGeneration(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host) =>
GenerationReset.Reset(retiringGeneration, host);
public IDisposable Subscribe(IRuntimeEventObserver observer) public IDisposable Subscribe(IRuntimeEventObserver observer)
{ {
lock (_lifetimeGate) lock (_lifetimeGate)
@ -393,6 +413,7 @@ public sealed class GameRuntime
MovementOwner), MovementOwner),
EnvironmentOwner.CaptureOwnership(), EnvironmentOwner.CaptureOwnership(),
TransitOwner.CaptureOwnership(), TransitOwner.CaptureOwnership(),
GenerationReset.CaptureSnapshot(),
_events.CaptureOwnership()); _events.CaptureOwnership());
} }
} }
@ -534,6 +555,7 @@ public sealed class GameRuntime
StopSession(); StopSession();
return Session.CaptureOwnership().IsConverged; return Session.CaptureOwnership().IsConverged;
case 3: case 3:
GenerationReset.DrainPending();
TransitOwner.ResetSession(); TransitOwner.ResetSession();
return TransitOwner.CaptureOwnership().IsSessionIdle; return TransitOwner.CaptureOwnership().IsSessionIdle;
case 4: case 4:

View file

@ -134,6 +134,26 @@ public sealed class RuntimeLocalPlayerMovementState
Interlocked.Increment(ref _revision); 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() => public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
new( new(
_disposed, _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 public interface ILiveSessionLifecycleHost
{ {
LiveSessionBinding BindSession(WorldSession session); LiveSessionBinding BindSession(WorldSession session);
void ResetSessionState(); void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user); void ReportConnecting(string host, int port, string user);
void ReportConnected(); void ReportConnected();
void ApplySelectedCharacter(LiveSessionCharacterSelection selection); void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
@ -185,12 +185,14 @@ public sealed class LiveSessionController
{ {
private sealed class SessionScope( private sealed class SessionScope(
WorldSession session, WorldSession session,
ILiveSessionLifecycleHost host) ILiveSessionLifecycleHost host,
RuntimeGenerationToken generation)
{ {
private int _teardownStage; private int _teardownStage;
public WorldSession Session { get; } = session; public WorldSession Session { get; } = session;
public ILiveSessionLifecycleHost Host { get; } = host; public ILiveSessionLifecycleHost Host { get; } = host;
public RuntimeGenerationToken Generation { get; } = generation;
public LiveSessionBinding? Binding { get; set; } public LiveSessionBinding? Binding { get; set; }
public bool HostAttached { get; set; } public bool HostAttached { get; set; }
public RuntimeTeardownStage CompletedStages { get; private set; } public RuntimeTeardownStage CompletedStages { get; private set; }
@ -226,7 +228,7 @@ public sealed class LiveSessionController
} }
if (_teardownStage == 3) if (_teardownStage == 3)
{ {
Host.ResetSessionState(); Host.ResetSessionState(Generation);
CompletedStages |= RuntimeTeardownStage.HostReset; CompletedStages |= RuntimeTeardownStage.HostReset;
_teardownStage = 4; _teardownStage = 4;
} }
@ -247,11 +249,15 @@ public sealed class LiveSessionController
LiveSessionConnectOptions? Options = null, LiveSessionConnectOptions? Options = null,
ILiveSessionLifecycleHost? Host = null); ILiveSessionLifecycleHost? Host = null);
private sealed record PendingHostReset(
ILiveSessionLifecycleHost Host,
RuntimeGenerationToken Generation);
private readonly object _gate = new(); private readonly object _gate = new();
private readonly ILiveSessionOperations _operations; private readonly ILiveSessionOperations _operations;
private SessionScope? _scope; private SessionScope? _scope;
private SessionScope? _retiredScope; private SessionScope? _retiredScope;
private ILiveSessionLifecycleHost? _pendingInitialResetHost; private PendingHostReset? _pendingInitialReset;
private PendingOperation? _pendingOperation; private PendingOperation? _pendingOperation;
private int _operationDepth; private int _operationDepth;
private bool _inWorld; private bool _inWorld;
@ -306,7 +312,7 @@ public sealed class LiveSessionController
_inWorld, _inWorld,
_scope is not null, _scope is not null,
_retiredScope is not null, _retiredScope is not null,
_pendingInitialResetHost is not null, _pendingInitialReset is not null,
_pendingOperation is not null, _pendingOperation is not null,
_operationDepth, _operationDepth,
_generation, _generation,
@ -460,7 +466,9 @@ public sealed class LiveSessionController
ILiveSessionLifecycleHost host) ILiveSessionLifecycleHost host)
{ {
ILiveSessionLifecycleHost? oldHost = ILiveSessionLifecycleHost? oldHost =
_scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost; _scope?.Host
?? _retiredScope?.Host
?? _pendingInitialReset?.Host;
ulong generationBeforeStop = _generation; ulong generationBeforeStop = _generation;
try try
{ {
@ -480,6 +488,7 @@ public sealed class LiveSessionController
ILiveSessionLifecycleHost host, ILiveSessionLifecycleHost host,
bool resetHost) bool resetHost)
{ {
RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation; ulong generation = ++_generation;
try try
{ {
@ -487,7 +496,12 @@ public sealed class LiveSessionController
if (_generation != generation) if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
if (resetHost) if (resetHost)
ResetHostBeforeStart(host, generation); {
ResetHostBeforeStart(
host,
generation,
resetGeneration);
}
if (_generation != generation) if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
} }
@ -509,7 +523,10 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
Console.WriteLine($"live: connecting to {endpoint} as {options.User}"); Console.WriteLine($"live: connecting to {endpoint} as {options.User}");
WorldSession session = _operations.CreateSession(endpoint); WorldSession session = _operations.CreateSession(endpoint);
scope = new SessionScope(session, host); scope = new SessionScope(
session,
host,
new RuntimeGenerationToken(generation));
_scope = scope; _scope = scope;
_inWorld = false; _inWorld = false;
if (!IsCurrent(scope, generation)) if (!IsCurrent(scope, generation))
@ -614,7 +631,7 @@ public sealed class LiveSessionController
} }
DrainRetiredScope(); DrainRetiredScope();
DrainPendingInitialReset(); DrainPendingInitialReset();
if (_retiredScope is null && _pendingInitialResetHost is null) if (_retiredScope is null && _pendingInitialReset is null)
_lastTeardownStages = RuntimeTeardownStage.Complete; _lastTeardownStages = RuntimeTeardownStage.Complete;
} }
@ -632,35 +649,38 @@ public sealed class LiveSessionController
private void ResetHostBeforeStart( private void ResetHostBeforeStart(
ILiveSessionLifecycleHost host, ILiveSessionLifecycleHost host,
ulong generation) ulong generation,
RuntimeGenerationToken resetGeneration)
{ {
bool requestedHostAlreadyReset = false; bool requestedHostAlreadyReset = false;
if (_pendingInitialResetHost is { } pending) if (_pendingInitialReset is { } pending)
{ {
pending.ResetSessionState(); pending.Host.ResetSessionState(pending.Generation);
_pendingInitialResetHost = null; _pendingInitialReset = null;
requestedHostAlreadyReset = ReferenceEquals(pending, host); requestedHostAlreadyReset =
ReferenceEquals(pending.Host, host);
} }
if (requestedHostAlreadyReset || _generation != generation) if (requestedHostAlreadyReset || _generation != generation)
return; return;
try try
{ {
host.ResetSessionState(); host.ResetSessionState(resetGeneration);
} }
catch catch
{ {
_pendingInitialResetHost = host; _pendingInitialReset =
new PendingHostReset(host, resetGeneration);
throw; throw;
} }
} }
private void DrainPendingInitialReset() private void DrainPendingInitialReset()
{ {
if (_pendingInitialResetHost is not { } host) if (_pendingInitialReset is not { } pending)
return; return;
host.ResetSessionState(); pending.Host.ResetSessionState(pending.Generation);
_pendingInitialResetHost = null; _pendingInitialReset = null;
} }
private void Schedule(PendingOperation operation) private void Schedule(PendingOperation operation)

View file

@ -24,7 +24,7 @@ public sealed record LiveSessionEnteredWorldBindings(
public sealed record LiveSessionHostBindings( public sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing, LiveSessionRoutingFactories Routing,
Action Reset, Action<RuntimeGenerationToken> Reset,
LiveSessionSelectionBindings Selection, LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld, LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> Connecting, Action<string, int, string> Connecting,
@ -84,7 +84,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
private readonly LiveSessionRoutingFactories _routing; private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection; private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld; private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly Action _reset; private readonly Action<RuntimeGenerationToken> _reset;
private readonly LiveSessionLifecycleHost _lifecycle; private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback; 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 // An incompletely detached route can still deliver callbacks into the
// state below. Treat physical route convergence as the same hard // state below. Treat physical route convergence as the same hard
// barrier used by normal LiveSessionBinding teardown. // barrier used by normal LiveSessionBinding teardown.
DrainPendingRouteRollback(); DrainPendingRouteRollback();
_reset(); _reset(retiringGeneration);
} }
private void ApplySelection(LiveSessionCharacterSelection selection) private void ApplySelection(LiveSessionCharacterSelection selection)

View file

@ -4,7 +4,7 @@ namespace AcDream.Runtime.Session;
public sealed record LiveSessionLifecycleBindings( public sealed record LiveSessionLifecycleBindings(
Func<WorldSession, LiveSessionBinding> Bind, Func<WorldSession, LiveSessionBinding> Bind,
Action Reset, Action<RuntimeGenerationToken> Reset,
Action<string, int, string> Connecting, Action<string, int, string> Connecting,
Action Connected, Action Connected,
Action<LiveSessionCharacterSelection> Selected, Action<LiveSessionCharacterSelection> Selected,
@ -42,7 +42,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
return binding; return binding;
} }
public void ResetSessionState() => _bindings.Reset(); public void ResetSessionState(
RuntimeGenerationToken retiringGeneration) =>
_bindings.Reset(retiringGeneration);
public void ReportConnecting(string host, int port, string user) => public void ReportConnecting(string host, int port, string user) =>
_bindings.Connecting(host, port, user); _bindings.Connecting(host, port, user);

View file

@ -13,6 +13,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Net; namespace AcDream.App.Tests.Net;
@ -199,190 +200,107 @@ public sealed class LiveSessionResetPlanTests
} }
[Fact] [Fact]
public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld() public void Manifest_PreservesHostOrderAndForwardsExactRetiringGeneration()
{ {
const uint playerA = 0x50000001u; var calls = new List<string>();
const uint objectA = 0x70000001u; RuntimeGenerationToken observed = default;
const uint landblock = 0x0101FFFFu; Action Stage(string name) => () => calls.Add(name);
var staticEntity = Entity(42u, 0u); var plan = LiveSessionResetManifest.Create(new()
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
new List<WorldEntity> { 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<string, uint> { ["A"] = 1u },
new Dictionary<uint, SquelchInfo>(),
new SquelchInfo("A", false, new HashSet<uint>())));
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<string, int>(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()
{ {
MouseCapture = Stage("mouse capture"), MouseCapture = Stage("mouse capture"),
PlayerPresentation = Stage("player presentation"), PlayerPresentation = Stage("player presentation"),
TeleportTransit = Stage("teleport transit"), TeleportPresentation = Stage("teleport presentation"),
SessionDialogs = Stage("session dialogs"), SessionDialogs = Stage("session dialogs"),
ChatCommandTargets = Stage("chat command targets"), SettingsCharacterContext = Stage("settings character context"),
SettingsCharacterContext = Stage(
"settings character context",
() => activeSetting = savedDefaultSetting),
EquippedChildren = Stage("equipped children"), EquippedChildren = Stage("equipped children"),
ExternalContainer = Stage("external container", () => external.Reset()), InteractionPresentation = Stage("interaction presentation"),
InteractionAndSelection = Stage(
"interaction and selection",
() => selection.Reset()),
InventoryTransactions = Stage("inventory transactions"),
SelectionPresentation = Stage("selection 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"), ParticleVisibility = Stage("particle visibility"),
InboundEventFifo = Stage("inbound event fifo"), InboundEventFifo = Stage("inbound event fifo"),
LiveLiveness = Stage("live liveness"), LiveLiveness = Stage("live liveness"),
LiveRuntime = Stage( RuntimeGeneration = generation =>
"live runtime",
() => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)),
RenderSceneProjection = Stage("render scene projection"),
SessionIdentity = Stage("session identity", () =>
{ {
LiveSessionEntityRuntimeReset.RequireConvergence(live); observed = generation;
sessionIdentity = 0u; calls.Add("runtime generation");
chat.ResetSessionIdentity(); },
}), SessionIdentityPresentation = _ =>
calls.Add("session identity presentation"),
RemoteTeleport = Stage("remote teleport"), RemoteTeleport = Stage("remote teleport"),
NetworkEffects = Stage("network effects"), NetworkEffects = Stage("network effects"),
AnimationHookFrames = Stage("animation hook frames"), AnimationHookFrames = Stage("animation hook frames"),
LivePresentation = Stage("live presentation"), 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); Assert.Equal(ExpectedManifestNames(), plan.StageNames);
AggregateException first = Assert.Throws<AggregateException>(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<WorldEntity>()));
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<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal( Assert.Equal(
["live runtime", "session identity"], RuntimeGenerationResetStage.RetireEntities,
first.InnerExceptions first.Stage);
.Cast<LiveSessionResetStageException>() Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
.Select(static error => error.StageName));
Assert.Equal(playerA, sessionIdentity);
Assert.Equal(1, live.PendingTeardownCount); Assert.Equal(1, live.PendingTeardownCount);
Assert.Contains(staticEntity, spatial.Entities); Assert.Equal(1, live.MaterializedCount);
Assert.Equal(1, spatial.PersistentGuidCount);
plan.Execute(); runtime.ResetGeneration(retiring, host);
Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name])); Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.DoesNotContain(dirty, static pair => pair.Value);
Assert.Equal(0u, sessionIdentity);
Assert.Equal(savedDefaultSetting, activeSetting);
Assert.Equal(0, live.Count); Assert.Equal(0, live.Count);
Assert.Equal(0, live.PendingTeardownCount); Assert.Equal(0, live.PendingTeardownCount);
Assert.Equal(0, live.MaterializedCount); 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() => private static string[] ExpectedManifestNames() =>
[ [
"mouse capture", "mouse capture",
"player presentation", "player presentation",
"teleport transit", "teleport presentation",
"session dialogs", "session dialogs",
"chat command targets",
"settings character context", "settings character context",
"equipped children", "equipped children",
"external container", "interaction presentation",
"interaction and selection",
"inventory transactions",
"selection presentation", "selection presentation",
"object table",
"spellbook",
"magic runtime",
"combat attack",
"combat state",
"item mana",
"local player",
"friends",
"squelch",
"turbine chat",
"particle visibility", "particle visibility",
"inbound event fifo", "inbound event fifo",
"live liveness", "live liveness",
"live runtime", "runtime generation",
"render scene projection", "session identity presentation",
"session identity",
"remote teleport", "remote teleport",
"network effects", "network effects",
"animation hook frames", "animation hook frames",

View file

@ -2,6 +2,7 @@ using System.Net;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net; namespace AcDream.App.Tests.Net;
@ -92,7 +93,8 @@ public sealed class LiveSessionShutdownIntegrationTests
deactivateCommands: static () => { }, deactivateCommands: static () => { },
detachEvents: static () => { }); detachEvents: static () => { });
public void ResetSessionState() { } public void ResetSessionState(
RuntimeGenerationToken retiringGeneration) { }
public void ReportConnecting(string host, int port, string user) { } public void ReportConnecting(string host, int port, string user) { }
public void ReportConnected() { } public void ReportConnected() { }
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }

View file

@ -945,31 +945,17 @@ public sealed class CurrentGameRuntimeAdapterTests
{ {
MouseCapture = noop, MouseCapture = noop,
PlayerPresentation = noop, PlayerPresentation = noop,
TeleportTransit = noop, TeleportPresentation = noop,
SessionDialogs = noop, SessionDialogs = noop,
ChatCommandTargets = noop,
SettingsCharacterContext = noop, SettingsCharacterContext = noop,
EquippedChildren = noop, EquippedChildren = noop,
ExternalContainer = noop, InteractionPresentation = noop,
InteractionAndSelection = noop,
InventoryTransactions = noop,
SelectionPresentation = 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, ParticleVisibility = noop,
InboundEventFifo = noop, InboundEventFifo = noop,
LiveLiveness = noop, LiveLiveness = noop,
LiveRuntime = noop, RuntimeGeneration = _ => { },
RenderSceneProjection = noop, SessionIdentityPresentation = _ => { },
SessionIdentity = noop,
RemoteTeleport = noop, RemoteTeleport = noop,
NetworkEffects = noop, NetworkEffects = noop,
AnimationHookFrames = noop, AnimationHookFrames = noop,

View file

@ -850,6 +850,9 @@ public sealed class LocalPlayerTeleportControllerTests
public void ResetSession() public void ResetSession()
{ {
} }
public void ResetGenerationPresentation()
{
}
} }
private sealed class FakePresentation : ILocalPlayerTeleportPresentation private sealed class FakePresentation : ILocalPlayerTeleportPresentation

View file

@ -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<RuntimeTraceEntry> 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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
guid.ToString("X8"),
null,
null,
null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}

View file

@ -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<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
RuntimeGenerationResetStage.DrainHostProjection,
failure.Stage);
Assert.Equal(2, host.Retired.Count);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(0, host.CompleteCalls);
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
RuntimeGenerationResetSnapshot pending =
runtime.GenerationReset.CaptureSnapshot();
Assert.True(pending.IsActive);
Assert.Equal(2, pending.RetirementCursor);
runtime.ResetGeneration(retiring, host);
Assert.Equal(2, host.Retired.Count);
Assert.Equal(2, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
[Fact]
public void FailedHostCompletionKeepsOldIdentityAndDoesNotReplayDrainOrEntities()
{
using var runtime = Create();
const uint player = 0x50000001u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
var host = new RecordingResetHost(runtime)
{
FailCompleteOnce = true,
};
var retiring = new RuntimeGenerationToken(17);
RuntimeGenerationResetStageException failure =
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
RuntimeGenerationResetStage.CompleteHostProjection,
failure.Stage);
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, runtime.EntityObjects.Entities.Count);
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(2, host.CompleteCalls);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
}
[Fact]
public void PendingResetRejectsHostReplacementAndReentrantReset()
{
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
var retiring = new RuntimeGenerationToken(4);
var host = new RecordingResetHost(runtime)
{
Reenter = true,
FailDrainOnce = true,
};
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.IsType<InvalidOperationException>(host.ReentrantFailure);
Assert.Throws<InvalidOperationException>(() =>
runtime.ResetGeneration(
retiring,
new RecordingResetHost(runtime)));
runtime.ResetGeneration(retiring, host);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
private static GameRuntime Create()
{
var operations = new Operations();
return new GameRuntime(new GameRuntimeDependencies(
operations,
operations,
operations,
operations));
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort incarnation)
{
var position = new CreateObject.ServerPosition(
0x01010001u,
10f,
10f,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
1,
1,
1,
1,
0,
1,
0,
1,
incarnation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
guid.ToString("X8"),
null,
null,
null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class RecordingResetHost(GameRuntime runtime)
: IRuntimeGenerationResetHost
{
public List<RuntimeEntityRecord> Retired { get; } = [];
public int DrainCalls { get; private set; }
public int CompleteCalls { get; private set; }
public bool FailDrainOnce { get; set; }
public bool FailCompleteOnce { get; set; }
public bool Reenter { get; set; }
public Exception? ReentrantFailure { get; private set; }
public void RetireEntityProjection(RuntimeEntityRecord entity)
{
Retired.Add(entity);
if (!Reenter)
return;
Reenter = false;
ReentrantFailure = Record.Exception(() =>
runtime.ResetGeneration(
runtime.GenerationReset
.CaptureSnapshot()
.RetiringGeneration,
this));
}
public void DrainEntityProjectionBoundary()
{
DrainCalls++;
if (!FailDrainOnce)
return;
FailDrainOnce = false;
throw new InvalidOperationException("injected drain failure");
}
public void CompleteEntityProjectionRetirement()
{
CompleteCalls++;
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
if (!FailCompleteOnce)
return;
FailCompleteOnce = false;
throw new InvalidOperationException("injected completion failure");
}
}
private sealed class RecordingObserver : IRuntimeEventObserver
{
public List<RuntimeEventStamp> Entity { get; } = [];
public List<RuntimeEventStamp> Inventory { get; } = [];
public 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<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(CombatMode mode) { }
public uint LocalPlayerId => 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
}

View file

@ -94,29 +94,28 @@ public sealed class RuntimeSimulationFixtureHostTests
public RuntimeOnlySimulationFixtureHost() public RuntimeOnlySimulationFixtureHost()
{ {
EntityObjects = new RuntimeEntityObjectLifetime(); Runtime = new GameRuntime(new GameRuntimeDependencies(
Inventory = new RuntimeInventoryState(EntityObjects); this,
Character = new RuntimeCharacterState(); this,
this,
this,
CombatTime: () => _now));
Character.Spellbook.InstallMetadata(SpellMetadata()); Character.Spellbook.InstallMetadata(SpellMetadata());
Character.Spellbook.OnSpellLearned(1u); 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 GameRuntime Runtime { get; }
public RuntimeInventoryState Inventory { get; } public RuntimeEntityObjectLifetime EntityObjects =>
public RuntimeCharacterState Character { get; } Runtime.EntityObjects;
public RuntimeCommunicationState Communication { get; } public RuntimeInventoryState Inventory =>
public RuntimeActionState Actions { get; } Runtime.InventoryOwner;
public RuntimeLocalPlayerMovementState Movement { get; } public RuntimeCharacterState Character =>
Runtime.CharacterOwner;
public RuntimeCommunicationState Communication =>
Runtime.CommunicationOwner;
public RuntimeActionState Actions => Runtime.ActionOwner;
public RuntimeLocalPlayerMovementState Movement =>
Runtime.MovementOwner;
public List<string> Trace { get; } = []; public List<string> Trace { get; } = [];
public void Move() public void Move()
@ -219,13 +218,7 @@ public sealed class RuntimeSimulationFixtureHostTests
} }
public RuntimeSimulationOwnershipSnapshot CaptureOwnership() => public RuntimeSimulationOwnershipSnapshot CaptureOwnership() =>
RuntimeSimulationOwnership.Capture( Runtime.CaptureOwnership().Simulation;
EntityObjects,
Inventory,
Character,
Communication,
Actions,
Movement);
public bool IsInWorld => true; public bool IsInWorld => true;
public uint LocalPlayerId => 0x50000001u; public uint LocalPlayerId => 0x50000001u;
@ -297,12 +290,7 @@ public sealed class RuntimeSimulationFixtureHostTests
if (_disposed) if (_disposed)
return; return;
_disposed = true; _disposed = true;
Movement.Dispose(); Runtime.Dispose();
Actions.Dispose();
Communication.Dispose();
Character.Dispose();
Inventory.Dispose();
EntityObjects.Dispose();
} }
private static SpellTable SpellMetadata() private static SpellTable SpellMetadata()

View file

@ -157,6 +157,7 @@ public sealed class LiveSessionControllerTests
public int ActivateCount { get; private set; } public int ActivateCount { get; private set; }
public List<TestCommandBus> CommandBuses { get; } = []; public List<TestCommandBus> CommandBuses { get; } = [];
public List<LiveSessionCharacterSelection> Selections { get; } = []; public List<LiveSessionCharacterSelection> Selections { get; } = [];
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
public LiveSessionBinding BindSession(WorldSession session) public LiveSessionBinding BindSession(WorldSession session)
{ {
@ -200,10 +201,12 @@ public sealed class LiveSessionControllerTests
}); });
} }
public void ResetSessionState() public void ResetSessionState(
RuntimeGenerationToken retiringGeneration)
{ {
calls.Add("reset"); calls.Add("reset");
ResetCount++; ResetCount++;
ResetGenerations.Add(retiringGeneration);
OnReset?.Invoke(); OnReset?.Invoke();
if (FailResetOnce) if (FailResetOnce)
{ {
@ -449,6 +452,34 @@ public sealed class LiveSessionControllerTests
Assert.Same(operations.Sessions[1], controller.CurrentSession); Assert.Same(operations.Sessions[1], controller.CurrentSession);
} }
[Fact]
public void ResetCallbacksCarryTheExactRetiringScopeGeneration()
{
var calls = new List<string>();
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] [Theory]
[InlineData("detach", "stop")] [InlineData("detach", "stop")]
[InlineData("detach", "dispose")] [InlineData("detach", "dispose")]

View file

@ -221,7 +221,7 @@ public sealed class LiveSessionHostTests
Func<WorldSession, ILiveSessionCommandRouting> createCommands) => Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
new(controller, new LiveSessionHostBindings( new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands), Routing: new(createEvents, createCommands),
Reset: () => calls.Add("reset"), Reset: _ => calls.Add("reset"),
Selection: new( Selection: new(
id => calls.Add($"player:{id}"), id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"), id => calls.Add($"vitals:{id}"),

View file

@ -15,7 +15,7 @@ public sealed class LiveSessionLifecycleHostTests
var host = CreateHost(calls); var host = CreateHost(calls);
LiveSessionBinding binding = host.BindSession(sessionA); LiveSessionBinding binding = host.BindSession(sessionA);
host.ResetSessionState(); host.ResetSessionState(RuntimeGenerationToken.Initial);
host.ReportConnecting("host", 9000, "user"); host.ReportConnecting("host", 9000, "user");
host.ReportConnected(); host.ReportConnected();
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account"); var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
@ -67,7 +67,7 @@ public sealed class LiveSessionLifecycleHostTests
Func<WorldSession, LiveSessionBinding>? bind = null) => Func<WorldSession, LiveSessionBinding>? bind = null) =>
new(new LiveSessionLifecycleBindings( new(new LiveSessionLifecycleBindings(
Bind: bind ?? (session => CreateBinding(session, calls)), Bind: bind ?? (session => CreateBinding(session, calls)),
Reset: () => calls.Add("reset"), Reset: _ => calls.Add("reset"),
Connecting: (host, port, user) => Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"), calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"), Connected: () => calls.Add("connected"),

View file

@ -19,7 +19,7 @@ public sealed class RuntimeLiveSessionNoWindowTests
new LiveSessionRoutingFactories( new LiveSessionRoutingFactories(
_ => new EventRoute(calls), _ => new EventRoute(calls),
_ => new CommandRoute(calls)), _ => new CommandRoute(calls)),
() => calls.Add("reset"), _ => calls.Add("reset"),
new LiveSessionSelectionBindings( new LiveSessionSelectionBindings(
id => calls.Add($"player:{id}"), id => calls.Add($"player:{id}"),
_ => { }, _ => { },

View file

@ -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<string> LifecycleTrace => _operations.Trace;
public IReadOnlyList<string> 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<GameRuntime> delivery) =>
Deliver(Runtime.Generation, delivery);
public bool Deliver(
RuntimeGenerationToken expectedGeneration,
Action<GameRuntime> 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<GameRuntime> command) =>
Execute(Runtime.Generation, command);
public bool Execute(
RuntimeGenerationToken expectedGeneration,
Action<GameRuntime> 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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
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<string> 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<string> 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<string> 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<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> 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<string> 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<ClientObject> 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;
}
}
}