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? Log = null, Action? TimeSyncDiagnostic = null, ILiveSessionOperations? SessionOperations = null, Func? 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; } } /// /// 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. /// public sealed class GameRuntime : IGameRuntimeView, IRuntimeEventSource, IDisposable { private const int TeardownStageCount = 11; private readonly object _lifetimeGate = new(); private readonly Dictionary _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? 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(); } } /// /// The one flush body shared by every trigger that can send the batched /// SetCharacterOptions (0x01A1) blob: the explicit /// SaveOptions command (both IRuntimeCharacterCommands /// adapters), the 480 s auto-save timer, and the pre-logoff flush (the /// latter two wired via / in the constructor above). Reads /// / 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. /// /// The pre-check /// below is retail-faithful (CPlayerModule::UseTime @0x0059A710 /// opens with the identical m_bDirty 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 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. /// /// 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()); } } /// /// Makes transport and all per-generation routes inert while retaining the /// root for ordered host projection teardown. /// 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? 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? 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 _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 { 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); } } }