acdream/src/AcDream.Runtime/GameRuntime.cs
Erik 09029f9f4b fix(runtime,net): OP1 review fixes — server-seed gate, tick-wired auto-save/logout flush, fellowship mutual exclusion
Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.

MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.

MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.

SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).

SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.

SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.

SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.

SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.

SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).

SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.

SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.

Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.

Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
  from inside a flush callback can still be erased by the trailing
  "_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
  (same outcome whether the callback runs inside or outside the lock),
  not reachable from any current caller, not a one-liner to close
  correctly (needs a per-dirty-period generation token). Left as documented
  in the review; worth closing before the Options panel ever flushes from
  inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
  touching files this round doesn't otherwise edit (SocialActions.cs,
  CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
  the "one-liner in a file already being edited" instruction.

Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).

Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:39:51 +02:00

819 lines
31 KiB
C#

using System.Numerics;
using AcDream.Core.Net;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.Runtime;
public sealed record GameRuntimeDependencies(
IRuntimeCombatAttackOperations CombatAttackOperations,
IRuntimeCombatTargetOperations CombatTargetOperations,
IRuntimeCombatModeOperations CombatModeOperations,
IRuntimeSpellCastOperations SpellCastOperations,
TimeProvider? TimeProvider = null,
Action<string>? Log = null,
Action<string>? TimeSyncDiagnostic = null,
ILiveSessionOperations? SessionOperations = null,
Func<double>? CombatTime = null,
uint FirstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
int MaximumChatEntries = 500);
[Flags]
public enum GameRuntimeTeardownStage
{
None = 0,
HostLeasesReleased = 1 << 0,
EventsDetached = 1 << 1,
SessionDisposed = 1 << 2,
TransitReset = 1 << 3,
ActionsDisposed = 1 << 4,
MovementDisposed = 1 << 5,
CharacterDisposed = 1 << 6,
InventoryDisposed = 1 << 7,
CommunicationDisposed = 1 << 8,
IdentityDisposed = 1 << 9,
EntityObjectsDisposed = 1 << 10,
Complete =
HostLeasesReleased
| EventsDetached
| SessionDisposed
| TransitReset
| ActionsDisposed
| MovementDisposed
| CharacterDisposed
| InventoryDisposed
| CommunicationDisposed
| IdentityDisposed
| EntityObjectsDisposed,
}
public readonly record struct GameRuntimeOwnershipSnapshot(
bool IsDisposeRequested,
bool IsDisposeDrainActive,
bool IsDisposed,
int HostLeaseCount,
GameRuntimeTeardownStage CompletedTeardownStages,
LiveSessionOwnershipSnapshot Session,
RuntimeLocalPlayerIdentityOwnershipSnapshot PlayerIdentity,
RuntimeSimulationOwnershipSnapshot Simulation,
RuntimeWorldEnvironmentOwnershipSnapshot Environment,
RuntimeWorldTransitOwnershipSnapshot Transit,
RuntimeGenerationResetSnapshot GenerationReset,
GameRuntimeEventOwnershipSnapshot Events)
{
public bool IsConverged =>
IsDisposed
&& IsDisposeRequested
&& !IsDisposeDrainActive
&& HostLeaseCount == 0
&& CompletedTeardownStages == GameRuntimeTeardownStage.Complete
&& Session.IsConverged
&& PlayerIdentity.IsConverged
&& Simulation.IsConverged
&& Transit.IsSessionIdle
&& GenerationReset.IsConverged
&& Events.IsConverged;
}
internal enum GameRuntimeConstructionPoint
{
ClockCreated,
SessionCreated,
PlayerIdentityCreated,
EntityObjectsCreated,
InventoryCreated,
CharacterCreated,
CommunicationCreated,
MovementCreated,
ActionsCreated,
EnvironmentCreated,
TransitCreated,
EventsCreated,
}
internal sealed class GameRuntimeConstructionContext
{
public LiveSessionController? Session { get; set; }
public RuntimeLocalPlayerIdentityState? PlayerIdentity { get; set; }
public RuntimeEntityObjectLifetime? EntityObjects { get; set; }
public RuntimeInventoryState? Inventory { get; set; }
public RuntimeCharacterState? Character { get; set; }
public RuntimeCommunicationState? Communication { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
public GameRuntimeEventHub? Events { get; set; }
}
/// <summary>
/// One instance-scoped, presentation-independent ownership root for the
/// complete live client kernel. Graphical and direct hosts borrow this exact
/// object graph; they never reconstruct or copy its mutable state.
/// </summary>
public sealed class GameRuntime
: IGameRuntimeView,
IRuntimeEventSource,
IDisposable
{
private const int TeardownStageCount = 11;
private readonly object _lifetimeGate = new();
private readonly Dictionary<long, string> _hostLeases = [];
private readonly GameRuntimeEventHub _events;
private long _nextHostLeaseId;
private int _disposeStage;
private bool _disposeRequested;
private bool _disposeDrainActive;
private bool _disposed;
public GameRuntime(GameRuntimeDependencies dependencies)
: this(dependencies, faultInjection: null)
{
}
internal GameRuntime(
GameRuntimeDependencies dependencies,
Action<GameRuntimeConstructionPoint, GameRuntimeConstructionContext>?
faultInjection)
{
ArgumentNullException.ThrowIfNull(dependencies);
ArgumentNullException.ThrowIfNull(
dependencies.CombatAttackOperations);
ArgumentNullException.ThrowIfNull(
dependencies.CombatTargetOperations);
ArgumentNullException.ThrowIfNull(
dependencies.CombatModeOperations);
ArgumentNullException.ThrowIfNull(
dependencies.SpellCastOperations);
if (dependencies.MaximumChatEntries <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(dependencies.MaximumChatEntries));
}
var context = new GameRuntimeConstructionContext();
var construction = new ConstructionTransaction();
try
{
var clock = new GameRuntimeClock();
Fault(
GameRuntimeConstructionPoint.ClockCreated,
context,
faultInjection);
context.Session = dependencies.SessionOperations is null
? new LiveSessionController()
: new LiveSessionController(dependencies.SessionOperations);
construction.Own(context.Session);
Fault(
GameRuntimeConstructionPoint.SessionCreated,
context,
faultInjection);
context.PlayerIdentity = new RuntimeLocalPlayerIdentityState();
construction.Own(context.PlayerIdentity);
Fault(
GameRuntimeConstructionPoint.PlayerIdentityCreated,
context,
faultInjection);
context.EntityObjects = new RuntimeEntityObjectLifetime(
dependencies.FirstLocalEntityId,
dependencies.TimeProvider,
clock);
construction.Own(context.EntityObjects);
Fault(
GameRuntimeConstructionPoint.EntityObjectsCreated,
context,
faultInjection);
context.Inventory = new RuntimeInventoryState(
context.EntityObjects);
construction.Own(context.Inventory);
Fault(
GameRuntimeConstructionPoint.InventoryCreated,
context,
faultInjection);
context.Character = new RuntimeCharacterState(
timeProvider: dependencies.TimeProvider);
construction.Own(context.Character);
Fault(
GameRuntimeConstructionPoint.CharacterCreated,
context,
faultInjection);
context.Communication = new RuntimeCommunicationState(
dependencies.MaximumChatEntries);
construction.Own(context.Communication);
Fault(
GameRuntimeConstructionPoint.CommunicationCreated,
context,
faultInjection);
context.Movement = new RuntimeLocalPlayerMovementState();
// Campaign CH slice CH2: local jump refusals (CommenceJump/
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
// the SpewBox through the SAME AddText router server-sent
// WeenieErrors use. Wired here, at construction, because
// Communication (built just above) always exists before any
// PlayerMovementController is installed.
context.Movement.OnInterfaceText =
(text, type) => context.Communication.AddText(text, type);
construction.Own(context.Movement);
Fault(
GameRuntimeConstructionPoint.MovementCreated,
context,
faultInjection);
context.Actions = new RuntimeActionState(
context.Inventory.Transactions,
context.Character.Spellbook,
dependencies.CombatAttackOperations,
dependencies.CombatTargetOperations,
dependencies.CombatModeOperations,
dependencies.SpellCastOperations,
dependencies.CombatTime);
construction.Own(context.Actions);
Fault(
GameRuntimeConstructionPoint.ActionsCreated,
context,
faultInjection);
var environment = new RuntimeWorldEnvironmentState(
dependencies.TimeProvider,
dependencies.Log,
dependencies.TimeSyncDiagnostic);
Fault(
GameRuntimeConstructionPoint.EnvironmentCreated,
context,
faultInjection);
var transit = new RuntimeWorldTransitState(dependencies.Log);
Fault(
GameRuntimeConstructionPoint.TransitCreated,
context,
faultInjection);
var generationReset = new RuntimeGenerationReset(
transit,
context.Communication,
context.Inventory,
context.Actions,
context.Movement,
context.EntityObjects,
context.Character,
context.PlayerIdentity);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
context.EntityObjects.Entities,
context.EntityObjects.Physics,
context.Movement,
context.PlayerIdentity));
// C3c: the C3a conductor's "first act" — bind the publication
// owner the conductor was constructed without (it is built by
// RuntimeEntityObjectLifetime BEFORE
// RuntimeLocalPlayerPhysicsPublicationState exists; see the F2
// late-bind note on RuntimeLocalPlayerFirstEntryState's ctor).
context.EntityObjects.LocalPlayerFirstEntry.BindPublication(
context.Movement.PhysicsPublication);
context.EntityObjects.BindEventContext(
() => generationReset.ActiveRetiringGeneration
?? context.Session.Generation,
() => clock.FrameNumber);
// C0-2: bind the executor's live-input sources to the real
// Runtime owners now that both exist (RuntimeCharacterState at
// CharacterCreated, RuntimeLocalPlayerMovementState at
// MovementCreated - both after EntityObjectsCreated, so this
// cannot move earlier). UsePositionFromServer mirrors retail
// CommandInterpreter::UsePositionFromServer exactly; PlayerDistance
// is derived per Execute call from the live physics-controller
// position, matching the legacy remote path's own distance basis
// (LiveEntityNetworkUpdateController's MaxPhysicsDistance/dist).
// F3: the position source is nullable - a null Controller (the
// login-window drain, before the local player's own controller
// exists yet) must yield null, never a fabricated Vector3.Zero
// that would misclassify every remote entity as implausibly far.
context.EntityObjects.BindLiveInputs(
() => context.Character.UsePositionFromServer,
() => context.Movement.Controller?.Position);
context.Events = new GameRuntimeEventHub(
context.EntityObjects,
context.Communication,
context.Actions);
construction.Own(context.Events);
Fault(
GameRuntimeConstructionPoint.EventsCreated,
context,
faultInjection);
Clock = clock;
Session = context.Session;
PlayerIdentity = context.PlayerIdentity;
EntityObjects = context.EntityObjects;
InventoryOwner = context.Inventory;
CharacterOwner = context.Character;
CommunicationOwner = context.Communication;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
EnvironmentOwner = environment;
TransitOwner = transit;
GenerationReset = generationReset;
_events = context.Events;
// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11): wire
// retail's two remaining CPlayerModule::SaveToServer trigger
// sites — the 480 s auto-save timer (CPlayerModule::UseTime) and
// the pre-logoff flush (CPlayerSystem::LogOffCharacter) — through
// LiveSessionController's own tick/stop transaction, closing
// TS-71. Both share the SAME flush body the explicit SaveOptions
// command already uses (CharacterOptionsBlobSource.Capture +
// WorldSession.SendSetCharacterOptions); this talks to the
// WorldSession directly rather than through App's
// LiveSessionCommandRouter/LiveCommandBus, which is what keeps
// it off the S2 lock-order hazard the blast-lens review named.
context.Session.ConfigureAutoSaveTick(
session => FlushCharacterOptions(session, ifAutoSaveDue: true));
context.Session.ConfigurePreLogoffFlush(
session => FlushCharacterOptions(session, ifAutoSaveDue: false));
construction.Complete();
}
catch (Exception failure)
{
construction.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
/// <summary>
/// The one flush body shared by every trigger that can send the batched
/// <c>SetCharacterOptions (0x01A1)</c> blob: the explicit
/// <c>SaveOptions</c> command (both <c>IRuntimeCharacterCommands</c>
/// adapters), the 480 s auto-save timer, and the pre-logoff flush (the
/// latter two wired via <see cref="LiveSessionController.
/// ConfigureAutoSaveTick"/>/<see cref="LiveSessionController.
/// ConfigurePreLogoffFlush"/> in the constructor above). Reads
/// <see cref="CharacterOwner"/>/<see cref="InventoryOwner"/> at
/// invocation time, not construction time, so this is safe to bind
/// before either property's backing value is technically "public" —
/// both are always populated long before the session can ever tick or
/// stop.
/// <para>
/// The <see cref="RuntimeCharacterOptionsState.IsDirty"/> pre-check
/// below is retail-faithful (<c>CPlayerModule::UseTime @0x0059A710</c>
/// opens with the identical <c>m_bDirty</c> byte compare before it ever
/// touches the FPU timer math) AND load-bearing for allocation: without
/// it, the auto-save-tick hook would allocate a fresh flush closure on
/// EVERY <see cref="LiveSessionController.Tick"/> call — every frame,
/// for every live session, whether or not anything is actually
/// dirty — which is exactly the per-tick allocation the K4 headless
/// resource-envelope gate measures. Gating on the cheap flag first means
/// the closure below is only ever allocated on the rare tick where a
/// flush might really happen.
/// </para>
/// </summary>
private void FlushCharacterOptions(WorldSession session, bool ifAutoSaveDue)
{
RuntimeCharacterOptionsState options = CharacterOwner.Options;
if (!options.IsDirty)
return;
void SendBlob()
{
CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture(
CharacterOwner,
InventoryOwner.Shortcuts);
session.SendSetCharacterOptions(
echo.Options1,
echo.Options2,
echo.Shortcuts,
echo.FavoriteSpells,
echo.DesiredComponents,
echo.SpellbookFilters);
}
if (ifAutoSaveDue)
options.TryFlushIfAutoSaveDue(SendBlob);
else
options.TryFlush(SendBlob);
}
public GameRuntimeClock Clock { get; }
public LiveSessionController Session { get; }
public RuntimeLocalPlayerIdentityState PlayerIdentity { get; }
public RuntimeEntityObjectLifetime EntityObjects { get; }
public RuntimeInventoryState InventoryOwner { get; }
public RuntimeCharacterState CharacterOwner { get; }
public RuntimeCommunicationState CommunicationOwner { get; }
public RuntimeActionState ActionOwner { get; }
public RuntimeLocalPlayerMovementState MovementOwner { get; }
internal RuntimeLocalPlayerPhysicsPublicationState
LocalPlayerPhysicsPublication => MovementOwner.PhysicsPublication;
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
public RuntimeWorldTransitState TransitOwner { get; }
public RuntimeGenerationReset GenerationReset { get; }
public RuntimePlacementProjectionChannel Placements =>
EntityObjects.Placements;
public RuntimeGenerationToken Generation => Session.Generation;
public RuntimeLifecycleSnapshot Lifecycle
{
get
{
RuntimeLifecycleState state;
if (_disposed)
state = RuntimeLifecycleState.Disposed;
else if (Session.IsInWorld)
state = RuntimeLifecycleState.InWorld;
else if (Session.CurrentSession is not null)
state = RuntimeLifecycleState.Starting;
else if (Session.SessionGeneration == 0UL)
state = RuntimeLifecycleState.Constructed;
else
state = RuntimeLifecycleState.Stopped;
return new RuntimeLifecycleSnapshot(
Generation,
state,
PlayerIdentity.ServerGuid,
Session.CurrentSession is not null);
}
}
IGameRuntimeClock IGameRuntimeView.Clock => Clock;
public IRuntimeEntityView Entities => EntityObjects.EntityView;
public IRuntimeInventoryView Inventory => EntityObjects.InventoryView;
public IRuntimeInventoryStateView InventoryState => InventoryOwner.View;
public IRuntimeCharacterView Character => CharacterOwner.View;
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
public IRuntimeChatView Chat => CommunicationOwner.View;
public IRuntimeActionView Actions => ActionOwner.View;
public IRuntimeMovementView Movement => MovementOwner.View;
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
public IRuntimePortalView Portal => TransitOwner;
internal IGameRuntimeEventSink EventSink => _events;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
new(
Generation,
Lifecycle.State,
Clock.FrameNumber,
Entities.Count,
Entities.MaterializedCount,
Inventory.ObjectCount,
Inventory.ContainerCount,
InventoryState.Snapshot,
Character.Snapshot,
Social.Snapshot,
Chat.Revision,
Chat.Count,
Actions.Snapshot,
Movement.Snapshot,
Environment.Snapshot,
Environment.Ownership,
Portal.Snapshot,
Portal.Ownership);
public RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
IRuntimeLocalPlayerFrameHost host,
IRuntimeMovementInputSource input)
{
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
return new RuntimeLocalPlayerFrameController(
host,
input,
() =>
{
_events.EmitMovement(MovementOwner.Snapshot);
// Slice 5.3: the local-player movement publish already fires
// once per advanced frame for both graphical and no-window
// hosts (RuntimeLocalPlayerFrameController.RunPostNetworkCommandPhase),
// so the client-local vendor distance watcher piggybacks on
// it instead of adding a second polling loop.
RuntimeVendorRangeQuery.EnforceRange(this);
});
}
public void ResetGeneration(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host) =>
GenerationReset.Reset(retiringGeneration, host);
public IDisposable Subscribe(IRuntimeEventObserver observer)
{
lock (_lifetimeGate)
{
ObjectDisposedException.ThrowIf(
_disposeRequested || _disposed,
this);
return _events.Subscribe(observer);
}
}
public IDisposable AcquireHostLease(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
lock (_lifetimeGate)
{
ObjectDisposedException.ThrowIf(
_disposeRequested || _disposed,
this);
long id = checked(++_nextHostLeaseId);
_hostLeases.Add(id, name);
return new HostLease(this, id);
}
}
public GameRuntimeOwnershipSnapshot CaptureOwnership()
{
lock (_lifetimeGate)
{
return new GameRuntimeOwnershipSnapshot(
_disposeRequested,
_disposeDrainActive,
_disposed,
_hostLeases.Count,
CompletedTeardownStages,
Session.CaptureOwnership(),
PlayerIdentity.CaptureOwnership(),
RuntimeSimulationOwnership.Capture(
EntityObjects,
InventoryOwner,
CharacterOwner,
CommunicationOwner,
ActionOwner,
MovementOwner),
EnvironmentOwner.CaptureOwnership(),
TransitOwner.CaptureOwnership(),
GenerationReset.CaptureSnapshot(),
_events.CaptureOwnership());
}
}
/// <summary>
/// Makes transport and all per-generation routes inert while retaining the
/// root for ordered host projection teardown.
/// </summary>
public void StopSession()
{
Session.Dispose();
if (!Session.IsDisposalComplete)
{
throw new InvalidOperationException(
"The Runtime session shutdown was deferred by a re-entrant callback.");
}
}
public void Dispose()
{
lock (_lifetimeGate)
{
if (_disposed || _disposeDrainActive)
return;
_disposeRequested = true;
_disposeDrainActive = true;
}
List<Exception>? completedStageFailures = null;
try
{
while (_disposeStage < TeardownStageCount)
{
bool complete;
try
{
complete = DrainCurrentStage();
}
catch (Exception error)
{
complete = IsCurrentStageComplete();
if (!complete)
throw;
(completedStageFailures ??= []).Add(error);
}
if (!complete)
{
throw new InvalidOperationException(
$"GameRuntime teardown stage {_disposeStage} did not complete.");
}
_disposeStage++;
}
lock (_lifetimeGate)
_disposed = true;
}
finally
{
lock (_lifetimeGate)
_disposeDrainActive = false;
}
if (completedStageFailures is not null)
{
throw new AggregateException(
"GameRuntime reached terminal ownership with callback failures.",
completedStageFailures);
}
}
private GameRuntimeTeardownStage CompletedTeardownStages =>
_disposeStage switch
{
<= 0 => GameRuntimeTeardownStage.None,
1 => GameRuntimeTeardownStage.HostLeasesReleased,
2 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached,
3 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed,
4 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed
| GameRuntimeTeardownStage.TransitReset,
5 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed
| GameRuntimeTeardownStage.TransitReset
| GameRuntimeTeardownStage.ActionsDisposed,
6 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed
| GameRuntimeTeardownStage.TransitReset
| GameRuntimeTeardownStage.ActionsDisposed
| GameRuntimeTeardownStage.MovementDisposed,
7 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed
| GameRuntimeTeardownStage.TransitReset
| GameRuntimeTeardownStage.ActionsDisposed
| GameRuntimeTeardownStage.MovementDisposed
| GameRuntimeTeardownStage.CharacterDisposed,
8 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed
| GameRuntimeTeardownStage.TransitReset
| GameRuntimeTeardownStage.ActionsDisposed
| GameRuntimeTeardownStage.MovementDisposed
| GameRuntimeTeardownStage.CharacterDisposed
| GameRuntimeTeardownStage.InventoryDisposed,
9 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
10 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
_ => GameRuntimeTeardownStage.Complete,
};
private bool DrainCurrentStage()
{
switch (_disposeStage)
{
case 0:
lock (_lifetimeGate)
{
if (_hostLeases.Count != 0)
{
throw new InvalidOperationException(
"GameRuntime cannot retire while host leases remain: "
+ string.Join(", ", _hostLeases.Values));
}
}
return true;
case 1:
_events.Dispose();
return _events.CaptureOwnership().IsConverged;
case 2:
StopSession();
return Session.CaptureOwnership().IsConverged;
case 3:
GenerationReset.DrainPending();
TransitOwner.ResetSession();
return TransitOwner.CaptureOwnership().IsSessionIdle;
case 4:
ActionOwner.Dispose();
return ActionOwner.CaptureOwnership().IsConverged;
case 5:
MovementOwner.Dispose();
return MovementOwner.CaptureOwnership().IsConverged;
case 6:
CharacterOwner.Dispose();
return CharacterOwner.CaptureOwnership().IsConverged;
case 7:
InventoryOwner.Dispose();
return InventoryOwner.CaptureOwnership().IsConverged;
case 8:
CommunicationOwner.Dispose();
return CommunicationOwner.CaptureOwnership().IsConverged;
case 9:
PlayerIdentity.Dispose();
return PlayerIdentity.CaptureOwnership().IsConverged;
case 10:
EntityObjects.Dispose();
return EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
default:
return true;
}
}
private bool IsCurrentStageComplete() =>
_disposeStage switch
{
0 => HostLeaseCount == 0,
1 => _events.CaptureOwnership().IsConverged,
2 => Session.CaptureOwnership().IsConverged,
3 => TransitOwner.CaptureOwnership().IsSessionIdle,
4 => ActionOwner.CaptureOwnership().IsConverged,
5 => MovementOwner.CaptureOwnership().IsConverged,
6 => CharacterOwner.CaptureOwnership().IsConverged,
7 => InventoryOwner.CaptureOwnership().IsConverged,
8 => CommunicationOwner.CaptureOwnership().IsConverged,
9 => PlayerIdentity.CaptureOwnership().IsConverged,
10 => EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
_ => true,
};
private int HostLeaseCount
{
get
{
lock (_lifetimeGate)
return _hostLeases.Count;
}
}
private void ReleaseHostLease(long id)
{
lock (_lifetimeGate)
_hostLeases.Remove(id);
}
private static void Fault(
GameRuntimeConstructionPoint point,
GameRuntimeConstructionContext context,
Action<GameRuntimeConstructionPoint, GameRuntimeConstructionContext>?
faultInjection) =>
faultInjection?.Invoke(point, context);
private sealed class HostLease(GameRuntime owner, long id) : IDisposable
{
private GameRuntime? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.ReleaseHostLease(id);
}
private sealed class ConstructionTransaction
{
private readonly List<IDisposable> _owners = [];
private bool _complete;
public void Own(IDisposable owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_complete)
throw new InvalidOperationException(
"Runtime construction already completed.");
_owners.Add(owner);
}
public void Complete()
{
_complete = true;
_owners.Clear();
}
public void RollbackAndThrow(Exception failure)
{
var failures = new List<Exception> { failure };
for (int i = _owners.Count - 1; i >= 0; i--)
{
try
{
_owners[i].Dispose();
}
catch (Exception cleanup)
{
failures.Add(cleanup);
}
}
_owners.Clear();
if (failures.Count == 1)
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(failure)
.Throw();
throw new AggregateException(
"GameRuntime construction and rollback both failed.",
failures);
}
}
}