From 7771c07fb69e3a9ec0752d7826b58a7be5a6a320 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 18:28:32 +0200 Subject: [PATCH] refactor(app): compose session and player startup Move streaming, live-session, hydration, local-player, combat, and teleport construction behind the typed Phase-7 boundary. Add exact-owner runtime bindings and focused spawn-claim classification so partial startup rolls back without retaining old session targets while preserving the accepted construction and frame dependencies. Co-authored-by: Codex --- docs/ISSUES.md | 9 +- docs/architecture/code-structure.md | 13 +- docs/plans/2026-04-11-roadmap.md | 12 +- docs/plans/2026-05-12-milestones.md | 14 +- ...lice-8-checkpoint-i-ordered-composition.md | 9 +- ...ow-slice-8-checkpoint-i7-session-player.md | 19 +- ...amewindow-slice-8-composition-lifecycle.md | 3 +- .../Combat/LiveCombatAttackOperations.cs | 33 + .../Composition/SessionPlayerComposition.cs | 749 ++++++++++++++++++ .../SessionPlayerRuntimeBindings.cs | 95 +++ .../Input/CameraPointerInputController.cs | 32 + .../Rendering/DevToolsFramePresenter.cs | 31 + .../Rendering/DevToolsRuntimeSources.cs | 31 + src/AcDream.App/Rendering/GameWindow.cs | 621 +++------------ .../Settings/RuntimeSettingsController.cs | 30 + .../DatSpawnClaimHydrationClassifier.cs | 57 ++ .../LocalPlayerTeleportController.cs | 28 + .../World/LiveEntityHydrationController.cs | 28 + .../World/LiveEntityHydrationPorts.cs | 28 + src/AcDream.App/World/LiveEntityRuntime.cs | 28 + .../Combat/LiveCombatAttackOperationsTests.cs | 20 +- .../SessionPlayerCompositionTests.cs | 160 ++++ .../Rendering/DevToolsFramePresenterTests.cs | 19 + .../Rendering/DevToolsRuntimeSourcesTests.cs | 19 + .../GameWindowRenderLeafCompositionTests.cs | 18 +- .../GameWindowSlice8BoundaryTests.cs | 31 +- .../RuntimeSettingsControllerTests.cs | 21 + .../LocalPlayerTeleportControllerTests.cs | 33 + ...iveEntityRuntimeComponentLifecycleTests.cs | 23 + .../LiveEntityHydrationControllerTests.cs | 37 + .../World/UpdateFrameOrchestratorTests.cs | 40 +- 31 files changed, 1759 insertions(+), 532 deletions(-) create mode 100644 src/AcDream.App/Composition/SessionPlayerComposition.cs create mode 100644 src/AcDream.App/Composition/SessionPlayerRuntimeBindings.cs create mode 100644 src/AcDream.App/Streaming/DatSpawnClaimHydrationClassifier.cs create mode 100644 tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 13d6e872..c8a4dfa5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,13 +27,14 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 and Slice - 8 checkpoints A–H are complete; I.1–I.6 now own the executable ordered + 8 checkpoints A–H are complete; I.1–I.7 now own the executable ordered startup path through retained UI and live presentation, including the canonical runtime/world, selection/radar, landblock publishers, and portal/sky/particle resources. - `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 2,840 raw lines. I.7–I.9 plus the remaining - shutdown/closeout + I.7 now owns streaming/session/hydration/local-player/teleport startup and + every late edge explicitly. `GameWindow.OnUpdate` and `GameWindow.OnRender` + are typed orchestration handoffs and the class is 2,479 raw lines. I.8–I.9 + plus the remaining shutdown/closeout cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 93d70450..d4e43d0d 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -731,11 +731,14 @@ retained UI, live presentation, and landblock publication into ordered composition phases. Typed exact-owner sources bridge later session, selection, radar, view-plane, diagnostics, inventory, hydration, and automation owners without callbacks to the window. The old item-use and selection wrapper -methods are deleted. `GameWindow` is 2,840 raw lines after I.6. I.7–L remain -active, with session, streaming, and hydration composition next. The App gate -passes 3,407 tests / 3 intentional skips and the complete Release suite passes -7,779 tests / 5 intentional skips. The clean solution build retains only the -17 test-project warnings tracked by #228; the I.6 behavior, architecture, and +methods are deleted. I.7 now owns the complete streaming/session/hydration/ +local-player/teleport construction phase and every late edge through named +exact-owner tokens; the former 432-line inline body and spawn-claim memo are +gone. `GameWindow` is 2,479 raw lines after I.7. I.8–L remain active, with +atomic frame-root publication and terminal session start next. The App gate +passes 3,420 tests / 3 intentional skips and the complete Release suite passes +7,792 tests / 5 intentional skips. The clean solution build retains only the +17 test-project warnings tracked by #228; the I.7 behavior, architecture, and adversarial reviews are clean. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index e6f2ae7b..44200d11 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -97,15 +97,17 @@ runtime-target binding without replay, and one current/toon settings owner. `GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an atomic update/render frame-root pair, prepared portal fallback/transfer, and -checked retryable GL leaf construction and state mutation. Checkpoint I.1–I.6 +checked retryable GL leaf construction and state mutation. Checkpoint I.1–I.7 now provide the executable ordered startup pipeline through retained UI and live presentation. The canonical live runtime/world, presentation/effect controllers, selection/radar, landblock publishers, and portal/sky/particle resources have transactional phase ownership and exact-owner late bindings. -`GameWindow` is now 2,840 raw lines. I.7–I.9, shutdown, canonical soak -snapshots, and closeout checkpoints J–L remain. The App gate passes 3,407 tests -/ 3 intentional skips, the complete Release suite passes 7,779 tests / 5 -skips, and all three I.6 corrected-diff reviews are clean. +I.7 adds the complete streaming/session/hydration/local-player/teleport phase, +including explicit reversible ownership for every late edge. `GameWindow` is +now 2,479 raw lines. I.8–I.9, shutdown, canonical soak snapshots, and closeout +checkpoints J–L remain. The App gate passes 3,420 tests / 3 intentional skips, +the complete Release suite passes 7,792 tests / 5 skips, and all three I.7 +corrected-diff reviews are clean. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index f7c8c3da..8b7cbef6 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -53,15 +53,17 @@ startup application, and inert late target binding. `GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky resource roots, one retained Host/runtime lease, an atomic update/render root pair, a prepared portal fallback/transfer transaction, and checked retryable GL -leaf construction. Checkpoint I.1–I.6 now provide the executable ordered +leaf construction. Checkpoint I.1–I.7 now provide the executable ordered startup pipeline through retained UI and live presentation. The canonical live runtime/world, presentation/effect controllers, selection/radar, landblock publishers, and portal/sky/particle resources have transactional phase -ownership and exact-owner late bindings. `GameWindow` is 2,840 raw lines. -I.7–I.9 and checkpoints J–L remain active. Issue #232 tracks process-residency -variance in the soak without loosening its leak threshold. The App gate passes -3,407 tests / 3 skips, the complete Release suite passes 7,779 tests / 5 skips, -and all three I.6 corrected-diff reviews are clean. +ownership and exact-owner late bindings. I.7 now owns the complete streaming, +session, hydration, local-player, combat, and teleport startup graph, including +named reversible late edges. `GameWindow` is 2,479 raw lines. I.8–I.9 and +checkpoints J–L remain active. Issue #232 tracks process-residency variance in +the soak without loosening its leak threshold. The App gate passes 3,420 tests +/ 3 skips, the complete Release suite passes 7,792 tests / 5 skips, and all +three I.7 corrected-diff reviews are clean. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md index 98cf96ee..5a37c4e4 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md @@ -281,9 +281,16 @@ ledger only after the complete pipeline is live. under the same ownership contract. `GameWindow.cs` is 2,840 raw lines; the App gate passes 3,407 tests / 3 intentional skips and the complete Release suite passes 7,779 tests / 5 intentional skips. I.7 session, streaming, and - hydration composition is the active cut; its ownership, rollback, and gate + hydration composition is complete; its ownership, rollback, and gate contract is [`2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md`](2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md). +- I.7 removes the former 432-line Phase-7 body. Streaming, session, hydration, + local-player, combat, and teleport startup now compose through one typed + result; all late edges have named reversible exact-owner tokens. The + spawn-claim DAT range memo has a focused owner. `GameWindow.cs` is 2,479 raw + lines. App Release passes 3,420 tests / 3 skips; the complete Release suite + passes 7,792 tests / 5 skips with the same 17 warnings tracked by #228. I.8 + frame-root publication and terminal session start is the active cut. Checkpoint I remains active until I.9 closes the complete nine-phase pipeline. ## 6. Automated acceptance diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md index b5db9889..9b914201 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md @@ -1,6 +1,6 @@ # GameWindow Slice 8 — Checkpoint I.7 Session and Player Composition -**Status:** Active +**Status:** Complete 2026-07-22 **Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md` @@ -183,3 +183,20 @@ edge has explicit reversible ownership, failure and retry gates pass, the old window body is gone, documentation/memory report the measured class size and test totals, and the commit is independently buildable. I.8 then composes the update/render roots and terminal session start over the typed result graph. + +## 8. Completion evidence + +- `SessionPlayerCompositionPhase` is the production Phase-7 path. The former + 432-line radius/streaming/session/hydration/player/teleport block is absent + from `GameWindow`. +- `SessionPlayerRuntimeBindings` plus exact-owner tokens cover every Phase-7 + late edge. Focused tests cover reverse retry, rebind, and stale-token safety. +- `DatSpawnClaimHydrationClassifier` owns the former window memo and exact + indoor-cell boundaries, including missing/empty DAT cases and reset. +- `GameWindow.cs` is 2,479 raw lines, down 13,244 lines (84.2%) from the + 15,723-line campaign baseline. +- App Release: 3,420 passed / 3 intentional skips. Complete Release suite: + 7,792 passed / 5 intentional skips. Clean solution build: 0 errors and the + 17 pre-existing test warnings tracked by #228. +- Behavior/order, architecture/ownership, and adversarial rollback reviews are + clean. No gameplay mechanism changed and no divergence row was introduced. diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 6db74fff..0ff209a9 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -29,7 +29,8 @@ audit. - [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots explicit single ownership and transfer seams. - [ ] I — group `OnLoad` into small ordered, fakeable composition phases with - transactional partial-acquisition rollback. Active detailed plan: + transactional partial-acquisition rollback. I.1–I.7 are complete; I.8 + frame-root publication and terminal session start is active. Detailed plan: [`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md). - [ ] J — move the exact retryable shutdown manifest to a focused lifetime owner and prove all partial-load/reentrant/retry paths. diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs index 1ccf7994..e84945fa 100644 --- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -67,6 +67,22 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations _owner = owner; } + public IDisposable BindOwned(ICombatAttackOperations owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (_owner is not null) + throw new InvalidOperationException( + "Combat attack operations are already bound."); + _owner = owner; + return new Binding(this, owner); + } + + private void Unbind(ICombatAttackOperations expected) + { + if (ReferenceEquals(_owner, expected)) + _owner = null; + } + public bool CanStartAttack() => _owner?.CanStartAttack() == true; public void PrepareAttackRequest() => _owner?.PrepareAttackRequest(); public bool SendAttack(AttackHeight height, float power) => @@ -75,6 +91,23 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations public bool IsDualWield => _owner?.IsDualWield == true; public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true; public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true; + + private sealed class Binding : IDisposable + { + private CombatAttackOperationsSlot? _slot; + private readonly ICombatAttackOperations _expected; + + public Binding( + CombatAttackOperationsSlot slot, + ICombatAttackOperations expected) + { + _slot = slot; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _slot, null)?.Unbind(_expected); + } } /// diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs new file mode 100644 index 00000000..f867a6df --- /dev/null +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -0,0 +1,749 @@ +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Interaction; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Settings; +using AcDream.App.Streaming; +using AcDream.App.Update; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Selection; +using AcDream.Core.World; +using Silk.NET.Windowing; + +namespace AcDream.App.Composition; + +internal sealed record SessionPlayerDependencies( + RuntimeOptions Options, + IWindow Window, + object DatLock, + RuntimeSettingsController Settings, + SettingsDevToolsResult SettingsDevTools, + PhysicsEngine PhysicsEngine, + PhysicsDataCache PhysicsDataCache, + WorldGameState WorldGameState, + WorldEvents WorldEvents, + SelectionState Selection, + ClientObjectTable Objects, + EntityClassificationCache ClassificationCache, + LiveEntityRuntimeSlot RuntimeSlot, + LiveEntityAnimationRuntimeView AnimatedEntities, + RemoteMovementObservationTracker RemoteMovementObservations, + RemotePhysicsUpdater RemotePhysicsUpdater, + RemoteInboundMotionDispatcher RemoteInboundMotion, + RetailInboundEventDispatcher InboundEntityEvents, + DeferredLiveEntityMotionRuntimeBindings MotionBindings, + LocalPlayerIdentityState PlayerIdentity, + LocalPlayerControllerSlot PlayerController, + LocalPlayerPhysicsHostSlot PlayerHost, + LocalPlayerModeState PlayerMode, + ChaseCameraInputState ChaseCameraInput, + LocalPlayerOutboundController PlayerOutbound, + DeferredLocalPlayerTeleportNetworkSink TeleportSink, + LiveWorldOriginState WorldOrigin, + WorldRenderRangeState RenderRange, + LocalPlayerShadowState PlayerShadow, + LocalPlayerSkillState PlayerSkills, + ViewportAspectState ViewportAspect, + PlayerApproachCompletionState PlayerApproachCompletions, + PointerPositionState PointerPosition, + DispatcherMovementInputSource MovementInput, + IInputCaptureSource InputCapture, + AcDream.App.Rendering.Vfx.ParticleVisibilityController ParticleVisibility, + TranslucencyFadeManager TranslucencyFades, + EntityEffectPoseRegistry EffectPoses, + UpdateFrameClock UpdateClock, + MovementTruthDiagnosticController MovementDiagnostics, + CombatAttackOperationsSlot CombatAttackOperations, + CombatState Combat, + CombatFeedbackSlot CombatFeedback, + TransferableResourceSlot PortalTunnelFallback, + Action Log); + +internal sealed record SessionPlayerResult( + LandblockStreamer Streamer, + StreamingController Streaming, + StreamingOriginRecenterCoordinator StreamingOriginRecenter, + WorldRevealCoordinator WorldReveal, + DatSpawnClaimHydrationClassifier SpawnClaimHydration, + LiveSessionController LiveSession, + LiveEntityHydrationController Hydration, + LiveEntityNetworkUpdateController NetworkUpdates, + LiveEntityLivenessController Liveness, + LiveEntitySessionController SessionEvents, + GameplayInputFrameController GameplayInput, + StreamingFrameController StreamingFrame, + LocalPlayerAnimationController LocalPlayerAnimation, + LocalPlayerShadowSynchronizer LocalPlayerShadow, + LiveLocalPlayerFrameRuntime LocalPlayerFrameRuntime, + RetailLocalPlayerFrameController LocalPlayerFrame, + LiveSpatialPresentationReconciler LiveSpatialReconciler, + LiveObjectFrameController LiveObjectFrame, + PlayerModeController PlayerMode, + PlayerModeAutoEntry PlayerModeAutoEntry, + LocalPlayerTeleportController LocalTeleport, + SessionPlayerRuntimeBindings RuntimeBindings); + +internal interface IGameWindowSessionPlayerPublication +{ + void PublishSessionPlayer(SessionPlayerResult result); +} + +internal enum SessionPlayerCompositionPoint +{ + StreamingRadiiResolved, + StreamerCreated, + StreamerStarted, + StreamingCreated, + RuntimeSettingsBound, + WorldRevealCreated, + LiveSessionCreated, + HydrationCreated, + LiveEntityGraphBound, + CombatOperationsBound, + GameplayInputBound, + UpdateLeavesCreated, + PlayerModeBound, + PortalTransferred, + TeleportBound, + ResultPublished, +} + +internal sealed class SessionPlayerCompositionPhase + : ISessionPlayerCompositionPhase< + HostInputCameraResult, + ContentEffectsAudioResult, + WorldRenderResult, + InteractionRetainedUiResult, + LivePresentationResult, + SessionPlayerResult> +{ + private readonly SessionPlayerDependencies _dependencies; + private readonly IGameWindowSessionPlayerPublication _publication; + private readonly Action? _faultInjection; + + public SessionPlayerCompositionPhase( + SessionPlayerDependencies dependencies, + IGameWindowSessionPlayerPublication publication, + Action? faultInjection = null) + { + _dependencies = dependencies + ?? throw new ArgumentNullException(nameof(dependencies)); + _publication = publication + ?? throw new ArgumentNullException(nameof(publication)); + _faultInjection = faultInjection; + } + + public SessionPlayerResult Compose( + HostInputCameraResult host, + ContentEffectsAudioResult content, + WorldRenderResult world, + InteractionRetainedUiResult interaction, + LivePresentationResult live) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(interaction); + ArgumentNullException.ThrowIfNull(live); + + var scope = new CompositionAcquisitionScope(); + SessionPlayerRuntimeBindings? bindings = null; + bool bindingsOwnedByScope = false; + try + { + SessionPlayerResult result = ComposeCore( + host, + content, + world, + interaction, + live, + scope, + ref bindings, + ref bindingsOwnedByScope); + scope.Complete(); + return result; + } + catch (Exception failure) + { + if (bindings is not null && !bindingsOwnedByScope) + { + scope.Own( + "session/player runtime bindings", + bindings, + static value => value.Dispose()); + bindingsOwnedByScope = true; + } + + scope.RollbackAndThrow(failure); + throw new System.Diagnostics.UnreachableException(); + } + } + + private SessionPlayerResult ComposeCore( + HostInputCameraResult host, + ContentEffectsAudioResult content, + WorldRenderResult world, + InteractionRetainedUiResult interaction, + LivePresentationResult live, + CompositionAcquisitionScope scope, + ref SessionPlayerRuntimeBindings? bindings, + ref bool bindingsOwnedByScope) + { + SessionPlayerDependencies d = _dependencies; + WorldRenderFoundation foundation = world.Foundation; + int nearRadius = d.Settings.ResolvedQuality.NearRadius; + int farRadius = d.Settings.ResolvedQuality.FarRadius; + if (d.Options.LegacyStreamRadius is { } legacyRadius) + { + nearRadius = legacyRadius; + farRadius = Math.Max(legacyRadius, farRadius); + } + d.RenderRange.NearRadius = nearRadius; + d.RenderRange.FarRadius = farRadius; + d.Log( + $"streaming: nearRadius={nearRadius} " + + $"(window={2 * nearRadius + 1}x{2 * nearRadius + 1}) " + + $"farRadius={farRadius} " + + $"(window={2 * farRadius + 1}x{2 * farRadius + 1})"); + Fault(SessionPlayerCompositionPoint.StreamingRadiiResolved); + + var landblockBuildFactory = new LandblockBuildFactory( + content.Dats, + d.DatLock, + world.TerrainBuild.HeightTable, + d.PhysicsDataCache, + d.Options.DumpSceneryZ); + var streamerLease = scope.Acquire( + "landblock streamer", + () => LandblockStreamer.CreateForRequests( + landblockBuildFactory.Build, + (id, landblock) => + { + if (landblock is null) + return null; + uint x = (id >> 24) & 0xFFu; + uint y = (id >> 16) & 0xFFu; + return AcDream.Core.Terrain.LandblockMesh.Build( + landblock.Heightmap, + x, + y, + world.TerrainBuild.HeightTable, + world.TerrainBuild.Blending, + world.TerrainBuild.SurfaceCache); + }), + static value => value.Dispose()); + Fault(SessionPlayerCompositionPoint.StreamerCreated); + streamerLease.Resource.Start(); + Fault(SessionPlayerCompositionPoint.StreamerStarted); + + var streaming = new StreamingController( + enqueueLoad: (id, kind, generation) => streamerLease.Resource.EnqueueLoad( + new LandblockBuildRequest( + id, + kind, + generation, + new LandblockBuildOrigin( + d.WorldOrigin.CenterX, + d.WorldOrigin.CenterY))), + enqueueUnload: streamerLease.Resource.EnqueueUnload, + drainCompletions: streamerLease.Resource.DrainCompletions, + state: live.WorldState, + nearRadius: nearRadius, + farRadius: farRadius, + presentationPipeline: live.LandblockPipeline, + clearPendingLoads: streamerLease.Resource.ClearPendingLoads); + var streamingOriginRecenter = new StreamingOriginRecenterCoordinator( + streaming, + d.WorldOrigin); + streaming.MaxCompletionsPerFrame = + d.Settings.ResolvedQuality.MaxCompletionsPerFrame; + Fault(SessionPlayerCompositionPoint.StreamingCreated); + + bindings = new SessionPlayerRuntimeBindings(); + var settingsTargets = new RuntimeSettingsTargets( + new SilkRuntimeDisplayWindowTarget(d.Window), + live.DrawDispatcher, + foundation.TerrainAtlas, + streaming, + d.RenderRange, + interaction.RetainedUi?.Host.Root, + d.Log); + bindings.Adopt( + "runtime settings targets", + d.Settings.BindRuntimeTargetsOwned(settingsTargets)); + Fault(SessionPlayerCompositionPoint.RuntimeSettingsBound); + + var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier( + content.Dats, + d.DatLock); + var worldReveal = new WorldRevealCoordinator( + streaming.IsRenderNeighborhoodResident, + d.PhysicsEngine.IsSpawnCellReady, + d.PhysicsEngine.IsNeighborhoodTerrainResident, + () => live.DrawDispatcher.CompositeTexturesReady, + (destinationCell, radius) => live.DrawDispatcher.PrepareCompositeTextures( + live.WorldState.Entities, + live.WorldState.FlatViewGeneration, + destinationCell, + radius), + live.DrawDispatcher.InvalidateCompositeWarmupReadiness, + spawnClaimClassifier.IsUnhydratable, + d.Log); + Fault(SessionPlayerCompositionPoint.WorldRevealCreated); + + return CompleteSessionPlayer( + host, + content, + world, + interaction, + live, + scope, + streamerLease, + streaming, + streamingOriginRecenter, + worldReveal, + spawnClaimClassifier, + bindings, + ref bindingsOwnedByScope); + } + + private SessionPlayerResult CompleteSessionPlayer( + HostInputCameraResult host, + ContentEffectsAudioResult content, + WorldRenderResult world, + InteractionRetainedUiResult interaction, + LivePresentationResult live, + CompositionAcquisitionScope scope, + CompositionAcquisitionScope.CompositionAcquisitionLease + streamerLease, + StreamingController streaming, + StreamingOriginRecenterCoordinator streamingOriginRecenter, + WorldRevealCoordinator worldReveal, + DatSpawnClaimHydrationClassifier spawnClaimClassifier, + SessionPlayerRuntimeBindings bindings, + ref bool bindingsOwnedByScope) + { + SessionPlayerDependencies d = _dependencies; + var networkUpdateBridge = new DeferredLiveEntityNetworkUpdateSink(); + var sealedDungeonCells = new DatSealedDungeonCellClassifier( + content.Dats, + d.DatLock); + var projectionMaterializer = new DatLiveEntityProjectionMaterializer( + d.Options, + content.Dats, + live.LiveEntities, + d.PhysicsDataCache, + content.AnimationLoader, + live.EntitySpawnAdapter, + world.Foundation.TextureCache, + d.ClassificationCache, + d.EffectPoses, + live.EquippedChildren, + d.WorldGameState, + d.WorldEvents, + d.PhysicsEngine.ShadowObjects, + content.CollisionBuilder, + live.ProjectileController, + live.AnimationPresenter, + live.StaticAnimationScheduler, + d.WorldOrigin, + d.UpdateClock); + var originCoordinator = new LiveEntityWorldOriginCoordinator( + d.WorldOrigin, + streaming, + live.WorldState, + worldReveal, + d.PlayerIdentity, + sealedDungeonCells, + d.Log); + + var liveSessionLease = scope.Acquire( + "live-session controller", + static () => new LiveSessionController(), + static value => value.Dispose()); + LiveSessionController liveSession = liveSessionLease.Resource; + bindings.Adopt( + "retained-UI live session", + interaction.LateBindings.Session.Bind(liveSession)); + var localPhysicsTimestamps = + new LiveSessionLocalPhysicsTimestampPublisher( + d.PlayerIdentity, + liveSession); + Fault(SessionPlayerCompositionPoint.LiveSessionCreated); + + var teardown = new LiveEntityRuntimeTeardownController( + live.LiveEntities, + live.Presentation, + live.EntityEffects, + live.RemoteTeleport, + live.SelectionInteractions, + d.Selection, + d.AnimatedEntities, + d.RemoteMovementObservations, + d.TranslucencyFades, + live.ProjectionWithdrawal, + live.EquippedChildren, + d.PhysicsEngine.ShadowObjects, + live.Lights, + d.ClassificationCache, + d.PlayerIdentity); + var deletion = new LiveEntityDeletionController( + live.LiveEntities, + d.Objects, + teardown, + d.PlayerIdentity, + d.Options.DumpLiveSpawns ? d.Log : null); + var hydration = new LiveEntityHydrationController( + live.LiveEntities, + d.Objects, + d.DatLock, + projectionMaterializer, + new LiveEntityRelationshipProjection(live.EquippedChildren), + new LiveEntityReadyPublisher( + live.LiveEntities, + live.EntityEffects, + live.Presentation), + originCoordinator, + networkUpdateBridge, + localPhysicsTimestamps, + d.PlayerIdentity, + deletion, + d.Options.DumpLiveSpawns ? d.Log : null); + bindings.Adopt( + "landblock-loaded hydration", + live.LandblockLoaded.Bind(hydration)); + Fault(SessionPlayerCompositionPoint.HydrationCreated); + + var networkUpdates = new LiveEntityNetworkUpdateController( + live.LiveEntities, + d.Objects, + hydration, + live.EntityEffects, + live.Presentation, + live.Lights, + live.EquippedChildren, + live.ProjectileController, + live.RemoteTeleport, + d.AnimatedEntities, + new LiveEntityRemoteMotionRuntimeView(d.RuntimeSlot), + d.RemoteMovementObservations, + d.RemotePhysicsUpdater, + d.RemoteInboundMotion, + live.MotionRuntime, + d.PhysicsEngine, + content.Dats, + content.AnimationLoader, + interaction.CombatTarget, + d.WorldOrigin, + d.TeleportSink, + d.PlayerController, + d.PlayerOutbound, + d.PlayerHost, + d.PlayerIdentity, + d.UpdateClock, + liveSession, + localPhysicsTimestamps.Publish, + d.MovementDiagnostics); + var liveness = new LiveEntityLivenessController( + live.LiveEntities, + d.PlayerIdentity, + deletion); + var sessionEvents = new LiveEntitySessionController( + d.InboundEntityEvents, + hydration, + networkUpdates, + d.TeleportSink, + live.EntityEffects); + bindings.Adopt( + "live parent acceptance", + live.ParentAcceptance.BindOwned( + hydration.TryAcceptParentForProjection)); + bindings.Adopt( + "same-generation network updates", + networkUpdateBridge.BindOwned(networkUpdates)); + bindings.BindEntityReady( + live.EquippedChildren, + candidate => _ = hydration.OnEntityReady(candidate)); + bindings.BindAppearanceApplied( + hydration, + guid => + { + if (guid == d.PlayerIdentity.ServerGuid) + live.PaperdollPresenter?.MarkDirty(); + }); + Fault(SessionPlayerCompositionPoint.LiveEntityGraphBound); + + d.WorldOrigin.SetPlaceholder( + world.TerrainBuild.InitialCenterX, + world.TerrainBuild.InitialCenterY); + bindings.Adopt( + "combat attack operations", + d.CombatAttackOperations.BindOwned( + new LiveCombatAttackOperations( + d.Combat, + new CombatAttackTargetSource( + d.Selection, + live.LiveEntities, + d.Objects, + d.PlayerIdentity), + d.Settings, + d.PlayerController, + d.PlayerOutbound, + liveSession, + liveSession, + d.CombatFeedback))); + Fault(SessionPlayerCompositionPoint.CombatOperationsBound); + + MouseLookController? mouseLook = + host.MouseSource is not null && host.MouseLookCursor is not null + ? new MouseLookController( + host.MouseSource, + d.PointerPosition, + d.PlayerMode, + d.PlayerController, + host.CameraController, + d.ChaseCameraInput, + d.MovementInput, + d.PlayerOutbound, + liveSession, + host.MouseLookCursor, + new EnvironmentInputMonotonicClock()) + : null; + var gameplayInput = new GameplayInputFrameController( + host.InputDispatcher, + d.MovementInput, + mouseLook, + new CombatAttackInputFrameAdapter(interaction.CombatAttack)); + if (host.CameraPointerInput is { } cameraPointer) + { + bindings.Adopt( + "camera pointer gameplay frame", + cameraPointer.BindGameplayFrameOwned(gameplayInput)); + } + Fault(SessionPlayerCompositionPoint.GameplayInputBound); + + var streamingFrame = new StreamingFrameController( + d.Options.LiveMode, + d.PlayerMode, + d.PlayerController, + liveSession, + d.WorldOrigin, + networkUpdates, + new FlyCameraStreamingObserverSource(host.CameraController), + new PhysicsStreamingDungeonCellSource(d.PhysicsEngine), + streamingOriginRecenter, + streaming, + new LiveProjectionRescueRebucketter( + live.WorldState, + live.LiveEntities)); + var liveEffectFrame = new LiveEffectFrameController( + d.TranslucencyFades, + content.AnimationHookFrames, + live.EntityEffects, + content.ParticleSink, + live.Lights, + d.ParticleVisibility, + content.ParticleSystem, + content.ScriptRunner, + d.UpdateClock, + new SettingsParticleRangeSource(d.Settings)); + var liveSpatialReconciler = new LiveSpatialPresentationReconciler( + live.EntityEffects, + live.EquippedChildren, + content.ParticleSink, + live.Lights); + var localPlayerAnimation = new LocalPlayerAnimationController( + live.LiveEntities, + d.PlayerIdentity, + d.AnimatedEntities, + live.AnimationPresenter, + content.AnimationHookFrames); + var localPlayerShadow = new LocalPlayerShadowSynchronizer( + d.PhysicsEngine, + live.LiveEntities, + d.PlayerIdentity, + d.WorldOrigin, + d.PlayerShadow); + var localPlayerProjection = new LocalPlayerProjectionController( + new LiveLocalPlayerProjectionRuntime( + live.LiveEntities, + d.PlayerIdentity, + d.WorldOrigin, + localPlayerShadow)); + var localPlayerFrameRuntime = new LiveLocalPlayerFrameRuntime( + host.CameraController, + d.PlayerMode, + d.PlayerController, + d.ChaseCameraInput, + d.MovementInput, + d.InputCapture, + live.LiveEntities, + d.PlayerIdentity, + d.PlayerHost, + localPlayerProjection, + d.PlayerOutbound, + liveSession); + var localPlayerFrame = new RetailLocalPlayerFrameController( + localPlayerFrameRuntime, + d.MovementInput); + var liveObjectFrame = new LiveObjectFrameController( + d.InboundEntityEvents, + localPlayerFrame, + live.SelectionInteractions, + live.LiveEntities, + d.PlayerIdentity, + d.WorldOrigin, + live.AnimationScheduler, + live.StaticAnimationScheduler, + live.AnimationPresenter, + d.AnimatedEntities, + live.EquippedChildren, + liveEffectFrame); + Fault(SessionPlayerCompositionPoint.UpdateLeavesCreated); + + var playerMode = new PlayerModeController( + d.PlayerMode, + d.PlayerController, + d.PlayerHost, + d.ChaseCameraInput, + host.CameraController, + d.PhysicsEngine, + live.LiveEntities, + d.PlayerIdentity, + d.WorldOrigin, + d.MotionBindings, + content.Dats, + d.DatLock, + d.PhysicsDataCache, + d.AnimatedEntities, + localPlayerAnimation, + localPlayerShadow, + d.PlayerApproachCompletions, + gameplayInput, + liveSession, + d.MovementDiagnostics, + d.PlayerSkills, + d.ViewportAspect); + if (d.SettingsDevTools.DevTools is { } devTools) + { + bindings.Adopt( + "developer player mode", + devTools.LateBindings.PlayerModeCommands.BindOwned(playerMode)); + bindings.Adopt( + "developer command bus", + devTools.CommandBus.BindOwned(liveSession)); + } + var playerModeAutoEntry = new PlayerModeAutoEntry( + new LivePlayerModeAutoEntryContext( + liveSession, + live.LiveEntities, + d.PlayerIdentity, + worldReveal, + d.PlayerMode, + playerMode)); + playerMode.BindAutoEntry(playerModeAutoEntry); + Fault(SessionPlayerCompositionPoint.PlayerModeBound); + + LocalPlayerTeleportController localTeleport = + d.PortalTunnelFallback.Transfer( + portalTunnel => new LocalPlayerTeleportController( + new LiveLocalPlayerTeleportAuthority( + live.LiveEntities, + d.PlayerIdentity), + gameplayInput, + playerMode, + new LocalPlayerTeleportStreamingOperations( + d.WorldOrigin, + streamingOriginRecenter, + streaming, + sealedDungeonCells), + worldReveal, + new LocalPlayerTeleportPlacement( + d.PhysicsEngine, + live.LiveEntities, + d.PlayerIdentity, + d.PlayerController, + d.PlayerHost, + d.ChaseCameraInput, + d.WorldOrigin, + liveSpatialReconciler), + new LocalPlayerTeleportSession(liveSession), + new LocalPlayerTeleportPresentation(portalTunnel))); + var teleportLease = scope.Own( + "local-player teleport", + localTeleport, + static value => value.Dispose()); + Fault(SessionPlayerCompositionPoint.PortalTransferred); + bindings.Adopt( + "local teleport network sink", + d.TeleportSink.BindOwned(localTeleport)); + bindings.Adopt( + "selection view plane", + interaction.LateBindings.SelectionViewPlane.Bind(localTeleport)); + Fault(SessionPlayerCompositionPoint.TeleportBound); + + IDisposable componentLifecycleBinding = + live.ComponentLifecycle.BindOwned(teardown); + try + { + live.RuntimeBindings.Adopt( + "live runtime component teardown", + componentLifecycleBinding); + } + catch + { + componentLifecycleBinding.Dispose(); + throw; + } + + var bindingsLease = scope.Own( + "session/player runtime bindings", + bindings, + static value => value.Dispose()); + bindingsOwnedByScope = true; + var result = new SessionPlayerResult( + streamerLease.Resource, + streaming, + streamingOriginRecenter, + worldReveal, + spawnClaimClassifier, + liveSession, + hydration, + networkUpdates, + liveness, + sessionEvents, + gameplayInput, + streamingFrame, + localPlayerAnimation, + localPlayerShadow, + localPlayerFrameRuntime, + localPlayerFrame, + liveSpatialReconciler, + liveObjectFrame, + playerMode, + playerModeAutoEntry, + teleportLease.Resource, + bindings); + _publication.PublishSessionPlayer(result); + + streamerLease.Transfer(); + liveSessionLease.Transfer(); + teleportLease.Transfer(); + bindingsLease.Transfer(); + Fault(SessionPlayerCompositionPoint.ResultPublished); + return result; + } + + private void Fault(SessionPlayerCompositionPoint point) => + _faultInjection?.Invoke(point); +} diff --git a/src/AcDream.App/Composition/SessionPlayerRuntimeBindings.cs b/src/AcDream.App/Composition/SessionPlayerRuntimeBindings.cs new file mode 100644 index 00000000..cf8d07ca --- /dev/null +++ b/src/AcDream.App/Composition/SessionPlayerRuntimeBindings.cs @@ -0,0 +1,95 @@ +using AcDream.App.Rendering; +using AcDream.App.World; + +namespace AcDream.App.Composition; + +/// +/// Named Phase-7 external edges. Releases run in reverse order; completed +/// edges are removed immediately so an incomplete cleanup retries only the +/// remaining work. +/// +internal sealed class SessionPlayerRuntimeBindings : IDisposable +{ + private readonly List<(string Name, IDisposable Binding)> _bindings = []; + private bool _deactivationStarted; + + public void Adopt(string name, IDisposable binding) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(binding); + ObjectDisposedException.ThrowIf(_deactivationStarted, this); + _bindings.Add((name, binding)); + } + + public void BindEntityReady( + EquippedChildRenderController source, + Action handler) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(handler); + source.EntityReady += handler; + Adopt( + "equipped-child entity ready", + new DelegateBinding(() => source.EntityReady -= handler)); + } + + public void BindAppearanceApplied( + LiveEntityHydrationController source, + Action handler) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(handler); + source.AppearanceApplied += handler; + Adopt( + "live appearance applied", + new DelegateBinding(() => source.AppearanceApplied -= handler)); + } + + public void Dispose() + { + if (_deactivationStarted && _bindings.Count == 0) + return; + _deactivationStarted = true; + + List? failures = null; + for (int i = _bindings.Count - 1; i >= 0; i--) + { + (string name, IDisposable binding) = _bindings[i]; + try + { + binding.Dispose(); + _bindings.RemoveAt(i); + } + catch (Exception failure) + { + (failures ??= []).Add(new InvalidOperationException( + $"Session/player binding '{name}' did not detach.", + failure)); + } + } + + if (failures is not null) + { + throw new AggregateException( + "Session/player binding cleanup remains incomplete.", + failures); + } + } + + private sealed class DelegateBinding : IDisposable + { + private Action? _release; + + public DelegateBinding(Action release) => + _release = release ?? throw new ArgumentNullException(nameof(release)); + + public void Dispose() + { + Action? release = _release; + if (release is null) + return; + release(); + _release = null; + } + } +} diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index fcbb78fe..8da0a466 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -245,6 +245,20 @@ internal sealed class CameraPointerInputController _gameplayFrame = gameplayFrame; } + public IDisposable BindGameplayFrameOwned( + GameplayInputFrameController gameplayFrame) + { + ArgumentNullException.ThrowIfNull(gameplayFrame); + if (_gameplayFrame is not null) + { + throw new InvalidOperationException( + "The raw pointer owner already has a gameplay frame."); + } + + _gameplayFrame = gameplayFrame; + return new GameplayFrameBinding(this, gameplayFrame); + } + public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame) { ArgumentNullException.ThrowIfNull(gameplayFrame); @@ -252,6 +266,24 @@ internal sealed class CameraPointerInputController _gameplayFrame = null; } + private sealed class GameplayFrameBinding : IDisposable + { + private CameraPointerInputController? _owner; + private readonly GameplayInputFrameController _expected; + + public GameplayFrameBinding( + CameraPointerInputController owner, + GameplayInputFrameController expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)? + .UnbindGameplayFrame(_expected); + } + public void HandleFocusChanged(bool focused) { if (!focused) diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs index 922c1b2d..81ca471e 100644 --- a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs +++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs @@ -199,6 +199,20 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource _session = session; } + public IDisposable BindOwned(LiveSessionController session) + { + ArgumentNullException.ThrowIfNull(session); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_session is not null) + { + throw new InvalidOperationException( + "Developer command-bus authority is already bound."); + } + + _session = session; + return new Binding(this, session); + } + public void Unbind(LiveSessionController session) { ArgumentNullException.ThrowIfNull(session); @@ -211,6 +225,23 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource _deactivated = true; _session = null; } + + private sealed class Binding : IDisposable + { + private DevToolsCommandBusSource? _owner; + private readonly LiveSessionController _expected; + + public Binding( + DevToolsCommandBusSource owner, + LiveSessionController expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } /// Typed panel operations used by menu and input presentation. diff --git a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs index 1eb97f04..f9624c7c 100644 --- a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs +++ b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs @@ -167,6 +167,20 @@ internal sealed class DeferredDevToolsPlayerModeCommands _target = target; } + public IDisposable BindOwned(IDevToolsPlayerModeTarget target) + { + ArgumentNullException.ThrowIfNull(target); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null) + { + throw new InvalidOperationException( + "Developer player-mode commands are already bound."); + } + + _target = target; + return new Binding(this, target); + } + public void Unbind(IDevToolsPlayerModeTarget target) { ArgumentNullException.ThrowIfNull(target); @@ -185,6 +199,23 @@ internal sealed class DeferredDevToolsPlayerModeCommands if (!_deactivated) _target?.ToggleFlyOrChase(); } + + private sealed class Binding : IDisposable + { + private DeferredDevToolsPlayerModeCommands? _owner; + private readonly IDevToolsPlayerModeTarget _expected; + + public Binding( + DeferredDevToolsPlayerModeCommands owner, + IDevToolsPlayerModeTarget expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } internal interface IDevToolsRuntimeFacts diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b33a49a8..9c5177af 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -21,7 +21,8 @@ public sealed class GameWindow : IGameWindowSettingsDevToolsPublication, IGameWindowWorldRenderPublication, IGameWindowInteractionRetainedUiPublication, - IGameWindowLivePresentationPublication + IGameWindowLivePresentationPublication, + IGameWindowSessionPlayerPublication { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -99,6 +100,7 @@ public sealed class GameWindow : private AcDream.App.Rendering.RenderFrameDiagnosticsController? _renderFrameDiagnostics; private LivePresentationRuntimeBindings? _livePresentationBindings; + private SessionPlayerRuntimeBindings? _sessionPlayerBindings; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; @@ -123,22 +125,14 @@ public sealed class GameWindow : private AcDream.App.Streaming.StreamingOriginRecenterCoordinator? _streamingOriginRecenter; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; + private AcDream.App.Streaming.DatSpawnClaimHydrationClassifier? + _spawnClaimHydration; private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink _localPlayerTeleportSink = new(); private AcDream.App.Streaming.LocalPlayerTeleportController? _localPlayerTeleport; private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange = new(nearRadius: 4, farRadius: 12); - private int _nearRadius - { - get => _renderRange.NearRadius; - set => _renderRange.NearRadius = value; - } - private int _farRadius - { - get => _renderRange.FarRadius; - set => _renderRange.FarRadius = value; - } // Phase B.3: physics engine — populated from the streaming pipeline. private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); @@ -170,7 +164,7 @@ public sealed class GameWindow : _liveEntityProjectionWithdrawal; private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration; private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates; - private AcDream.App.Net.LiveEntitySessionController _liveEntitySessionEvents = null!; + private AcDream.App.Net.LiveEntitySessionController? _liveEntitySessionEvents; private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings _liveEntityMotionBindings = new(); @@ -996,6 +990,51 @@ public sealed class GameWindow : _livePresentationBindings = result.RuntimeBindings; } + void IGameWindowSessionPlayerPublication.PublishSessionPlayer( + SessionPlayerResult result) + { + ArgumentNullException.ThrowIfNull(result); + if (_streamer is not null + || _streamingController is not null + || _streamingOriginRecenter is not null + || _worldReveal is not null + || _spawnClaimHydration is not null + || _liveSessionController is not null + || _liveEntityHydration is not null + || _liveEntityNetworkUpdates is not null + || _liveEntityLiveness is not null + || _liveEntitySessionEvents is not null + || _gameplayInputFrame is not null + || _localPlayerAnimation is not null + || _localPlayerShadowSynchronizer is not null + || _playerModeController is not null + || _playerModeAutoEntry is not null + || _localPlayerTeleport is not null + || _sessionPlayerBindings is not null) + { + throw new InvalidOperationException( + "The GameWindow composition shell already owns session/player state."); + } + + _streamer = result.Streamer; + _streamingController = result.Streaming; + _streamingOriginRecenter = result.StreamingOriginRecenter; + _worldReveal = result.WorldReveal; + _spawnClaimHydration = result.SpawnClaimHydration; + _liveSessionController = result.LiveSession; + _liveEntityHydration = result.Hydration; + _liveEntityNetworkUpdates = result.NetworkUpdates; + _liveEntityLiveness = result.Liveness; + _liveEntitySessionEvents = result.SessionEvents; + _gameplayInputFrame = result.GameplayInput; + _localPlayerAnimation = result.LocalPlayerAnimation; + _localPlayerShadowSynchronizer = result.LocalPlayerShadow; + _playerModeController = result.PlayerMode; + _playerModeAutoEntry = result.PlayerModeAutoEntry; + _localPlayerTeleport = result.LocalTeleport; + _sessionPlayerBindings = result.RuntimeBindings; + } + private static void PublishCompositionOwner( ref T? destination, T value, @@ -1139,9 +1178,6 @@ public sealed class GameWindow : Console.WriteLine), this).Compose(platform, contentEffectsAudio, settingsDevTools); string shadersDir = worldRender.Foundation.ShadersDirectory; - TerrainAtlas terrainAtlas = worldRender.Foundation.TerrainAtlas; - int centerX = worldRender.TerrainBuild.InitialCenterX; - int centerY = worldRender.TerrainBuild.InitialCenterY; Console.WriteLine( $"loading world view centered on " + $"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}"); @@ -1243,441 +1279,64 @@ public sealed class GameWindow : worldRender, interactionUi); - // Apply radii from the same immutable quality snapshot used for the - // window's MSAA, terrain anisotropy, and dispatcher A2C setup. - // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. - _nearRadius = _runtimeSettings.ResolvedQuality.NearRadius; - _farRadius = _runtimeSettings.ResolvedQuality.FarRadius; - - // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and - // ensures farRadius >= streamRadius. Parsed once into - // RuntimeOptions.LegacyStreamRadius (null when unset or invalid). - if (_options.LegacyStreamRadius is { } sr) - { - _nearRadius = sr; - _farRadius = System.Math.Max(sr, _farRadius); - } - Console.WriteLine( - $"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" + - $" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})"); - - // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. - // loadLandblock acquires _datLock (T10) before touching DatCollection. - // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so - // it can call LandblockMesh.Build without a dat read — _heightTable and - // _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). - var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory( - _dats!, - _datLock, - _heightTable!, - _physicsDataCache, - _options.DumpSceneryZ); - _streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests( - loadLandblock: landblockBuildFactory.Build, - buildMeshOrNull: (id, lb) => - { - if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) - return null; - uint lbX = (id >> 24) & 0xFFu; - uint lbY = (id >> 16) & 0xFFu; - // _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread. - // _heightTable and _blendCtx are read-only after initialization. - // lb.Heightmap is the pre-loaded LandBlock; no dat read needed here. - return AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); - }); - _streamer.Start(); - - _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( - new AcDream.App.Streaming.LandblockBuildRequest( - id, - kind, - generation, - new AcDream.App.Streaming.LandblockBuildOrigin( - _liveCenterX, - _liveCenterY))), - enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), - drainCompletions: _streamer.DrainCompletions, - state: _worldState, - nearRadius: _nearRadius, - farRadius: _farRadius, - clearPendingLoads: _streamer.ClearPendingLoads, - // Retained-object recovery runs after each - // (dungeon-exit expand or Far→Near promote). ACE won't re-send the - // objects it still considers known. - presentationPipeline: _landblockPresentationPipeline!); - _streamingOriginRecenter = - new AcDream.App.Streaming.StreamingOriginRecenterCoordinator( - _streamingController, - _liveWorldOrigin); - // A.5 T22.5: apply max-completions from resolved quality. - _streamingController.MaxCompletionsPerFrame = - _runtimeSettings.ResolvedQuality.MaxCompletionsPerFrame; - _runtimeSettings.BindRuntimeTargets( - new RuntimeSettingsTargets( - new SilkRuntimeDisplayWindowTarget(_window!), - _wbDrawDispatcher!, - terrainAtlas, - _streamingController, - _renderRange, - _uiHost?.Root, - Console.WriteLine)); - _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( - isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, - isSpawnCellReady: _physicsEngine.IsSpawnCellReady, - isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident, - areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true, - prepareCompositeTextures: (destinationCell, radius) => - _wbDrawDispatcher?.PrepareCompositeTextures( - _worldState.Entities, - _worldState.FlatViewGeneration, - destinationCell, - radius), - invalidateCompositeTextures: () => - _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(), - isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable, - log: message => Console.WriteLine(message)); - - var networkUpdateBridge = - new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink(); - var sealedDungeonCells = - new AcDream.App.Streaming.DatSealedDungeonCellClassifier( - _dats!, - _datLock); - var projectionMaterializer = new DatLiveEntityProjectionMaterializer( - _options, - _dats!, - _liveEntities!, - _physicsDataCache, - _animLoader!, - _wbEntitySpawnAdapter!, - _textureCache!, - _classificationCache, - _effectPoses, - _equippedChildRenderer!, - _worldGameState, - _worldEvents, - _physicsEngine.ShadowObjects, - _liveEntityCollisionBuilder!, - _projectileController!, - _animationPresenter, - _staticAnimationScheduler!, - _liveWorldOrigin, - _updateFrameClock); - var originCoordinator = - new AcDream.App.World.LiveEntityWorldOriginCoordinator( - _liveWorldOrigin, - _streamingController, - _worldState, - _worldReveal, - _localPlayerIdentity, - sealedDungeonCells, - Console.WriteLine); - _liveSessionController = new AcDream.App.Net.LiveSessionController(); - interactionUi.LateBindings.AdoptLateOwnerBinding( - "live session", - interactionUi.LateBindings.Session.Bind(_liveSessionController)); - var localPhysicsTimestamps = - new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher( - _localPlayerIdentity, - _liveSessionController); - var liveEntityTeardown = - new AcDream.App.World.LiveEntityRuntimeTeardownController( - livePresentation.LiveEntities, - _liveEntityPresentation!, - _entityEffects!, - _remoteTeleportController!, - _selectionInteractions, - _selection, - _animatedEntities, - _remoteMovementObservations, - _translucencyFades, - _liveEntityProjectionWithdrawal!, - _equippedChildRenderer!, - _physicsEngine.ShadowObjects, - _liveEntityLights!, - _classificationCache, - _localPlayerIdentity); - livePresentation.ComponentLifecycle.Bind(liveEntityTeardown); - var liveEntityDeletion = - new AcDream.App.World.LiveEntityDeletionController( - livePresentation.LiveEntities, - Objects, - liveEntityTeardown, - _localPlayerIdentity, - _options.DumpLiveSpawns ? Console.WriteLine : null); - _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( - livePresentation.LiveEntities, - Objects, - _datLock, - projectionMaterializer, - new AcDream.App.World.LiveEntityRelationshipProjection( - livePresentation.EquippedChildren), - new AcDream.App.World.LiveEntityReadyPublisher( - livePresentation.LiveEntities, - _entityEffects!, - _liveEntityPresentation!), - originCoordinator, - networkUpdateBridge, - localPhysicsTimestamps, - _localPlayerIdentity, - liveEntityDeletion, - _options.DumpLiveSpawns ? Console.WriteLine : null); - livePresentation.RuntimeBindings.Adopt( - "landblock-loaded hydration", - livePresentation.LandblockLoaded.Bind(_liveEntityHydration)); - _liveEntityNetworkUpdates = - new AcDream.App.Physics.LiveEntityNetworkUpdateController( - livePresentation.LiveEntities, - Objects, - _liveEntityHydration, - _entityEffects!, - _liveEntityPresentation!, - _liveEntityLights!, - _equippedChildRenderer!, - _projectileController!, - _remoteTeleportController!, - _animatedEntities, - new LiveEntityRemoteMotionRuntimeView( - _liveEntityRuntimeSlot), - _remoteMovementObservations, - _remotePhysicsUpdater, - _remoteInboundMotion, - livePresentation.MotionRuntime, - _physicsEngine, - _dats!, - _animLoader!, - _combatTargetController, - _liveWorldOrigin, - _localPlayerTeleportSink, - _playerControllerSlot, - _localPlayerOutbound, - _playerHostSlot, - _localPlayerIdentity, - _updateFrameClock, - _liveSessionController, - localPhysicsTimestamps.Publish, - _movementTruthDiagnostics); - _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( - livePresentation.LiveEntities, - _localPlayerIdentity, - liveEntityDeletion); - _liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController( - _inboundEntityEvents, - _liveEntityHydration, - _liveEntityNetworkUpdates, - _localPlayerTeleportSink, - _entityEffects!); - livePresentation.ParentAcceptance.Bind( - _liveEntityHydration.TryAcceptParentForProjection); - networkUpdateBridge.Bind(_liveEntityNetworkUpdates); - livePresentation.EquippedChildren.EntityReady += candidate => - _liveEntityHydration.OnEntityReady(candidate); - _liveEntityHydration.AppearanceApplied += guid => - { - if (guid == _playerServerGuid) - _paperdollFramePresenter?.MarkDirty(); - }; - - // Phase 4.7: optional live-mode startup. Connect to the ACE server, - // enter the world as the first character on the account, and stream - // CreateObject messages into _worldGameState as they arrive. Entirely - // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. - _liveWorldOrigin.SetPlaceholder(centerX, centerY); - _combatAttackOperations.Bind( - new AcDream.App.Combat.LiveCombatAttackOperations( - Combat, - new AcDream.App.Combat.CombatAttackTargetSource( - _selection, - _liveEntities!, - Objects, - _localPlayerIdentity), - _runtimeSettings, - _playerControllerSlot, - _localPlayerOutbound, - _liveSessionController, - _liveSessionController, - _combatFeedback)); - AcDream.App.Input.MouseLookController? mouseLookController = - _mouseSource is not null && _mouseLookCursor is not null - ? new AcDream.App.Input.MouseLookController( - _mouseSource, - _pointerPosition, - _localPlayerMode, - _playerControllerSlot, - _cameraController!, - _chaseCameraInput, - _movementInput, - _localPlayerOutbound, - _liveSessionController, - _mouseLookCursor, - new AcDream.App.Input.EnvironmentInputMonotonicClock()) - : null; - _gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController( - _inputDispatcher, - _movementInput, - mouseLookController, - new AcDream.App.Input.CombatAttackInputFrameAdapter( - _combatAttackController!)); - _cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame); - var streamingFrame = new AcDream.App.Streaming.StreamingFrameController( - _options.LiveMode, - _localPlayerMode, - _playerControllerSlot, - _liveSessionController, - _liveWorldOrigin, - _liveEntityNetworkUpdates!, - new AcDream.App.Streaming.FlyCameraStreamingObserverSource( - _cameraController!), - new AcDream.App.Streaming.PhysicsStreamingDungeonCellSource( - _physicsEngine), - _streamingOriginRecenter!, - _streamingController!, - new AcDream.App.Streaming.LiveProjectionRescueRebucketter( - _worldState, - livePresentation.LiveEntities)); - var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController( - _translucencyFades, - _animationHookFrames!, - _entityEffects!, - _particleSink!, - _liveEntityLights!, - _particleVisibility, - _particleSystem!, - _scriptRunner!, - _updateFrameClock, - new AcDream.App.Update.SettingsParticleRangeSource( - _runtimeSettings)); - var liveSpatialReconciler = - new AcDream.App.Update.LiveSpatialPresentationReconciler( - _entityEffects!, - _equippedChildRenderer!, - _particleSink!, - _liveEntityLights!); - _localPlayerAnimation = - new AcDream.App.Input.LocalPlayerAnimationController( - livePresentation.LiveEntities, - _localPlayerIdentity, - _animatedEntities, - _animationPresenter, - _animationHookFrames!); - _localPlayerShadowSynchronizer = - new AcDream.App.Physics.LocalPlayerShadowSynchronizer( - _physicsEngine, - livePresentation.LiveEntities, - _localPlayerIdentity, - _liveWorldOrigin, - _localPlayerShadow); - var localPlayerProjection = - new AcDream.App.Input.LocalPlayerProjectionController( - new AcDream.App.Input.LiveLocalPlayerProjectionRuntime( - livePresentation.LiveEntities, - _localPlayerIdentity, - _liveWorldOrigin, - _localPlayerShadowSynchronizer)); - var localPlayerFrameRuntime = - new AcDream.App.Input.LiveLocalPlayerFrameRuntime( - _cameraController!, - _localPlayerMode, - _playerControllerSlot, - _chaseCameraInput, - _movementInput, - _inputCapture, - livePresentation.LiveEntities, - _localPlayerIdentity, - _playerHostSlot, - localPlayerProjection, - _localPlayerOutbound, - _liveSessionController); - var localPlayerFrame = - new AcDream.App.Input.RetailLocalPlayerFrameController( - localPlayerFrameRuntime, - _movementInput); - var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController( - _inboundEntityEvents, - localPlayerFrame, - _selectionInteractions, - livePresentation.LiveEntities, - _localPlayerIdentity, - _liveWorldOrigin, - _liveAnimationScheduler, - livePresentation.StaticAnimationScheduler, - _animationPresenter, - _animatedEntities, - _equippedChildRenderer!, - liveEffectFrame); - _playerModeController = new AcDream.App.Input.PlayerModeController( - _localPlayerMode, - _playerControllerSlot, - _playerHostSlot, - _chaseCameraInput, - _cameraController!, - _physicsEngine, - livePresentation.LiveEntities, - _localPlayerIdentity, - _liveWorldOrigin, - _liveEntityMotionBindings, - _dats!, - _datLock, - _physicsDataCache, - _animatedEntities, - _localPlayerAnimation, - _localPlayerShadowSynchronizer, - _playerApproachCompletions, - _gameplayInputFrame, - _liveSessionController, - _movementTruthDiagnostics, - _localPlayerSkills, - _viewportAspect); - _devToolsComposition?.LateBindings.PlayerModeCommands.Bind( - _playerModeController); - _devToolsCommandBus?.Bind(_liveSessionController); - _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( - new AcDream.App.Input.LivePlayerModeAutoEntryContext( - _liveSessionController, - livePresentation.LiveEntities, - _localPlayerIdentity, - _worldReveal - ?? throw new InvalidOperationException( - "World reveal was not composed before player auto-entry."), - _localPlayerMode, - _playerModeController)); - _playerModeController.BindAutoEntry(_playerModeAutoEntry); - _localPlayerTeleport = _portalTunnelFallback.Transfer( - portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController( - new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority( - livePresentation.LiveEntities, - _localPlayerIdentity), - _gameplayInputFrame, - _playerModeController, - new AcDream.App.Streaming.LocalPlayerTeleportStreamingOperations( - _liveWorldOrigin, - _streamingOriginRecenter!, - _streamingController!, - sealedDungeonCells), - _worldReveal, - new AcDream.App.Streaming.LocalPlayerTeleportPlacement( + SessionPlayerResult sessionPlayer = + new SessionPlayerCompositionPhase( + new SessionPlayerDependencies( + _options, + _window!, + _datLock, + _runtimeSettings, + settingsDevTools, _physicsEngine, - livePresentation.LiveEntities, + _physicsDataCache, + _worldGameState, + _worldEvents, + _selection, + Objects, + _classificationCache, + _liveEntityRuntimeSlot, + _animatedEntities, + _remoteMovementObservations, + _remotePhysicsUpdater, + _remoteInboundMotion, + _inboundEntityEvents, + _liveEntityMotionBindings, _localPlayerIdentity, _playerControllerSlot, _playerHostSlot, + _localPlayerMode, _chaseCameraInput, + _localPlayerOutbound, + _localPlayerTeleportSink, _liveWorldOrigin, - liveSpatialReconciler), - new AcDream.App.Streaming.LocalPlayerTeleportSession( - _liveSessionController), - new AcDream.App.Streaming.LocalPlayerTeleportPresentation( - portalTunnel))); - _localPlayerTeleportSink.Bind(_localPlayerTeleport); - interactionUi.LateBindings.AdoptLateOwnerBinding( - "selection view plane", - interactionUi.LateBindings.SelectionViewPlane.Bind( - _localPlayerTeleport)); + _renderRange, + _localPlayerShadow, + _localPlayerSkills, + _viewportAspect, + _playerApproachCompletions, + _pointerPosition, + _movementInput, + _inputCapture, + _particleVisibility, + _translucencyFades, + _effectPoses, + _updateFrameClock, + _movementTruthDiagnostics, + _combatAttackOperations, + Combat, + _combatFeedback, + _portalTunnelFallback, + Console.WriteLine), + this).Compose( + hostInputCamera, + contentEffectsAudio, + worldRender, + interactionUi, + livePresentation); + var teleportRenderState = new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( - _localPlayerTeleport); + sessionPlayer.LocalTeleport); var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource( _options.LiveMode, _localPlayerMode); @@ -1743,7 +1402,7 @@ public sealed class GameWindow : new AcDream.App.Rendering.WorldRenderFrameBuilder( new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( _cameraController!, - _localPlayerTeleport), + sessionPlayer.LocalTeleport), new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation( _retailSelectionScene, _particleVisibility, @@ -1876,7 +1535,7 @@ public sealed class GameWindow : var privatePresentation = new AcDream.App.Rendering.PrivatePresentationRenderer( new AcDream.App.Rendering.LocalPlayerPortalViewport( - _localPlayerTeleport!, + sessionPlayer.LocalTeleport, _cameraController!), renderFrameResources, _paperdollFramePresenter, @@ -1899,19 +1558,19 @@ public sealed class GameWindow : _devToolsFramePresenter ?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( - liveObjectFrame, + sessionPlayer.LiveObjectFrame, _worldState, - _liveSessionController, - localPlayerFrame, - liveSpatialReconciler); + sessionPlayer.LiveSession, + sessionPlayer.LocalPlayerFrame, + sessionPlayer.LiveSpatialReconciler); var cameraFrame = new AcDream.App.Rendering.CameraFrameController( _cameraController!, _inputCapture, _cameraInput, - localPlayerFrameRuntime, + sessionPlayer.LocalPlayerFrameRuntime, _chaseCameraInput, - localPlayerFrame, - liveSpatialReconciler, + sessionPlayer.LocalPlayerFrame, + sessionPlayer.LiveSpatialReconciler, new AcDream.App.Combat.CombatCameraTargetSource( _runtimeSettings, Combat, @@ -1923,15 +1582,15 @@ public sealed class GameWindow : new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), _updateFrameClock, new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!), - streamingFrame, - _gameplayInputFrame, + sessionPlayer.StreamingFrame, + sessionPlayer.GameplayInput, liveFrameCoordinator, new AcDream.App.Update.LiveEntityLivenessFramePhase( - _liveEntityLiveness, + sessionPlayer.Liveness, new AcDream.App.Update.StopwatchClientMonotonicTimeSource()), - _localPlayerTeleport, + sessionPlayer.LocalTeleport, new AcDream.App.Update.PlayerModeAutoEntryFramePhase( - _playerModeAutoEntry), + sessionPlayer.PlayerModeAutoEntry), cameraFrame); _frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator); _liveSessionHost = CreateLiveSessionHost(); @@ -1988,7 +1647,7 @@ public sealed class GameWindow : runtimeDiagnostics, new AcDream.App.Input.PlayerModeGameplayCommands( _localPlayerMode, - _playerModeController), + sessionPlayer.PlayerMode), new AcDream.App.Input.ItemTargetModeCommands( _itemInteractionController!), new AcDream.App.Input.GameplayCameraModeCommands( @@ -1996,7 +1655,7 @@ public sealed class GameWindow : combatCommand, new AcDream.App.Input.GameplayWindowCommands(_window!.Close)); var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets( - _gameplayInputFrame!, + sessionPlayer.GameplayInput, pointer, _retailUiRuntime, _selectionInteractions, @@ -2095,7 +1754,7 @@ public sealed class GameWindow : private void ResetSessionPlayerPresentation() { _playerModeController?.ResetSession(); - _spawnClaimRangeMemo = null; + _spawnClaimHydration?.Reset(); } private void ResetSessionIdentity() @@ -2157,7 +1816,10 @@ public sealed class GameWindow : return new AcDream.App.Net.LiveSessionEventRouter( session, - _liveEntitySessionEvents.CreateSink(), + (_liveEntitySessionEvents + ?? throw new InvalidOperationException( + "Live entity session routing was not composed.")) + .CreateSink(), new AcDream.App.Net.LiveEnvironmentSessionSink( _worldEnvironment.ApplyAdminEnvirons, _worldEnvironment.SynchronizeFromServer), @@ -2392,34 +2054,6 @@ public sealed class GameWindow : private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) => _framebufferResize.Resize(newSize); - // #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate" - // check against the dat's LandBlockInfo.NumCells. Used by the auto-entry - // hold so a garbage claim doesn't stall login forever; the Resolve-head - // safety net demotes it loudly once entry proceeds. - private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo; - - private bool IsSpawnClaimUnhydratable(uint claim) - { - if ((claim & 0xFFFFu) < 0x0100u) return false; - if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable; - - bool unhydratable = false; - if (_dats is not null) - { - DatReaderWriter.DBObjs.LandBlockInfo? lbInfo; - lock (_datLock) - { - lbInfo = _dats.Get( - (claim & 0xFFFF0000u) | 0xFFFEu); - } - uint low = claim & 0xFFFFu; - unhydratable = lbInfo is null || lbInfo.NumCells == 0 - || low >= 0x0100u + lbInfo.NumCells; - } - _spawnClaimRangeMemo = (claim, unhydratable); - return unhydratable; - } - private void OnClosing() { _hostQuiescence.StopAccepting(); @@ -2565,11 +2199,18 @@ public sealed class GameWindow : // the later render-frontend stage still disposes the GL owners. new ResourceShutdownStage("frame borrowers", [ - new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets), new("world frame composition", () => { _frameGraphs.Withdraw(); }), + new("session/player bindings", () => + { + SessionPlayerRuntimeBindings? bindings = _sessionPlayerBindings; + if (bindings is null) + return; + bindings.Dispose(); + _sessionPlayerBindings = null; + }), ]), new ResourceShutdownStage("session dependents", [ @@ -2587,8 +2228,6 @@ public sealed class GameWindow : _cameraPointerInput; if (pointer is null) return; pointer.ReleaseMouseLookAfterSessionRetirement(); - if (_gameplayInputFrame is { } gameplayFrame) - pointer.UnbindGameplayFrame(gameplayFrame); _cameraPointerInput = null; }), new("retail UI", () => diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 814dfc34..5b2ad44d 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -246,8 +246,38 @@ internal sealed class RuntimeSettingsController : _runtimeTargets = targets; } + public IDisposable BindRuntimeTargetsOwned(IRuntimeSettingsTargets targets) + { + BindRuntimeTargets(targets); + return new RuntimeTargetBinding(this, targets); + } + + private void UnbindRuntimeTargets(IRuntimeSettingsTargets expected) + { + if (ReferenceEquals(_runtimeTargets, expected)) + _runtimeTargets = null; + } + public void UnbindRuntimeTargets() => _runtimeTargets = null; + private sealed class RuntimeTargetBinding : IDisposable + { + private RuntimeSettingsController? _owner; + private readonly IRuntimeSettingsTargets _expected; + + public RuntimeTargetBinding( + RuntimeSettingsController owner, + IRuntimeSettingsTargets expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)? + .UnbindRuntimeTargets(_expected); + } + public SettingsVM CreateViewModel( KeyBindings persistedBindings, InputDispatcher dispatcher, diff --git a/src/AcDream.App/Streaming/DatSpawnClaimHydrationClassifier.cs b/src/AcDream.App/Streaming/DatSpawnClaimHydrationClassifier.cs new file mode 100644 index 00000000..a4ebd6a4 --- /dev/null +++ b/src/AcDream.App/Streaming/DatSpawnClaimHydrationClassifier.cs @@ -0,0 +1,57 @@ +using AcDream.Content; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Streaming; + +/// +/// Memoized range check for indoor spawn claims. Claims below the indoor-cell +/// range are always hydratable; indoor claims are validated against the exact +/// landblock's authored cell count. +/// +internal sealed class DatSpawnClaimHydrationClassifier +{ + private readonly Func _lookup; + private (uint Claim, bool Unhydratable)? _memo; + + public DatSpawnClaimHydrationClassifier( + IDatReaderWriter dats, + object datLock) + : this(CreateLookup(dats, datLock)) + { + } + + internal DatSpawnClaimHydrationClassifier( + Func lookup) => + _lookup = lookup ?? throw new ArgumentNullException(nameof(lookup)); + + public bool IsUnhydratable(uint claim) + { + uint low = claim & 0xFFFFu; + if (low < 0x0100u) + return false; + if (_memo is { } memo && memo.Claim == claim) + return memo.Unhydratable; + + LandBlockInfo? info = _lookup((claim & 0xFFFF0000u) | 0xFFFEu); + bool unhydratable = info is null + || info.NumCells == 0 + || low >= 0x0100u + info.NumCells; + _memo = (claim, unhydratable); + return unhydratable; + } + + public void Reset() => _memo = null; + + private static Func CreateLookup( + IDatReaderWriter dats, + object datLock) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(datLock); + return id => + { + lock (datLock) + return dats.Get(id); + }; + } +} diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 33f9326c..4b647776 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -41,6 +41,17 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink throw new InvalidOperationException("The local teleport sink is already bound."); } + public IDisposable BindOwned(ILocalPlayerTeleportNetworkSink inner) + { + Bind(inner); + return new Binding(this, inner); + } + + private void Unbind(ILocalPlayerTeleportNetworkSink expected) + { + _ = Interlocked.CompareExchange(ref _inner, null, expected); + } + public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence); public void OfferDestination( @@ -53,6 +64,23 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink private ILocalPlayerTeleportNetworkSink Required() => _inner ?? throw new InvalidOperationException( "The local teleport sink was used before composition completed."); + + private sealed class Binding : IDisposable + { + private DeferredLocalPlayerTeleportNetworkSink? _owner; + private readonly ILocalPlayerTeleportNetworkSink _expected; + + public Binding( + DeferredLocalPlayerTeleportNetworkSink owner, + ILocalPlayerTeleportNetworkSink expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } internal interface ILocalPlayerTeleportInputLifetime diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 4210b436..32fbd9e6 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -103,10 +103,38 @@ internal sealed class DeferredLiveEntityNetworkUpdateSink : ILiveEntityNetworkUp throw new InvalidOperationException("The live-entity network sink is already bound."); } + public IDisposable BindOwned(ILiveEntityNetworkUpdateSink inner) + { + Bind(inner); + return new Binding(this, inner); + } + + private void Unbind(ILiveEntityNetworkUpdateSink expected) + { + _ = Interlocked.CompareExchange(ref _inner, null, expected); + } + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) => (_inner ?? throw new InvalidOperationException( "The live-entity network sink must be bound before a session starts.")) .ApplySameGeneration(events); + + private sealed class Binding : IDisposable + { + private DeferredLiveEntityNetworkUpdateSink? _owner; + private readonly ILiveEntityNetworkUpdateSink _expected; + + public Binding( + DeferredLiveEntityNetworkUpdateSink owner, + ILiveEntityNetworkUpdateSink expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } internal sealed class DelegateLiveEntityNetworkUpdateSink( diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs index ea0969c4..0948bdac 100644 --- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -41,9 +41,37 @@ internal sealed class DeferredLiveEntityParentAcceptance throw new InvalidOperationException("Live parent acceptance is already bound."); } + public IDisposable BindOwned(Func accept) + { + Bind(accept); + return new Binding(this, accept); + } + + private void Unbind(Func expected) + { + _ = Interlocked.CompareExchange(ref _accept, null, expected); + } + public bool TryAccept(ParentEvent.Parsed update) => (_accept ?? throw new InvalidOperationException( "Live parent acceptance must be bound before a session starts."))(update); + + private sealed class Binding : IDisposable + { + private DeferredLiveEntityParentAcceptance? _owner; + private readonly Func _expected; + + public Binding( + DeferredLiveEntityParentAcceptance owner, + Func expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 2b0386ff..3871d87e 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -152,6 +152,17 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle : throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound."); } + public IDisposable BindOwned(ILiveEntityRuntimeComponentLifecycle lifecycle) + { + Bind(lifecycle); + return new Binding(this, lifecycle); + } + + private void Unbind(ILiveEntityRuntimeComponentLifecycle expected) + { + _ = Interlocked.CompareExchange(ref _inner, null, expected); + } + public void TearDown(LiveEntityRecord record) { ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner) @@ -159,6 +170,23 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle : "The live-entity runtime component lifecycle has not been bound."); lifecycle.TearDown(record); } + + private sealed class Binding : IDisposable + { + private DeferredLiveEntityRuntimeComponentLifecycle? _owner; + private readonly ILiveEntityRuntimeComponentLifecycle _expected; + + public Binding( + DeferredLiveEntityRuntimeComponentLifecycle owner, + ILiveEntityRuntimeComponentLifecycle expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } /// Delegate adapter used by the App composition root. diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs index 16532049..fa2b9122 100644 --- a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs +++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs @@ -24,6 +24,23 @@ public sealed class LiveCombatAttackOperationsTests Assert.Throws(() => slot.Bind(second)); } + [Fact] + public void OwnedBindingReleasesOnlyItsExactOwnerAndAllowsRebind() + { + var slot = new CombatAttackOperationsSlot(); + var first = new FakeOperations { CanStartValue = false }; + var second = new FakeOperations { CanStartValue = true }; + + IDisposable firstBinding = slot.BindOwned(first); + Assert.Throws(() => slot.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = slot.BindOwned(second); + firstBinding.Dispose(); + + Assert.True(slot.CanStartAttack()); + } + [Fact] public void CanStartRequiresLiveWorldBeforeTargetResolution() { @@ -137,7 +154,8 @@ public sealed class LiveCombatAttackOperationsTests private sealed class FakeOperations : ICombatAttackOperations { - public bool CanStartAttack() => true; + public bool CanStartValue { get; init; } = true; + public bool CanStartAttack() => CanStartValue; public void PrepareAttackRequest() { } diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs new file mode 100644 index 00000000..4703d7cc --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs @@ -0,0 +1,160 @@ +using AcDream.App.Composition; +using AcDream.App.Streaming; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Composition; + +public sealed class SessionPlayerCompositionTests +{ + [Fact] + public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges() + { + var calls = new List(); + var bindings = new SessionPlayerRuntimeBindings(); + bindings.Adopt("first", new RetryBinding("first", calls, 0)); + bindings.Adopt("second", new RetryBinding("second", calls, 1)); + + Assert.Throws(bindings.Dispose); + Assert.Equal(["second", "first"], calls); + + bindings.Dispose(); + bindings.Dispose(); + + Assert.Equal(["second", "first", "second"], calls); + Assert.Throws(() => + bindings.Adopt("late", new RetryBinding("late", calls, 0))); + } + + [Fact] + public void SpawnClaimClassifierPreservesIndoorBoundaryMemoAndReset() + { + int lookups = 0; + uint lastDid = 0; + var info = new LandBlockInfo { NumCells = 2 }; + var classifier = new DatSpawnClaimHydrationClassifier(did => + { + lookups++; + lastDid = did; + return info; + }); + + Assert.False(classifier.IsUnhydratable(0x123400FFu)); + Assert.Equal(0, lookups); + Assert.False(classifier.IsUnhydratable(0x12340100u)); + Assert.Equal(0x1234FFFEu, lastDid); + Assert.False(classifier.IsUnhydratable(0x12340100u)); + Assert.Equal(1, lookups); + Assert.False(classifier.IsUnhydratable(0x12340101u)); + Assert.True(classifier.IsUnhydratable(0x12340102u)); + + classifier.Reset(); + Assert.True(classifier.IsUnhydratable(0x12340102u)); + Assert.Equal(4, lookups); + } + + [Fact] + public void MissingOrEmptyLandblockMakesIndoorClaimUnhydratable() + { + var missing = new DatSpawnClaimHydrationClassifier(_ => null); + var empty = new DatSpawnClaimHydrationClassifier( + _ => new LandBlockInfo { NumCells = 0 }); + + Assert.True(missing.IsUnhydratable(0x12340100u)); + Assert.True(empty.IsUnhydratable(0x12340100u)); + } + + [Fact] + public void GameWindowUsesSessionPhaseAndContainsNoPhaseSevenBody() + { + string root = FindRepoRoot(); + string window = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + string phase = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); + + Assert.Contains("new SessionPlayerCompositionPhase(", window); + Assert.DoesNotContain("LandblockStreamer.CreateForRequests(", window); + Assert.DoesNotContain("new AcDream.App.Net.LiveSessionController()", window); + Assert.DoesNotContain( + "new AcDream.App.Streaming.LocalPlayerTeleportController(", + window); + Assert.DoesNotContain("IsSpawnClaimUnhydratable", window); + Assert.Contains("LandblockStreamer.CreateForRequests(", phase); + Assert.Contains("new LiveSessionController()", phase); + Assert.Contains("new LocalPlayerTeleportController(", phase); + Assert.Contains("new DatSpawnClaimHydrationClassifier(", phase); + } + + [Fact] + public void ProductionPhaseStartsStreamerBeforeSessionAndTransfersPortalLast() + { + string phase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); + + AssertAppearsInOrder( + phase, + "LandblockStreamer.CreateForRequests(", + "streamerLease.Resource.Start();", + "new StreamingController(", + "new WorldRevealCoordinator(", + "new LiveSessionController()", + "new LiveEntityHydrationController(", + "new LiveEntityNetworkUpdateController(", + "new GameplayInputFrameController(", + "new PlayerModeController(", + "d.PortalTunnelFallback.Transfer(", + "d.TeleportSink.BindOwned(localTeleport)", + "_publication.PublishSessionPlayer(result);"); + } + + private sealed class RetryBinding( + string name, + List calls, + int failures) : IDisposable + { + private int _failures = failures; + + public void Dispose() + { + calls.Add(name); + if (_failures-- > 0) + throw new InvalidOperationException("retry"); + } + } + + private static void AssertAppearsInOrder(string source, params string[] values) + { + int cursor = 0; + foreach (string value in values) + { + int found = source.IndexOf(value, cursor, StringComparison.Ordinal); + Assert.True(found >= 0, $"Missing expected source fragment: {value}"); + cursor = found + value.Length; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs index 391ce750..0dcd182a 100644 --- a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using System.Reflection; using AcDream.App.Diagnostics; +using AcDream.App.Net; using AcDream.App.Rendering; using AcDream.UI.Abstractions; @@ -8,6 +9,24 @@ namespace AcDream.App.Tests.Rendering; public sealed class DevToolsFramePresenterTests { + [Fact] + public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind() + { + var source = new DevToolsCommandBusSource(); + using var first = new LiveSessionController(); + using var second = new LiveSessionController(); + + IDisposable firstBinding = source.BindOwned(first); + Assert.Same(first.Commands, source.Current); + Assert.Throws(() => source.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = source.BindOwned(second); + firstBinding.Dispose(); + + Assert.Same(second.Commands, source.Current); + } + [Fact] public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder() { diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs index 841e5a16..1b328241 100644 --- a/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DevToolsRuntimeSourcesTests.cs @@ -61,6 +61,25 @@ public sealed class DevToolsRuntimeSourcesTests Assert.Throws(() => source.Bind(first)); } + [Fact] + public void PlayerModeOwnedBindingReleasesExactlyAndAllowsRebind() + { + var source = new DeferredDevToolsPlayerModeCommands(); + var first = new PlayerModeTarget(); + var second = new PlayerModeTarget(); + + IDisposable firstBinding = source.BindOwned(first); + Assert.Throws(() => source.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = source.BindOwned(second); + firstBinding.Dispose(); + source.ToggleFlyOrChase(); + + Assert.Equal(0, first.ToggleCalls); + Assert.Equal(1, second.ToggleCalls); + } + [Fact] public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld() { diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 920fd070..d8cd3968 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -39,14 +39,24 @@ public sealed class GameWindowRenderLeafCompositionTests [Fact] public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers() { + string phase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); string source = GameWindowSource(); + AssertAppearsInOrder( + phase, + "d.PortalTunnelFallback.Transfer(", + "new LocalPlayerTeleportController(", + "new LocalPlayerTeleportPresentation(portalTunnel)", + "d.TeleportSink.BindOwned(localTeleport)", + "_publication.PublishSessionPlayer(result);"); AssertAppearsInOrder( source, - "_portalTunnelFallback.Transfer(", - "new AcDream.App.Streaming.LocalPlayerTeleportController(", - "new AcDream.App.Streaming.LocalPlayerTeleportPresentation(", - "_localPlayerTeleportSink.Bind(_localPlayerTeleport);", + "new SessionPlayerCompositionPhase(", "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", "new AcDream.App.Rendering.RenderFrameResourceController(", "new AcDream.App.Rendering.PrivatePresentationRenderer(", diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 4585ac31..953a37d4 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -217,7 +217,7 @@ public sealed class GameWindowSlice8BoundaryTests "private AcDream.App.Net.LiveSessionHost"); string body = MethodBody( "private void OnFramebufferResize(", - "private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;"); + "private void OnClosing()"); Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal); Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); @@ -294,8 +294,18 @@ public sealed class GameWindowSlice8BoundaryTests "new SettingsDevToolsCompositionPhase(", "this).Compose(platform, hostInputCamera, contentEffectsAudio);", "new WorldRenderCompositionPhase(", - "_runtimeSettings.BindRuntimeTargets(", + "new SessionPlayerCompositionPhase(", "_liveSessionHost.Start(_options)"); + string sessionPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); + Assert.Contains( + "d.Settings.BindRuntimeTargetsOwned(settingsTargets)", + sessionPhase, + StringComparison.Ordinal); string settingsPhase = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", @@ -320,7 +330,8 @@ public sealed class GameWindowSlice8BoundaryTests AssertAppearsInOrder( shutdown, "_runtimeSettings.UnbindViewModel()", - "_runtimeSettings.UnbindRuntimeTargets", + "new ResourceShutdownStage(\"frame borrowers\"", + "bindings.Dispose();", "_retailUiLease.Dispose()", "_streamer?.Dispose()", "_wbDrawDispatcher?.Dispose()", @@ -425,7 +436,6 @@ public sealed class GameWindowSlice8BoundaryTests "pointer.Dispose();", "new ResourceShutdownStage(\"session dependents\"", "pointer.ReleaseMouseLookAfterSessionRetirement();", - "pointer.UnbindGameplayFrame(gameplayFrame);", "_cameraPointerInput = null;"); AssertAppearsInOrder( dispose, @@ -472,6 +482,12 @@ public sealed class GameWindowSlice8BoundaryTests "Composition", "WorldRenderComposition.cs")); string livePhase = LivePresentationSource(); + string sessionPhase = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); AssertAppearsInOrder( livePhase, @@ -483,8 +499,13 @@ public sealed class GameWindowSlice8BoundaryTests load, "new WorldRenderCompositionPhase(", "new LivePresentationCompositionPhase(", - "_portalTunnelFallback.Transfer(", + "new SessionPlayerCompositionPhase(", "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); + AssertAppearsInOrder( + sessionPhase, + "d.PortalTunnelFallback.Transfer(", + "new LocalPlayerTeleportController(", + "_publication.PublishSessionPlayer(result);"); AssertAppearsInOrder( worldPhase, "lifetime.AcquireTerrainAtlas(", diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index 118c3282..f582d2ab 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -524,6 +524,27 @@ public sealed class RuntimeSettingsControllerTests Assert.Equal(1, second.UiLockCalls); } + [Fact] + public void OwnedRuntimeTargetsReleaseExactlyAndAllowRebind() + { + var events = new List(); + RuntimeSettingsController controller = CreateController(events: events); + var first = new FakeRuntimeTargets(events); + var second = new FakeRuntimeTargets(events); + + IDisposable firstBinding = controller.BindRuntimeTargetsOwned(first); + Assert.Throws(() => + controller.BindRuntimeTargetsOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = controller.BindRuntimeTargetsOwned(second); + firstBinding.Dispose(); + controller.SetUiLocked(true); + + Assert.Equal(0, first.UiLockCalls); + Assert.Equal(1, second.UiLockCalls); + } + [Fact] public void DisplayPersistenceFailureDoesNotPublishStateOrTargets() { diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 59878bc4..cf06b74d 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -13,6 +13,25 @@ namespace AcDream.App.Tests.Streaming; public sealed class LocalPlayerTeleportControllerTests { + [Fact] + public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind() + { + var deferred = new DeferredLocalPlayerTeleportNetworkSink(); + var first = new FakeNetworkSink(); + var second = new FakeNetworkSink(); + + IDisposable firstBinding = deferred.BindOwned(first); + Assert.Throws(() => deferred.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = deferred.BindOwned(second); + firstBinding.Dispose(); + deferred.OnTeleportStarted(9); + + Assert.Empty(first.Starts); + Assert.Equal([9u], second.Starts); + } + [Fact] public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates() { @@ -684,6 +703,20 @@ public sealed class LocalPlayerTeleportControllerTests } } + private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink + { + public List Starts { get; } = []; + public void OnTeleportStarted(uint sequence) => Starts.Add(sequence); + public void OfferDestination( + WorldSession.EntityPositionUpdate update, + bool teleportTimestampAdvanced) + { + } + public void ResetSession() + { + } + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order; diff --git a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs index ec816fdb..21f6f1a2 100644 --- a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs +++ b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs @@ -42,6 +42,29 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests Assert.Throws(() => bridge.Bind(bridge)); } + [Fact] + public void OwnedBindingReleasesExactlyAndAllowsRebind() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + int firstCalls = 0; + int secondCalls = 0; + var first = new DelegateLiveEntityRuntimeComponentLifecycle( + _ => firstCalls++); + var second = new DelegateLiveEntityRuntimeComponentLifecycle( + _ => secondCalls++); + + IDisposable firstBinding = bridge.BindOwned(first); + Assert.Throws(() => bridge.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = bridge.BindOwned(second); + firstBinding.Dispose(); + bridge.TearDown(CreateRecord()); + + Assert.Equal(0, firstCalls); + Assert.Equal(1, secondCalls); + } + [Fact] public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index ea55f29f..852e8ac5 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -32,6 +32,24 @@ public sealed class LiveEntityHydrationControllerTests Assert.Throws(() => bridge.Bind(_ => false)); } + [Fact] + public void ParentAcceptanceOwnedBindingReleasesExactlyAndAllowsRebind() + { + var bridge = new DeferredLiveEntityParentAcceptance(); + Func first = _ => true; + Func second = _ => false; + + IDisposable firstBinding = bridge.BindOwned(first); + Assert.True(bridge.TryAccept(default)); + Assert.Throws(() => bridge.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = bridge.BindOwned(second); + firstBinding.Dispose(); + + Assert.False(bridge.TryAccept(default)); + } + [Fact] public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization() { @@ -1580,6 +1598,25 @@ public sealed class LiveEntityHydrationControllerTests deferred.Bind(new RecordingNetworkSink())); } + [Fact] + public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind() + { + var deferred = new DeferredLiveEntityNetworkUpdateSink(); + var first = new RecordingNetworkSink(); + var second = new RecordingNetworkSink(); + + IDisposable firstBinding = deferred.BindOwned(first); + Assert.Throws(() => deferred.BindOwned(second)); + + firstBinding.Dispose(); + using IDisposable secondBinding = deferred.BindOwned(second); + firstBinding.Dispose(); + deferred.ApplySameGeneration(default); + + Assert.Equal(0, first.ApplyCount); + Assert.Equal(1, second.ApplyCount); + } + private const uint Guid = 0x70000001u; private const uint Cell = 0x01010001u; diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index c0373787..542a8f41 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -343,9 +343,15 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Composition", "LivePresentationComposition.cs")); + string sessionPlayer = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); - Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source); - Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source); + Assert.Contains("new LiveObjectFrameController(", sessionPlayer); + Assert.Contains("new LiveSpatialPresentationReconciler(", sessionPlayer); Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source); Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal); Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source, @@ -363,8 +369,14 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Rendering", "GameWindow.cs")); + string sessionPlayer = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); - Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source); + Assert.Contains("new StreamingFrameController(", sessionPlayer); Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal); Assert.Equal(1, CountOccurrences( source, @@ -385,8 +397,14 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Rendering", "GameWindow.cs")); + string sessionPlayer = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); - Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source); + Assert.Contains("new GameplayInputFrameController(", sessionPlayer); Assert.DoesNotContain("_gameplayInputFrame!.Tick", source, StringComparison.Ordinal); Assert.DoesNotContain("_inputDispatcher?.Tick()", source, @@ -468,10 +486,16 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Physics", "LiveEntityNetworkUpdateController.cs")); + string sessionPlayer = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "SessionPlayerComposition.cs")); Assert.Contains( - "new AcDream.App.Streaming.LocalPlayerTeleportController(", - source, + "new LocalPlayerTeleportController(", + sessionPlayer, StringComparison.Ordinal); Assert.DoesNotContain("_localPlayerTeleport!.Tick", source, StringComparison.Ordinal); @@ -491,7 +515,7 @@ public sealed class UpdateFrameOrchestratorTests AssertAppearsInOrder( source, "new AcDream.App.Update.LiveEntityLivenessFramePhase(", - "_localPlayerTeleport,", + "sessionPlayer.LocalTeleport,", "new AcDream.App.Update.PlayerModeAutoEntryFramePhase(", "cameraFrame);"); } @@ -799,7 +823,7 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation, StringComparison.Ordinal); - Assert.Contains("_liveEntitySessionEvents.CreateSink()", source, + Assert.Contains("Live entity session routing was not composed.", source, StringComparison.Ordinal); }