diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8cf014d4..13d6e872 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,15 +26,14 @@ What does NOT go here: ## Current queue — 2026-07-22 -- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–G now own native callbacks, live-session - composition, world-environment state, reversible device input/raw pointer, - physical framebuffer publication, gameplay action priority, typed commands, - the retained-root item-drop edge, and two-phase runtime settings outside the - shell. +- **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 + 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 3,663 lines / 162 fields / 37 methods. Remaining - Slice 8 resource/composition/shutdown + handoffs and the class is 2,840 raw lines. I.7–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 d67026bb..93d70450 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -726,16 +726,17 @@ oracle, platform/host/content/settings phases, and the production world/render phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/ sampler construction; every Phase-4 GL constructor has retryable prefix -ownership. `GameWindow` is 3,522 raw lines after I.5. I.6a moves interaction -and retained-UI construction into `InteractionRetainedUiCompositionPhase`; -typed exact-owner sources now bridge the later session, selection, radar, -view-plane, diagnostics, inventory, and automation owners without callbacks to -the window. The old item-use and selection wrapper methods are deleted. -`GameWindow` is 3,246 raw lines after I.6a. I.6b–L remain active, with live -presentation and landblock-publication composition next. The App gate passes -3,401 tests / 3 intentional skips and the complete Release suite passes 7,773 -tests / 5 intentional skips. The solution retains only the 17 warnings tracked -by #228; the I.6a behavior, architecture, and adversarial reviews are clean. +ownership. `GameWindow` is 3,522 raw lines after I.5. I.6 moves interaction, +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 +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 1e0d45d9..e6f2ae7b 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -97,12 +97,15 @@ 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. `GameWindow` is now -3,689 lines / 162 fields / 37 methods. Ordered composition, shutdown, canonical -soak snapshots, and closeout checkpoints I–L remain. Checkpoint H passes 61 -focused ownership tests and 3,236 App tests / 3 intentional skips; the complete -Release suite passes 7,606 tests / 5 skips and all three final corrected-diff -reviews are clean. +checked retryable GL leaf construction and state mutation. Checkpoint I.1–I.6 +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. 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 5b8fe6ee..f7c8c3da 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -53,12 +53,15 @@ 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. `GameWindow` is 3,689 lines / 162 fields / 37 methods. -Checkpoints I–L remain active. Issue #232 -tracks process-residency variance in the soak without loosening its leak -threshold. Checkpoint H passes 61 focused ownership tests and 3,236 App tests / -3 skips; the complete Release suite passes 7,606 tests / 5 skips and all three -final corrected-diff reviews are clean. +leaf construction. Checkpoint I.1–I.6 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. 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 b433c0e3..a26aa0b1 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 @@ -271,15 +271,17 @@ ledger only after the complete pipeline is live. tests / 3 intentional skips; the complete Release suite passes 7,745 tests / 5 intentional skips. Production App builds with zero warnings/errors; the solution retains only the 17 warnings tracked by #228. -- I.6 (retained UI plus live-presentation composition) is active. Its detailed +- I.6 (retained UI plus live-presentation composition) is complete. Its detailed two-commit ownership and failure-test plan is [`2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md`](2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md). - I.6a is complete: Phase 5 now composes interaction and retained UI through - typed dependencies, exact-owner late bindings, and transactional rollback. - `GameWindow.cs` is 3,246 raw lines; the App gate passes 3,401 tests / 3 - intentional skips and the complete Release suite passes 7,773 tests / 5 - intentional skips. I.6b live presentation and landblock publication is the - active cut. + Phase 5 now composes interaction and retained UI through typed dependencies, + exact-owner late bindings, and transactional rollback. Phase 6 now composes + the canonical live runtime/world, presentation/effect controllers, + selection/radar, landblock publishers, and portal/sky/particle resources + 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. 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-i6-ui-live-presentation.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md index 3e702404..ca696e95 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md @@ -1,6 +1,6 @@ # GameWindow Slice 8 — Checkpoint I.6 UI and Live Presentation -**Status:** I.6a complete; I.6b active +**Status:** Complete; I.7 active **Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md` @@ -195,6 +195,27 @@ The fallback slot and final owner never co-own the tunnel. - App Release build/tests, full Release build/tests, divergence/source audit; - three independent self-review passes as in I.6a. +### 3.5 I.6b completion evidence + +I.6b moved the complete Phase-6 body into +`LivePresentationCompositionPhase`. The phase now owns the canonical live +runtime/world identity, live presentation and effect controllers, WB draw and +landblock publication owners, selection/radar authorities, optional paperdoll, +and portal/sky/particle resources. Named exact-owner leases replace the former +anonymous Phase-6 subscriptions. A deferred landblock-loaded sink preserves +the Phase-7 hydration edge without capturing a future window field. + +Construction uses transactional acquisition ownership throughout, including +portal-fallback and sky-shader lifetime rollback. The focused binding, +rollback, retry, stale-release, and source-boundary tests pass. The App Release +gate passes 3,407 tests / 3 intentional skips, the complete Release suite +passes 7,779 tests / 5 intentional skips, and the clean solution build retains +only the 17 test-project warnings tracked by #228. `GameWindow.cs` is 2,840 +raw lines after I.6b. +Behavior/fidelity, architecture/ownership, and adversarial failure reviews +found no remaining actionable issue. I.7 session, streaming, and hydration +composition is the next active cut. + ## 4. Production cutover rules - Each subcommit leaves `OnLoad` calling the new phase and leaves later phases diff --git a/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs b/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs index 86de8d9e..c2be56ee 100644 --- a/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs +++ b/src/AcDream.App/Composition/AnimationHookRegistrationSet.cs @@ -58,6 +58,31 @@ internal sealed class AnimationHookRegistrationSet : IDisposable, _entries.Add(new Entry(sink)); } + public IDisposable RegisterOwned(IAnimationHookSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + ObjectDisposedException.ThrowIf(_closed, this); + if (_entries.Any(entry => + !entry.Removed && ReferenceEquals(entry.Sink, sink))) + { + throw new InvalidOperationException( + "The animation-hook sink is already registered and cannot be adopted twice."); + } + _register(sink); + _entries.Add(new Entry(sink)); + return new Binding(this, sink); + } + + private void UnregisterOwned(IAnimationHookSink sink) + { + Entry? entry = _entries.LastOrDefault(candidate => + !candidate.Removed && ReferenceEquals(candidate.Sink, sink)); + if (entry is null) + return; + _unregister(entry.Sink); + entry.Removed = true; + } + public void Dispose() { _closed = true; @@ -102,4 +127,25 @@ internal sealed class AnimationHookRegistrationSet : IDisposable, "Animation-hook registration cleanup remains incomplete.", failures); } + + private sealed class Binding : IDisposable + { + private AnimationHookRegistrationSet? _owner; + private readonly IAnimationHookSink _sink; + + public Binding(AnimationHookRegistrationSet owner, IAnimationHookSink sink) + { + _owner = owner; + _sink = sink; + } + + public void Dispose() + { + AnimationHookRegistrationSet? owner = _owner; + if (owner is null) + return; + owner.UnregisterOwned(_sink); + _owner = null; + } + } } diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs new file mode 100644 index 00000000..93f932a6 --- /dev/null +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -0,0 +1,938 @@ +using System.Numerics; +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.App.Interaction; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Sky; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Settings; +using AcDream.App.Streaming; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.World; +using AcDream.Core.Audio; +using AcDream.Core.Items; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Selection; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace AcDream.App.Composition; + +internal sealed record LivePresentationDependencies( + RuntimeOptions Options, + GL Gl, + IWindow Window, + object DatLock, + RuntimeSettingsController Settings, + HostQuiescenceGate HostQuiescence, + PhysicsEngine PhysicsEngine, + PhysicsDataCache PhysicsDataCache, + WorldGameState WorldGameState, + WorldEvents WorldEvents, + SelectionState Selection, + ClientObjectTable Objects, + LiveEntityRuntimeSlot RuntimeSlot, + DeferredLiveEntityMotionRuntimeBindings MotionBindings, + DeferredEntityEffectAdvanceSource EffectAdvance, + EntityEffectPoseRegistry EffectPoses, + RemotePhysicsUpdater RemotePhysicsUpdater, + LocalPlayerShadowState LocalPlayerShadow, + LiveEntityAnimationRuntimeView AnimatedEntities, + AnimationPresentationDiagnostics AnimationDiagnostics, + EntityClassificationCache ClassificationCache, + TranslucencyFadeManager TranslucencyFades, + RetailAlphaQueue RetailAlphaQueue, + CellVisibility CellVisibility, + LiveWorldOriginState WorldOrigin, + LocalPlayerIdentityState PlayerIdentity, + LocalPlayerControllerSlot PlayerController, + PointerPositionState PointerPosition, + PlayerApproachCompletionState PlayerApproachCompletions, + GameRenderResourceLifetime RenderResourceLifetime, + TransferableResourceSlot PortalTunnelFallback, + AnimationHookRouter HookRouter, + IRenderFrameDiagnosticLog RenderDiagnosticLog, + WorldTimeService WorldTime, + DeferredCanonicalWorldEntityCountSource? DevWorldEntities, + DeferredRenderFrameDiagnosticsSource? DevFrameDiagnostics, + DeferredRenderFrameDiagnosticsSource UiFrameDiagnostics, + Action? Toast); + +internal sealed record LivePresentationResult( + DeferredLiveEntityRuntimeComponentLifecycle ComponentLifecycle, + LiveEntityMotionRuntimeController MotionRuntime, + DeferredLiveEntityParentAcceptance ParentAcceptance, + EntitySpawnAdapter EntitySpawnAdapter, + EntityScriptActivator EntityScriptActivator, + RetailStaticAnimatingObjectScheduler StaticAnimationScheduler, + GpuWorldState WorldState, + LiveEntityRuntime LiveEntities, + ProjectileController ProjectileController, + LiveEntityProjectionWithdrawalController ProjectionWithdrawal, + LiveEntityLightController Lights, + LiveEntityAnimationScheduler AnimationScheduler, + LiveEntityAnimationPresenter AnimationPresenter, + EquippedChildRenderController EquippedChildren, + EntityEffectController EntityEffects, + LiveEntityPresentationController Presentation, + RemoteTeleportController RemoteTeleport, + WbDrawDispatcher DrawDispatcher, + RetailSelectionScene SelectionScene, + WorldSelectionQuery SelectionQuery, + SelectionInteractionController SelectionInteractions, + RetainedUiGameplayBinding? RetainedGameplay, + PaperdollViewportRenderer? PaperdollRenderer, + PaperdollFramePresenter? PaperdollPresenter, + WbFrustum EnvCellFrustum, + EnvCellRenderer EnvCellRenderer, + LandblockPresentationPipeline LandblockPipeline, + ClipFrame ClipFrame, + PortalDepthMaskRenderer PortalDepthMask, + SkyRenderer SkyRenderer, + ParticleRenderer ParticleRenderer, + RenderFrameDiagnosticsController FrameDiagnostics, + LivePresentationRuntimeBindings RuntimeBindings, + DeferredLiveEntityLandblockLoadedSink LandblockLoaded); + +internal interface IGameWindowLivePresentationPublication +{ + void PublishLivePresentation(LivePresentationResult result); +} + +internal enum LivePresentationCompositionPoint +{ + CanonicalRuntimeCreated, + CanonicalRuntimeBound, + MotionRuntimeBound, + ProjectionVisibilityBound, + CorePresentationCreated, + EffectRoutingBound, + SelectionAndRadarBound, + RetainedGameplayBound, + PaperdollCreated, + EnvironmentCellsCreated, + LandblockPipelineCreated, + PortalResourcesCreated, + SkyAndParticlesCreated, + DiagnosticsBound, + ResultPublished, +} + +internal sealed class LivePresentationCompositionPhase + : ILivePresentationCompositionPhase< + HostInputCameraResult, + ContentEffectsAudioResult, + WorldRenderResult, + InteractionRetainedUiResult, + LivePresentationResult> +{ + private readonly LivePresentationDependencies _dependencies; + private readonly IGameWindowLivePresentationPublication _publication; + private readonly Action? _faultInjection; + + public LivePresentationCompositionPhase( + LivePresentationDependencies dependencies, + IGameWindowLivePresentationPublication publication, + Action? faultInjection = null) + { + _dependencies = dependencies + ?? throw new ArgumentNullException(nameof(dependencies)); + _publication = publication + ?? throw new ArgumentNullException(nameof(publication)); + _faultInjection = faultInjection; + } + + public LivePresentationResult Compose( + HostInputCameraResult host, + ContentEffectsAudioResult content, + WorldRenderResult world, + InteractionRetainedUiResult interaction) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(interaction); + return ComposeCore(host, content, world, interaction); + } + + private LivePresentationResult ComposeCore( + HostInputCameraResult host, + ContentEffectsAudioResult content, + WorldRenderResult world, + InteractionRetainedUiResult interaction) + { + LivePresentationDependencies d = _dependencies; + WorldRenderFoundation foundation = world.Foundation; + var scope = new CompositionAcquisitionScope(); + LivePresentationRuntimeBindings? bindings = null; + bool bindingsOwnedByScope = false; + + try + { + var componentLifecycle = + new DeferredLiveEntityRuntimeComponentLifecycle(); + var wbSpawnAdapter = new LandblockSpawnAdapter(foundation.MeshAdapter); + + AnimationSequencer SequencerFactory(WorldEntity entity) + { + Setup? setup = content.Dats.Get(entity.SourceGfxObjOrSetupId); + if (setup is not null) + { + uint motionTableId = (uint)setup.DefaultMotionTable; + if (motionTableId != 0 + && content.Dats.Get(motionTableId) is { } motionTable) + { + return new AnimationSequencer( + setup, + motionTable, + content.AnimationLoader); + } + + return new AnimationSequencer( + setup, + new MotionTable(), + content.AnimationLoader); + } + + return new AnimationSequencer( + new Setup(), + new MotionTable(), + NullAnimationLoader.Instance); + } + + var entitySpawnAdapter = new EntitySpawnAdapter( + foundation.TextureCache, + SequencerFactory, + foundation.MeshAdapter); + EntityEffectController? entityEffects = null; + LiveEntityRuntime? liveEntities = null; + var staticRootCommitter = new StaticLiveRootCommitter( + d.RuntimeSlot, + d.PhysicsEngine.ShadowObjects, + d.WorldOrigin, + d.EffectPoses); + var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot); + var staticAnimationScheduler = + new RetailStaticAnimatingObjectScheduler( + content.AnimationLoader, + content.AnimationHookFrames.Capture, + d.EffectPoses.Publish, + staticResidency.IsResident, + (entity, body) => _ = staticRootCommitter.Commit(entity, body), + staticResidency.ProjectionVersion); + + ScriptActivationInfo? ResolveActivation(WorldEntity entity) + { + try + { + Setup? setup = content.Dats.Get( + entity.SourceGfxObjOrSetupId); + if (setup is null) + return null; + uint scriptId = setup.DefaultScript.DataId; + if (entity.IndexedPartTransforms.Count == 0) + { + var indexed = IndexedSetupPartPoseBuilder.Build(setup, entity); + entity.SetIndexedPartPoses(indexed.Poses, indexed.Available); + } + + bool usesStaticAnimationWorkset = entity.ServerGuid == 0 + || (liveEntities?.TryGetRecord( + entity.ServerGuid, + out LiveEntityRecord liveRecord) == true + && (liveRecord.FinalPhysicsState + & PhysicsStateFlags.Static) != 0); + return new ScriptActivationInfo( + scriptId, + entity.IndexedPartTransforms, + EntityEffectProfile.CreateDatStatic(setup), + entity.IndexedPartAvailable, + setup, + (uint)setup.DefaultAnimation, + usesStaticAnimationWorkset); + } + catch + { + return null; + } + } + + var entityScriptActivator = new EntityScriptActivator( + content.ScriptRunner, + content.ParticleSink, + d.EffectPoses, + ResolveActivation, + (ownerId, entity, profile) => + entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile), + ownerId => + { + entityEffects?.OnDatStaticEntityRemoved(ownerId); + content.LightingSink.UnregisterOwner(ownerId); + d.TranslucencyFades.ClearEntity(ownerId); + }, + (entity, info) => staticAnimationScheduler.Register(entity, info), + staticAnimationScheduler.Unregister, + (entity, info) => staticAnimationScheduler.Rebind(entity, info)); + + var worldState = new GpuWorldState( + wbSpawnAdapter, + d.ClassificationCache.InvalidateLandblock, + entityScriptActivator); + bindings = new LivePresentationRuntimeBindings(); + if (d.DevWorldEntities is { } devWorldEntities) + { + bindings.Adopt( + "developer world-entity source", + devWorldEntities.BindOwned(worldState)); + } + liveEntities = new LiveEntityRuntime( + worldState, + new CompositeLiveEntityResourceLifecycle( + new( + entity => _ = entitySpawnAdapter.OnCreate(entity), + entity => _ = entitySpawnAdapter.OnRemove(entity)), + new( + entityScriptActivator.OnCreate, + entityScriptActivator.OnRemove)), + componentLifecycle); + var liveRuntimeLease = scope.Own( + "canonical live-entity runtime", + liveEntities, + static runtime => runtime.Clear()); + Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated); + + bindings.Adopt( + "canonical live-runtime slot", + d.RuntimeSlot.BindOwned(liveEntities)); + Fault(LivePresentationCompositionPoint.CanonicalRuntimeBound); + + var selectionInteractionSource = + new DeferredSelectionInteractionSource(); + var motionRuntime = new LiveEntityMotionRuntimeController( + liveEntities, + d.PhysicsDataCache, + () => selectionInteractionSource.Current, + d.Selection, + d.WorldOrigin); + bindings.Adopt( + "live motion runtime", + d.MotionBindings.BindOwned(motionRuntime)); + Fault(LivePresentationCompositionPoint.MotionRuntimeBound); + + Action wbVisibility = (record, visible) => + { + if (record.WorldEntity is { } entity) + entitySpawnAdapter.SetPresentationResident(entity, visible); + }; + bindings.BindProjectionVisibility( + liveEntities, + wbVisibility, + "WB projection visibility"); + Action particleVisibility = (record, visible) => + { + if (record.WorldEntity is { } entity) + content.ParticleSink.SetEntityPresentationVisible(entity.Id, visible); + }; + bindings.BindProjectionVisibility( + liveEntities, + particleVisibility, + "particle projection visibility"); + Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound); + + var projectileController = new ProjectileController( + liveEntities, + d.PhysicsEngine, + new DatProjectileSetupResolver(content.Dats, d.DatLock), + new EntityRootPosePublisher(d.EffectPoses), + d.WorldOrigin) + { + DiagnosticSink = message => + Console.Error.WriteLine($"projectile: {message}"), + }; + var projectionWithdrawal = + new LiveEntityProjectionWithdrawalController( + liveEntities, + projectileController, + d.WorldGameState, + d.WorldEvents, + d.PhysicsEngine.ShadowObjects, + d.EffectPoses, + d.LocalPlayerShadow); + var lightsLease = scope.Acquire( + "live-entity lights", + () => new LiveEntityLightController( + liveEntities, + d.EffectPoses, + content.LightingSink, + setupId => content.Dats.Get(setupId)), + static value => value.Dispose()); + var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater( + d.PhysicsEngine, + d.MotionBindings.GetSetupCylinder); + var animationScheduler = new LiveEntityAnimationScheduler( + liveEntities, + d.PlayerIdentity, + d.RemotePhysicsUpdater, + ordinaryPhysicsUpdater, + projectileController, + new EntityRootPosePublisher(d.EffectPoses), + new AnimationHookCaptureSink(content.AnimationHookFrames)); + var animationPresenter = new LiveEntityAnimationPresenter( + liveEntities, + staticAnimationScheduler, + d.EffectPoses, + new LiveAnimationPresentationContext( + liveEntities, + d.PlayerIdentity, + d.PlayerController), + d.AnimationDiagnostics, + d.Options.HidePartIndex); + var parentAcceptance = new DeferredLiveEntityParentAcceptance(); + var equippedLease = scope.Acquire( + "equipped-child renderer", + () => new EquippedChildRenderController( + content.Dats, + d.DatLock, + d.Objects, + liveEntities, + d.EffectPoses, + parentAcceptance.TryAccept, + (childRecord, positionVersion, projectionVersion) => + projectionWithdrawal.WithdrawExact( + childRecord, + positionVersion, + projectionVersion, + d.PlayerIdentity.ServerGuid)), + static value => value.Dispose()); + Fault(LivePresentationCompositionPoint.CorePresentationCreated); + + var tableResolver = new PhysicsScriptTableResolver( + id => content.Dats.Get(id)); + entityEffects = new EntityEffectController( + liveEntities, + content.ScriptRunner, + tableResolver, + d.EffectPoses, + (parentLocalId, partIndex) => + equippedLease.Resource.FindChildLocalIdAtPart( + parentLocalId, + partIndex), + equippedLease.Resource.FindParentLocalId, + ownerId => content.Audio?.EntitySoundTables.Remove(ownerId), + (ownerId, soundTableDid) => + { + content.Audio?.EntitySoundTables.Remove(ownerId); + if (soundTableDid is { } did) + content.Audio?.EntitySoundTables.Set(ownerId, did); + }); + bindings.Adopt( + "entity-effect advance", + d.EffectAdvance.BindOwned(entityEffects)); + entityEffects.DiagnosticSink = message => + Console.Error.WriteLine($"vfx: {message}"); + var partArrayLifecycle = new LiveEntityPartArrayLifecycle( + d.AnimatedEntities); + var presentationLease = scope.Acquire( + "live-entity presentation", + () => new LiveEntityPresentationController( + liveEntities, + d.PhysicsEngine.ShadowObjects, + entityEffects.PlayTypedFromHiddenTransition, + equippedLease.Resource.SetDirectChildrenNoDraw, + d.MotionBindings.ClearTargetForHiddenEntity, + d.WorldOrigin.GetCenter, + partArrayLifecycle.HandleEnterWorld), + static value => value.Dispose()); + var remoteShadowPlacement = new RemoteShadowPlacementSynchronizer( + d.RemotePhysicsUpdater, + d.WorldOrigin); + var remoteTeleportPresentation = + new RemoteTeleportPlacementPresentation(presentationLease.Resource); + var remoteTeleportLease = scope.Acquire( + "remote teleport", + () => new RemoteTeleportController( + d.PhysicsEngine, + liveEntities, + d.MotionBindings.GetSetupCylinder, + d.WorldOrigin.CellLocalForSeed, + remoteShadowPlacement.Sync, + remoteTeleportPresentation.Complete, + remoteTeleportPresentation.Begin), + static value => value.Dispose()); + bindings.BindProjectionPoseReady( + equippedLease.Resource, + lightsLease.Resource.OnAttachedPoseReady); + bindings.Adopt( + "entity-effect animation hooks", + content.HookRegistrations.RegisterOwned(entityEffects)); + Fault(LivePresentationCompositionPoint.EffectRoutingBound); + + return CompletePresentation( + host, + content, + world, + interaction, + componentLifecycle, + motionRuntime, + parentAcceptance, + entitySpawnAdapter, + entityScriptActivator, + staticAnimationScheduler, + worldState, + liveEntities, + projectileController, + projectionWithdrawal, + lightsLease, + animationScheduler, + animationPresenter, + equippedLease, + entityEffects, + presentationLease, + remoteTeleportLease, + selectionInteractionSource, + bindings, + scope, + ref bindingsOwnedByScope, + liveRuntimeLease); + } + catch (Exception failure) + { + if (bindings is not null && !bindingsOwnedByScope) + { + scope.Own( + "live-presentation runtime bindings", + bindings, + static value => value.Dispose()); + bindingsOwnedByScope = true; + } + + scope.RollbackAndThrow(failure); + throw new System.Diagnostics.UnreachableException(); + } + } + + private LivePresentationResult CompletePresentation( + HostInputCameraResult host, + ContentEffectsAudioResult content, + WorldRenderResult world, + InteractionRetainedUiResult interaction, + DeferredLiveEntityRuntimeComponentLifecycle componentLifecycle, + LiveEntityMotionRuntimeController motionRuntime, + DeferredLiveEntityParentAcceptance parentAcceptance, + EntitySpawnAdapter entitySpawnAdapter, + EntityScriptActivator entityScriptActivator, + RetailStaticAnimatingObjectScheduler staticAnimationScheduler, + GpuWorldState worldState, + LiveEntityRuntime liveEntities, + ProjectileController projectileController, + LiveEntityProjectionWithdrawalController projectionWithdrawal, + CompositionAcquisitionScope.CompositionAcquisitionLease lightsLease, + LiveEntityAnimationScheduler animationScheduler, + LiveEntityAnimationPresenter animationPresenter, + CompositionAcquisitionScope.CompositionAcquisitionLease equippedLease, + EntityEffectController entityEffects, + CompositionAcquisitionScope.CompositionAcquisitionLease presentationLease, + CompositionAcquisitionScope.CompositionAcquisitionLease remoteTeleportLease, + DeferredSelectionInteractionSource selectionInteractionSource, + LivePresentationRuntimeBindings bindings, + CompositionAcquisitionScope scope, + ref bool bindingsOwnedByScope, + CompositionAcquisitionScope.CompositionAcquisitionLease liveRuntimeLease) + { + LivePresentationDependencies d = _dependencies; + WorldRenderFoundation foundation = world.Foundation; + + var selectionScene = new RetailSelectionScene( + new RetailSelectionGeometryCache(content.Dats, d.DatLock)); + var dispatcherLease = scope.Acquire( + "WB draw dispatcher", + () => new WbDrawDispatcher( + d.Gl, + foundation.MeshShader, + foundation.TextureCache, + foundation.MeshAdapter, + entitySpawnAdapter, + foundation.Bindless, + d.ClassificationCache, + d.TranslucencyFades, + selectionScene, + d.RetailAlphaQueue), + static value => value.Dispose()); + var selectionQuery = new WorldSelectionQuery( + liveEntities, + d.Objects, + selectionScene, + () => d.PlayerIdentity.ServerGuid, + interaction.LateBindings.SelectionCamera.Snapshot, + () => new Vector2( + d.PointerPosition.X, + d.PointerPosition.Y), + () => d.PlayerController.Controller is { } player + ? new PlayerInteractionPose(player.CellId, player.Position) + : null, + d.MotionBindings.GetSetupCylinder, + setupId => + { + lock (d.DatLock) + { + if (!content.Dats.TryGet(setupId, out Setup? setup) + || setup.SelectionSphere is not { } sphere) + { + return null; + } + return (sphere.Origin, sphere.Radius); + } + }); + var radarSnapshotProvider = new RadarSnapshotProvider( + d.Objects, + () => liveEntities.WorldEntities, + () => liveEntities.Snapshots, + playerGuid: () => d.PlayerIdentity.ServerGuid, + playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f, + playerCellId: () => d.PlayerController.Controller?.CellId ?? 0u, + selectedGuid: () => d.Selection.SelectedObjectId, + coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar, + uiLocked: () => d.Settings.Gameplay.LockUI, + playerEntities: () => liveEntities.MaterializedWorldEntities, + spatialQuery: () => worldState); + bindings.Adopt( + "radar snapshot", + interaction.LateBindings.Radar.Bind(radarSnapshotProvider)); + var selectionInteractions = new SelectionInteractionController( + d.Selection, + selectionQuery, + interaction.ItemInteraction, + new WorldSessionSelectionInteractionTransport( + () => interaction.LateBindings.Session.CurrentSession), + new PlayerInteractionMovementSink( + () => d.PlayerController.Controller, + d.PlayerApproachCompletions), + d.Toast, + d.PlayerApproachCompletions); + selectionInteractionSource.Bind(selectionInteractions); + bindings.Adopt( + "world selection", + interaction.LateBindings.Selection.Bind( + selectionQuery, + selectionInteractions)); + Fault(LivePresentationCompositionPoint.SelectionAndRadarBound); + + CompositionAcquisitionScope.CompositionAcquisitionLease< + RetainedUiGameplayBinding>? retainedGameplayLease = null; + if (interaction.RetainedUi is { } retainedUi) + { + retainedGameplayLease = scope.Acquire( + "retained gameplay binding", + () => RetainedUiGameplayBinding.Create( + retainedUi.Host.Root, + (item, x, y) => + selectionInteractions.PlaceDraggedItem(item, x, y), + d.HostQuiescence), + static value => value.Dispose()); + retainedGameplayLease.Resource.Attach(); + } + Fault(LivePresentationCompositionPoint.RetainedGameplayBound); + dispatcherLease.Resource.AlphaToCoverage = + d.Settings.ResolvedQuality.AlphaToCoverage; + + CompositionAcquisitionScope.CompositionAcquisitionLease< + PaperdollViewportRenderer>? paperdollLease = null; + PaperdollFramePresenter? paperdollPresenter = null; + if (interaction.RetainedUi?.Runtime.PaperdollViewportWidget is { } viewport + && interaction.RetainedUi.Runtime.InventoryFrame is { } inventoryFrame) + { + paperdollLease = scope.Acquire( + "paperdoll viewport", + () => new PaperdollViewportRenderer( + d.Gl, + dispatcherLease.Resource, + foundation.SceneLighting, + foundation.TextureCache), + static value => value.Dispose()); + IUiViewportRenderer? previousRenderer = viewport.Renderer; + viewport.Renderer = paperdollLease.Resource; + bindings.AdoptRelease( + "paperdoll viewport target", + () => + { + if (ReferenceEquals(viewport.Renderer, paperdollLease.Resource)) + viewport.Renderer = previousRenderer; + }); + paperdollPresenter = new PaperdollFramePresenter( + paperdollLease.Resource, + new RetailPaperdollFrameView( + viewport, + new PaperdollInventoryVisibility(inventoryFrame)), + new RetailPaperdollDollFactory( + new LivePaperdollEntityLookup(liveEntities), + d.PlayerIdentity, + new RetailPaperdollPoseApplicator( + content.Dats, + content.AnimationLoader, + d.DatLock))); + } + Fault(LivePresentationCompositionPoint.PaperdollCreated); + + var envCellFrustum = new WbFrustum(); + var envCellLease = scope.Acquire( + "environment-cell renderer", + () => new EnvCellRenderer( + d.Gl, + foundation.MeshAdapter.MeshManager!, + envCellFrustum), + static value => value.Dispose()); + envCellLease.Resource.Initialize(foundation.MeshShader); + Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated); + + var landblockRenderPublisher = new LandblockRenderPublisher( + (landblockId, meshData, origin) => + foundation.Terrain.AddLandblockWithMesh( + landblockId, + meshData, + origin), + foundation.Terrain.RemoveLandblock, + d.CellVisibility, + worldState, + envCellLease.Resource.CommitLandblock, + build => EnvCellMeshPreparationScheduler.Schedule( + build, + foundation.MeshAdapter.MeshManager!), + envCellLease.Resource.RemoveLandblock); + var landblockPhysicsPublisher = new LandblockPhysicsPublisher( + d.PhysicsEngine, + world.TerrainBuild.HeightTable); + var landblockStaticPublisher = + new LandblockStaticPresentationPublisher( + content.LightingSink, + d.TranslucencyFades, + d.WorldGameState, + d.WorldEvents); + var landblockRetirementOwner = + new LandblockPresentationRetirementOwner( + landblockRenderPublisher, + landblockPhysicsPublisher, + landblockStaticPublisher, + content.LightingSink, + d.TranslucencyFades); + var landblockLoaded = new DeferredLiveEntityLandblockLoadedSink(); + var landblockPipeline = new LandblockPresentationPipeline( + landblockRenderPublisher, + landblockPhysicsPublisher, + landblockStaticPublisher, + worldState, + landblockRetirementOwner, + landblockLoaded.OnLandblockLoaded, + landblockRenderPublisher.PrepareAfterRenderPins); + Fault(LivePresentationCompositionPoint.LandblockPipelineCreated); + + var clipFrameLease = scope.Acquire( + "portal clip frame", + ClipFrame.NoClip, + static value => value.Dispose()); + var portalDepthLease = scope.Acquire( + "portal depth mask", + () => new PortalDepthMaskRenderer(d.Gl), + static value => value.Dispose()); + PortalTunnelPresentation portalTunnel; + try + { + portalTunnel = d.PortalTunnelFallback.AcquirePrepared( + () => PortalTunnelPresentation.CreateRequired( + d.Gl, + content.Dats, + content.AnimationLoader, + d.HookRouter, + dispatcherLease.Resource, + foundation.SceneLighting, + foundation.MeshAdapter), + static tunnel => tunnel.PrepareResources()); + } + catch (Exception acquisitionFailure) + { + try + { + d.PortalTunnelFallback.ReleaseFallback(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Portal-tunnel construction and fallback rollback both failed.", + acquisitionFailure, + cleanupFailure); + } + + throw; + } + var portalTunnelLease = scope.Own( + "portal tunnel fallback", + portalTunnel, + _ => d.PortalTunnelFallback.ReleaseFallback()); + Fault(LivePresentationCompositionPoint.PortalResourcesCreated); + + AcDream.App.Rendering.Shader skyShader = + d.RenderResourceLifetime.AcquireSkyShader( + () => new AcDream.App.Rendering.Shader( + d.Gl, + Path.Combine(foundation.ShadersDirectory, "sky.vert"), + Path.Combine(foundation.ShadersDirectory, "sky.frag"))); + var skyShaderLease = scope.Own( + "sky shader lifetime", + skyShader, + _ => d.RenderResourceLifetime.ReleaseSkyShader()); + var skyLease = scope.Acquire( + "sky renderer", + () => new SkyRenderer( + d.Gl, + content.Dats, + skyShader, + foundation.TextureCache, + foundation.Samplers), + static value => value.Dispose()); + var particleLease = scope.Acquire( + "particle renderer", + () => new ParticleRenderer( + d.Gl, + foundation.ShadersDirectory, + content.ParticleSystem, + foundation.TextureCache, + content.Dats, + foundation.MeshAdapter, + d.RetailAlphaQueue), + static value => value.Dispose()); + Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated); + + IRenderFrameResourceDiagnosticsSource? resourceDiagnostics = + d.Options.UiProbeDump + ? new RuntimeRenderFrameResourceDiagnosticsSource( + content.ParticleSystem, + content.ParticleSink, + dispatcherLease.Resource, + envCellLease.Resource, + particleLease.Resource, + interaction.RetainedUi?.Host.TextRenderer, + portalDepthLease.Resource, + clipFrameLease.Resource, + foundation.Terrain, + foundation.SceneLighting, + foundation.MeshAdapter, + foundation.TextureCache) + : null; + var frameDiagnostics = new RenderFrameDiagnosticsController( + new RuntimeRenderFrameTitleFactsSource( + worldState, + d.AnimatedEntities, + d.WorldTime), + new SilkRenderFrameTitleSink(d.Window), + d.RenderDiagnosticLog, + d.Options.UiProbeDump, + resourceDiagnostics); + if (d.DevFrameDiagnostics is { } devFrameDiagnostics) + { + bindings.Adopt( + "developer frame diagnostics", + devFrameDiagnostics.BindOwned(frameDiagnostics)); + } + bindings.Adopt( + "retained-UI frame diagnostics", + d.UiFrameDiagnostics.BindOwned(frameDiagnostics)); + Fault(LivePresentationCompositionPoint.DiagnosticsBound); + + var bindingsLease = scope.Own( + "live-presentation runtime bindings", + bindings, + static value => value.Dispose()); + bindingsOwnedByScope = true; + var result = new LivePresentationResult( + componentLifecycle, + motionRuntime, + parentAcceptance, + entitySpawnAdapter, + entityScriptActivator, + staticAnimationScheduler, + worldState, + liveEntities, + projectileController, + projectionWithdrawal, + lightsLease.Resource, + animationScheduler, + animationPresenter, + equippedLease.Resource, + entityEffects, + presentationLease.Resource, + remoteTeleportLease.Resource, + dispatcherLease.Resource, + selectionScene, + selectionQuery, + selectionInteractions, + retainedGameplayLease?.Resource, + paperdollLease?.Resource, + paperdollPresenter, + envCellFrustum, + envCellLease.Resource, + landblockPipeline, + clipFrameLease.Resource, + portalDepthLease.Resource, + skyLease.Resource, + particleLease.Resource, + frameDiagnostics, + bindings, + landblockLoaded); + _publication.PublishLivePresentation(result); + + liveRuntimeLease.Transfer(); + lightsLease.Transfer(); + equippedLease.Transfer(); + presentationLease.Transfer(); + remoteTeleportLease.Transfer(); + dispatcherLease.Transfer(); + retainedGameplayLease?.Transfer(); + paperdollLease?.Transfer(); + envCellLease.Transfer(); + clipFrameLease.Transfer(); + portalDepthLease.Transfer(); + portalTunnelLease.Transfer(); + skyLease.Transfer(); + skyShaderLease.Transfer(); + particleLease.Transfer(); + bindingsLease.Transfer(); + Fault(LivePresentationCompositionPoint.ResultPublished); + scope.Complete(); + return result; + } + + private void Fault(LivePresentationCompositionPoint point) => + _faultInjection?.Invoke(point); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public static NullAnimationLoader Instance { get; } = new(); + public Animation? LoadAnimation(uint id) => null; + } + + private sealed class DeferredSelectionInteractionSource + { + public SelectionInteractionController? Current { get; private set; } + + public void Bind(SelectionInteractionController value) + { + ArgumentNullException.ThrowIfNull(value); + if (Current is not null) + { + throw new InvalidOperationException( + "Live motion selection interactions are already bound."); + } + Current = value; + } + } +} diff --git a/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs b/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs new file mode 100644 index 00000000..764106e0 --- /dev/null +++ b/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs @@ -0,0 +1,97 @@ +using AcDream.App.Rendering; +using AcDream.App.World; + +namespace AcDream.App.Composition; + +/// +/// Exact-owner leases installed by live-presentation composition. Successful +/// releases are removed immediately, so a failed cleanup retries only its +/// remaining suffix and never replays completed detach operations. +/// +internal sealed class LivePresentationRuntimeBindings : 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 AdoptRelease(string name, Action release) => + Adopt(name, new DelegateBinding(release)); + + public void BindProjectionVisibility( + LiveEntityRuntime source, + Action handler, + string name) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(handler); + source.ProjectionVisibilityChanged += handler; + Adopt(name, new DelegateBinding( + () => source.ProjectionVisibilityChanged -= handler)); + } + + public void BindProjectionPoseReady( + EquippedChildRenderController source, + Action handler) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(handler); + source.ProjectionPoseReady += handler; + Adopt("equipped-child projection pose", new DelegateBinding( + () => source.ProjectionPoseReady -= 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( + $"Live-presentation binding '{name}' did not detach.", + failure)); + } + } + + if (failures is not null) + { + throw new AggregateException( + "Live-presentation 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/Physics/DeferredLiveEntityMotionRuntimeBindings.cs b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs index 6a2cb41c..6fbc456c 100644 --- a/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs +++ b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs @@ -39,6 +39,18 @@ internal sealed class DeferredLiveEntityMotionRuntimeBindings _target = target; } + public IDisposable BindOwned(ILiveEntityMotionRuntimeBindings target) + { + Bind(target); + return new Binding(this, target); + } + + private void Unbind(ILiveEntityMotionRuntimeBindings expected) + { + if (ReferenceEquals(_target, expected)) + _target = null; + } + private ILiveEntityMotionRuntimeBindings Target => _target ?? throw new InvalidOperationException( "Live entity motion runtime bindings were used before binding."); @@ -61,4 +73,21 @@ internal sealed class DeferredLiveEntityMotionRuntimeBindings public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) => Target.ResolvePhysicsHost(serverGuid); + + private sealed class Binding : IDisposable + { + private DeferredLiveEntityMotionRuntimeBindings? _owner; + private readonly ILiveEntityMotionRuntimeBindings _expected; + + public Binding( + DeferredLiveEntityMotionRuntimeBindings owner, + ILiveEntityMotionRuntimeBindings expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } diff --git a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs index 1088a671..1eb97f04 100644 --- a/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs +++ b/src/AcDream.App/Rendering/DevToolsRuntimeSources.cs @@ -103,6 +103,12 @@ internal sealed class DeferredCanonicalWorldEntityCountSource _target = target; } + public IDisposable BindOwned(GpuWorldState target) + { + Bind(target); + return new Binding(this, target); + } + public void Unbind(GpuWorldState target) { ArgumentNullException.ThrowIfNull(target); @@ -115,6 +121,23 @@ internal sealed class DeferredCanonicalWorldEntityCountSource _deactivated = true; _target = null; } + + private sealed class Binding : IDisposable + { + private DeferredCanonicalWorldEntityCountSource? _owner; + private readonly GpuWorldState _target; + + public Binding( + DeferredCanonicalWorldEntityCountSource owner, + GpuWorldState target) + { + _owner = owner; + _target = target; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_target); + } } internal interface IDevToolsPlayerModeCommands diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 76471bcf..b33a49a8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -20,7 +20,8 @@ public sealed class GameWindow : IGameWindowContentEffectsAudioPublication, IGameWindowSettingsDevToolsPublication, IGameWindowWorldRenderPublication, - IGameWindowInteractionRetainedUiPublication + IGameWindowInteractionRetainedUiPublication, + IGameWindowLivePresentationPublication { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -97,6 +98,7 @@ public sealed class GameWindow : _debugVmRenderFacts = new(); private AcDream.App.Rendering.RenderFrameDiagnosticsController? _renderFrameDiagnostics; + private LivePresentationRuntimeBindings? _livePresentationBindings; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; @@ -924,6 +926,76 @@ public sealed class GameWindow : } } + void IGameWindowLivePresentationPublication.PublishLivePresentation( + LivePresentationResult result) + { + ArgumentNullException.ThrowIfNull(result); + if (_liveEntities is not null + || _wbEntitySpawnAdapter is not null + || _entityScriptActivator is not null + || _staticAnimationScheduler is not null + || _wbDrawDispatcher is not null + || _retailSelectionScene is not null + || _worldSelectionQuery is not null + || _selectionInteractions is not null + || _retainedUiGameplayBinding is not null + || _equippedChildRenderer is not null + || _entityEffects is not null + || _liveEntityPresentation is not null + || _remoteTeleportController is not null + || _projectileController is not null + || _liveEntityProjectionWithdrawal is not null + || _liveEntityLights is not null + || _liveAnimationScheduler is not null + || _animationPresenter is not null + || _paperdollViewportRenderer is not null + || _paperdollFramePresenter is not null + || _envCellRenderer is not null + || _envCellFrustum is not null + || _landblockPresentationPipeline is not null + || _portalDepthMask is not null + || _clipFrame is not null + || _skyRenderer is not null + || _particleRenderer is not null + || _renderFrameDiagnostics is not null + || _livePresentationBindings is not null) + { + throw new InvalidOperationException( + "The GameWindow composition shell already owns live presentation state."); + } + + _wbEntitySpawnAdapter = result.EntitySpawnAdapter; + _entityScriptActivator = result.EntityScriptActivator; + _staticAnimationScheduler = result.StaticAnimationScheduler; + _worldState = result.WorldState; + _liveEntities = result.LiveEntities; + _projectileController = result.ProjectileController; + _liveEntityProjectionWithdrawal = result.ProjectionWithdrawal; + _liveEntityLights = result.Lights; + _liveAnimationScheduler = result.AnimationScheduler; + _animationPresenter = result.AnimationPresenter; + _equippedChildRenderer = result.EquippedChildren; + _entityEffects = result.EntityEffects; + _liveEntityPresentation = result.Presentation; + _remoteTeleportController = result.RemoteTeleport; + _wbDrawDispatcher = result.DrawDispatcher; + _retailSelectionScene = result.SelectionScene; + _worldSelectionQuery = result.SelectionQuery; + _selectionInteractions = result.SelectionInteractions; + _retainedUiGameplayBinding = result.RetainedGameplay; + _paperdollViewportRenderer = result.PaperdollRenderer; + _paperdollFramePresenter = result.PaperdollPresenter; + _envCellFrustum = result.EnvCellFrustum; + _envCellRenderer = result.EnvCellRenderer; + _landblockPresentationPipeline = result.LandblockPipeline; + _clipFrame = result.ClipFrame; + _portalDepthMask = result.PortalDepthMask; + _skyRenderer = result.SkyRenderer; + _particleRenderer = result.ParticleRenderer; + _renderFrameDiagnostics = result.FrameDiagnostics; + _livePresentationBindings = result.RuntimeBindings; + } + private static void PublishCompositionOwner( ref T? destination, T value, @@ -1124,532 +1196,52 @@ public sealed class GameWindow : settingsDevTools, worldRender); - AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null; - // Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag - // and rebuild _worldState so it threads the adapter in. _worldState starts - // as an unadorned GpuWorldState (field initializer); here we replace it with - // one that carries the adapter so AddLandblock/RemoveLandblock notify WB. - // Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned - // per-instance content under the same flag. - // N.5 mandatory path: spawn adapters + dispatcher always construct. - // _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are - // all guaranteed non-null here (startup throws above if any are missing). - var liveEntityComponentLifecycle = - new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle(); - AcDream.App.Physics.LiveEntityMotionRuntimeController? - liveEntityMotionRuntime = null; - { - var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!); - // Sequencer factory: look up Setup + MotionTable from dats and build - // an AnimationSequencer. Falls back to a no-op sequencer when the - // entity has no motion table (static props, etc.). Uses _animLoader - // which is initialised earlier in OnLoad; it is non-null here. - var capturedDats = _dats; - var capturedAnimLoader = _animLoader; - AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e) - { - if (capturedDats is not null && capturedAnimLoader is not null) - { - var setup = capturedDats.Get(e.SourceGfxObjOrSetupId); - if (setup is not null) - { - uint mtableId = (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = capturedDats.Get(mtableId); - if (mtable is not null) - return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader); - } - // Setup exists but no motion table — no-op sequencer. - return new AcDream.Core.Physics.AnimationSequencer( - setup, - new DatReaderWriter.DBObjs.MotionTable(), - capturedAnimLoader); - } - } - // Complete fallback: empty setup + empty motion table + null loader. - return new AcDream.Core.Physics.AnimationSequencer( - new DatReaderWriter.DBObjs.Setup(), - new DatReaderWriter.DBObjs.MotionTable(), - new NullAnimLoader()); - } - var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( - _textureCache!, SequencerFactory, _wbMeshAdapter!); - _wbEntitySpawnAdapter = wbEntitySpawnAdapter; - - // Phase C.1.5a/b: construct EntityScriptActivator so static entities - // (server-spawned AND dat-hydrated) fire Setup.DefaultScript through - // the PhysicsScriptRunner on enter-world. C.1.5b adds per-part - // transforms from the entity's exact current MeshRefs so - // multi-emitter scripts distribute across mesh parts (closes #56). - // Animated frames replace this snapshot through EntityEffectPoseRegistry. - // _scriptRunner and - // _particleSink are initialised earlier in OnLoad (line ~1083); both - // are non-null here. The resolver lambda captures _dats and swallows - // dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale. - AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null; - var staticLiveRootCommitter = new StaticLiveRootCommitter( - _liveEntityRuntimeSlot, - _physicsEngine.ShadowObjects, - _liveWorldOrigin, - _effectPoses); - var staticAnimationResidency = new LiveStaticAnimationResidency( - _liveEntityRuntimeSlot); - _staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler( - capturedAnimLoader!, - _animationHookFrames!.Capture, - _effectPoses.Publish, - staticAnimationResidency.IsResident, - (entity, body) => - _ = staticLiveRootCommitter.Commit(entity, body), - staticAnimationResidency.ProjectionVersion); - AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e) - { - try - { - DatReaderWriter.DBObjs.Setup? setup = - capturedDats?.Get( - e.SourceGfxObjOrSetupId); - if (setup is null) return null; - uint scriptId = setup.DefaultScript.DataId; - if (e.IndexedPartTransforms.Count == 0) - { - var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build( - setup, - e); - e.SetIndexedPartPoses(indexed.Poses, indexed.Available); - } - IReadOnlyList parts = e.IndexedPartTransforms; - IReadOnlyList availability = e.IndexedPartAvailable; - var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup); - bool usesStaticAnimationWorkset = e.ServerGuid == 0 - || (_liveEntities?.TryGetRecord( - e.ServerGuid, - out LiveEntityRecord liveRecord) == true - && (liveRecord.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0); - return new AcDream.App.Rendering.Vfx.ScriptActivationInfo( - scriptId, - parts, - profile, - availability, - setup, - (uint)setup.DefaultAnimation, - usesStaticAnimationWorkset); - } - catch - { - return null; - } - } - var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator( - _scriptRunner!, - _particleSink!, - _effectPoses, - ResolveActivation, - (ownerId, entity, profile) => - entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile), - ownerId => - { - entityEffects?.OnDatStaticEntityRemoved(ownerId); - _lightingSink?.UnregisterOwner(ownerId); - _translucencyFades.ClearEntity(ownerId); - }, - (entity, info) => _staticAnimationScheduler.Register(entity, info), - ownerId => _staticAnimationScheduler.Unregister(ownerId), - (entity, info) => _staticAnimationScheduler.Rebind(entity, info)); - _entityScriptActivator = entityScriptActivator; - - // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock - // so Tier 1 cache entries get swept on LB demote (Near to Far) and unload. - // Per spec §5.3 W3b. The callback receives the canonical landblock id - // matching the LandblockHint stored at Populate time. - _worldState = new AcDream.App.Streaming.GpuWorldState( - wbSpawnAdapter, - onLandblockUnloaded: _classificationCache.InvalidateLandblock, - entityScriptActivator: entityScriptActivator); - _devToolsComposition?.LateBindings.WorldEntities.Bind(_worldState); - _liveEntities = new AcDream.App.World.LiveEntityRuntime( - _worldState, - new AcDream.App.World.CompositeLiveEntityResourceLifecycle( - new( - entity => wbEntitySpawnAdapter.OnCreate(entity), - entity => wbEntitySpawnAdapter.OnRemove(entity)), - new( - entityScriptActivator.OnCreate, - entityScriptActivator.OnRemove)), - liveEntityComponentLifecycle); - _liveEntityRuntimeSlot.Bind(_liveEntities); - liveEntityMotionRuntime = - new AcDream.App.Physics.LiveEntityMotionRuntimeController( - _liveEntities, + LivePresentationResult livePresentation = + new LivePresentationCompositionPhase( + new LivePresentationDependencies( + _options, + platform.Graphics, + _window!, + _datLock, + _runtimeSettings, + _hostQuiescence, + _physicsEngine, _physicsDataCache, - () => _selectionInteractions, - _selection, - _liveWorldOrigin); - _liveEntityMotionBindings.Bind(liveEntityMotionRuntime); - _liveEntities.ProjectionVisibilityChanged += (record, visible) => - { - if (record.WorldEntity is { } entity) - wbEntitySpawnAdapter.SetPresentationResident(entity, visible); - }; - _liveEntities.ProjectionVisibilityChanged += (record, visible) => - { - if (record.WorldEntity is { } entity) - _particleSink!.SetEntityPresentationVisible(entity.Id, visible); - }; - _projectileController = new AcDream.App.Physics.ProjectileController( - _liveEntities, - _physicsEngine, - new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock), - new EntityRootPosePublisher(_effectPoses), - _liveWorldOrigin) - { - DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), - }; - _liveEntityProjectionWithdrawal = - new AcDream.App.World.LiveEntityProjectionWithdrawalController( - _liveEntities, - _projectileController, _worldGameState, _worldEvents, - _physicsEngine.ShadowObjects, - _effectPoses, - _localPlayerShadow); - _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( - _liveEntities, - _effectPoses, - _lightingSink!, - setupId => _dats!.Get(setupId)); - - var ordinaryPhysicsUpdater = - new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater( - _physicsEngine, - _liveEntityMotionBindings.GetSetupCylinder); - _liveAnimationScheduler = new LiveEntityAnimationScheduler( - _liveEntities, - _localPlayerIdentity, - _remotePhysicsUpdater, - ordinaryPhysicsUpdater, - _projectileController, - new EntityRootPosePublisher(_effectPoses), - new AnimationHookCaptureSink(_animationHookFrames!)); - _animationPresenter = new LiveEntityAnimationPresenter( - _liveEntities, - _staticAnimationScheduler, - _effectPoses, - new LiveAnimationPresentationContext( - _liveEntities, - _localPlayerIdentity, - _playerControllerSlot), - _animationDiagnostics, - _options.HidePartIndex); - - parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance(); - _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( - _dats!, - _datLock, - Objects, - _liveEntities, - _effectPoses, - parentAcceptance.TryAccept, - (childRecord, positionVersion, projectionVersion) => - _liveEntityProjectionWithdrawal!.WithdrawExact( - childRecord, - positionVersion, - projectionVersion, - _playerServerGuid)); - - var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver( - id => _dats!.Get(id)); - entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController( - _liveEntities, - _scriptRunner!, - tableResolver, - _effectPoses, - (parentLocalId, partIndex) => - _equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex), - childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId), - ownerId => _entitySoundTables?.Remove(ownerId), - (ownerId, soundTableDid) => - { - _entitySoundTables?.Remove(ownerId); - if (soundTableDid is { } did) - _entitySoundTables?.Set(ownerId, did); - }); - _entityEffects = entityEffects; - _entityEffectAdvance.Bind(entityEffects); - entityEffects.DiagnosticSink = message => - Console.Error.WriteLine($"vfx: {message}"); - var partArrayLifecycle = - new AcDream.App.Rendering.LiveEntityPartArrayLifecycle( - _animatedEntities); - _liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController( - _liveEntities, - _physicsEngine.ShadowObjects, - entityEffects.PlayTypedFromHiddenTransition, - _equippedChildRenderer.SetDirectChildrenNoDraw, - _liveEntityMotionBindings.ClearTargetForHiddenEntity, - _liveWorldOrigin.GetCenter, - handlePartArrayEnterWorld: partArrayLifecycle.HandleEnterWorld); - var remoteShadowPlacement = - new AcDream.App.Physics.RemoteShadowPlacementSynchronizer( - _remotePhysicsUpdater, - _liveWorldOrigin); - var remoteTeleportPresentation = - new AcDream.App.Physics.RemoteTeleportPlacementPresentation( - _liveEntityPresentation); - _remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController( - _physicsEngine, - _liveEntities, - _liveEntityMotionBindings.GetSetupCylinder, - _liveWorldOrigin.CellLocalForSeed, - remoteShadowPlacement.Sync, - remoteTeleportPresentation.Complete, - remoteTeleportPresentation.Begin); - _equippedChildRenderer.ProjectionPoseReady += guid => - _liveEntityLights.OnAttachedPoseReady(guid); - _hookRegistrations!.Register(entityEffects); - - _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( - _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!, - _classificationCache, _translucencyFades, - _retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene( - new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache( - _dats!, _datLock)), - _retailAlphaQueue); - _worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery( - _liveEntities, - Objects, - _retailSelectionScene, - () => _localPlayerIdentity.ServerGuid, - interactionUi.LateBindings.SelectionCamera.Snapshot, - () => new System.Numerics.Vector2( - _pointerPosition.X, - _pointerPosition.Y), - () => _playerControllerSlot.Controller is { } player - ? new AcDream.App.Interaction.PlayerInteractionPose( - player.CellId, - player.Position) - : null, - _liveEntityMotionBindings.GetSetupCylinder, - setupId => - { - lock (_datLock) - { - if (!contentEffectsAudio.Dats.TryGet( - setupId, - out var setup) - || setup.SelectionSphere is not { } sphere) - { - return null; - } - return (sphere.Origin, sphere.Radius); - } - }); - var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider( - Objects, - () => _liveEntities.WorldEntities, - () => _liveEntities.Snapshots, - playerGuid: () => _localPlayerIdentity.ServerGuid, - playerYawRadians: () => _playerControllerSlot.Controller?.Yaw ?? 0f, - playerCellId: () => _playerControllerSlot.Controller?.CellId ?? 0u, - selectedGuid: () => _selection.SelectedObjectId, - coordinatesOnRadar: () => - _runtimeSettings.Gameplay.CoordinatesOnRadar, - uiLocked: () => _runtimeSettings.Gameplay.LockUI, - playerEntities: () => _liveEntities.MaterializedWorldEntities, - spatialQuery: () => _worldState); - interactionUi.LateBindings.AdoptLateOwnerBinding( - "radar snapshot", - interactionUi.LateBindings.Radar.Bind(radarSnapshotProvider)); - if (_itemInteractionController is { } itemInteractions) - { - _selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController( _selection, - _worldSelectionQuery, - itemInteractions, - new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( - () => interactionUi.LateBindings.Session.CurrentSession), - new AcDream.App.Interaction.PlayerInteractionMovementSink( - () => _playerControllerSlot.Controller, - _playerApproachCompletions), - compositionToast, - _playerApproachCompletions); - interactionUi.LateBindings.AdoptLateOwnerBinding( - "world selection", - interactionUi.LateBindings.Selection.Bind( - _worldSelectionQuery, - _selectionInteractions)); - } - if (_uiHost is { } retainedHost - && _selectionInteractions is { } selectionInteractions) - { - _retainedUiGameplayBinding = - AcDream.App.Input.RetainedUiGameplayBinding.Create( - retainedHost.Root, - (item, x, y) => - selectionInteractions.PlaceDraggedItem(item, x, y), - _hostQuiescence); - _retainedUiGameplayBinding.Attach(); - } - // A.5 T22.5: apply A2C gate from quality preset. - _wbDrawDispatcher.AlphaToCoverage = - _runtimeSettings.ResolvedQuality.AlphaToCoverage; - - // Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to - // the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only - // blits the texture; the pre-UI hook drives the 3-D pass (see OnRender). - if (_retailUiRuntime?.PaperdollViewportWidget is { } paperdollViewport - && _retailUiRuntime.InventoryFrame is { } paperdollInventoryFrame - && _sceneLightingUbo is not null) - { - _paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer( - _gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!); - paperdollViewport.Renderer = _paperdollViewportRenderer; - _paperdollFramePresenter = - new AcDream.App.Rendering.PaperdollFramePresenter( - _paperdollViewportRenderer, - new AcDream.App.Rendering.RetailPaperdollFrameView( - paperdollViewport, - new AcDream.App.Rendering.PaperdollInventoryVisibility( - paperdollInventoryFrame)), - new AcDream.App.Rendering.RetailPaperdollDollFactory( - new AcDream.App.Rendering.LivePaperdollEntityLookup( - _liveEntities), - _localPlayerIdentity, - new AcDream.App.Rendering.RetailPaperdollPoseApplicator( - _dats!, - _animLoader!, - _datLock))); - } - - // Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag}) - // with the dispatcher — both consume the same global mesh buffer (VAO/IBO) - // from ObjectMeshManager.GlobalBuffer. - _envCellFrustum = new AcDream.App.Rendering.Wb.WbFrustum(); - _envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer( - _gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum); - _envCellRenderer.Initialize(_meshShader!); - var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher( - publishTerrain: (landblockId, meshData, origin) => - _terrain!.AddLandblockWithMesh(landblockId, meshData, origin), - removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId), - cellVisibility: _cellVisibility, - worldState: _worldState, - commitEnvCells: _envCellRenderer.CommitLandblock, - prepareEnvCells: build => - AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule( - build, - _wbMeshAdapter.MeshManager!), - removeEnvCells: _envCellRenderer.RemoveLandblock); - var landblockPhysicsPublisher = - new AcDream.App.Streaming.LandblockPhysicsPublisher( - _physicsEngine, - _heightTable!); - var landblockStaticPresentationPublisher = - new AcDream.App.Streaming.LandblockStaticPresentationPublisher( - _lightingSink!, - _translucencyFades, - _worldGameState, - _worldEvents); - var landblockRetirementOwner = - new AcDream.App.Streaming.LandblockPresentationRetirementOwner( - landblockRenderPublisher, - landblockPhysicsPublisher, - landblockStaticPresentationPublisher, - _lightingSink!, - _translucencyFades); - _landblockPresentationPipeline = - new AcDream.App.Streaming.LandblockPresentationPipeline( - landblockRenderPublisher, - landblockPhysicsPublisher, - landblockStaticPresentationPublisher, - _worldState, - landblockRetirementOwner, - onLandblockLoaded: loadedLandblockId => - _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), - ensureEnvCellMeshes: - landblockRenderPublisher.PrepareAfterRenderPins); - - _clipFrame ??= ClipFrame.NoClip(); - // T1: invisible portal depth writes (seal/punch) — retail - // DrawPortalPolyInternal (Ghidra 0x0059bc90). - _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); - _portalTunnelFallback.AcquirePrepared( - () => AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired( - _gl, - _dats!, - _animLoader!, - _hookRouter, - _wbDrawDispatcher, - _sceneLightingUbo!, - _wbMeshAdapter!), - static portalTunnel => portalTunnel.PrepareResources()); - // Publish the presentation before touching the mesh-reference - // backend. Partial acquisition remains reachable by staged - // shutdown instead of becoming constructor-local leaked state. - } - - // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) - // with depth writes off + far plane 1e6 so celestial meshes - // never clip. Shares the TextureCache with the static pipeline. - var skyShader = _renderResourceLifetime.AcquireSkyShader( - () => new Shader( - _gl, - Path.Combine(shadersDir, "sky.vert"), - Path.Combine(shadersDir, "sky.frag"))); - _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( - _gl, - contentEffectsAudio.Dats, - skyShader, - _textureCache!, - _samplerCache!); - - // Phase G.1 particle renderer — renders rain / snow / spell auras - // spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs - // retain their authored mesh; Always2D degrade modes use billboards. - // Weather uses AttachLocal emitters so its volume follows the player. - _particleRenderer = new ParticleRenderer( - _gl, - shadersDir, - contentEffectsAudio.ParticleSystem, - _textureCache, - contentEffectsAudio.Dats, - _wbMeshAdapter, - _retailAlphaQueue); - - AcDream.App.Rendering.IRenderFrameResourceDiagnosticsSource? resourceDiagnostics = - _options.UiProbeDump - ? new AcDream.App.Rendering.RuntimeRenderFrameResourceDiagnosticsSource( - _particleSystem, - _particleSink, - _wbDrawDispatcher, - _envCellRenderer, - _particleRenderer, - _uiHost?.TextRenderer, - _portalDepthMask, - _clipFrame, - _terrain, - _sceneLightingUbo, - _wbMeshAdapter, - _textureCache) - : null; - _renderFrameDiagnostics = - new AcDream.App.Rendering.RenderFrameDiagnosticsController( - new AcDream.App.Rendering.RuntimeRenderFrameTitleFactsSource( - _worldState, + Objects, + _liveEntityRuntimeSlot, + _liveEntityMotionBindings, + _entityEffectAdvance, + _effectPoses, + _remotePhysicsUpdater, + _localPlayerShadow, _animatedEntities, - WorldTime), - new AcDream.App.Rendering.SilkRenderFrameTitleSink(_window!), - _renderDiagnosticLog, - _options.UiProbeDump, - resourceDiagnostics); - _devToolsComposition?.LateBindings.FrameDiagnostics.Bind( - _renderFrameDiagnostics); - interactionUi.LateBindings.AdoptLateOwnerBinding( - "render-frame diagnostics", - _uiFrameDiagnostics.BindOwned(_renderFrameDiagnostics)); + _animationDiagnostics, + _classificationCache, + _translucencyFades, + _retailAlphaQueue, + _cellVisibility, + _liveWorldOrigin, + _localPlayerIdentity, + _playerControllerSlot, + _pointerPosition, + _playerApproachCompletions, + _renderResourceLifetime, + _portalTunnelFallback, + _hookRouter, + _renderDiagnosticLog, + WorldTime, + settingsDevTools.DevTools?.LateBindings.WorldEntities, + settingsDevTools.DevTools?.LateBindings.FrameDiagnostics, + _uiFrameDiagnostics, + compositionToast), + this).Compose( + hostInputCamera, + contentEffectsAudio, + worldRender, + interactionUi); // Apply radii from the same immutable quality snapshot used for the // window's MSAA, terrain anisotropy, and dispatcher A2C setup. @@ -1792,7 +1384,7 @@ public sealed class GameWindow : _liveSessionController); var liveEntityTeardown = new AcDream.App.World.LiveEntityRuntimeTeardownController( - _liveEntities, + livePresentation.LiveEntities, _liveEntityPresentation!, _entityEffects!, _remoteTeleportController!, @@ -1807,23 +1399,23 @@ public sealed class GameWindow : _liveEntityLights!, _classificationCache, _localPlayerIdentity); - liveEntityComponentLifecycle.Bind(liveEntityTeardown); + livePresentation.ComponentLifecycle.Bind(liveEntityTeardown); var liveEntityDeletion = new AcDream.App.World.LiveEntityDeletionController( - _liveEntities, + livePresentation.LiveEntities, Objects, liveEntityTeardown, _localPlayerIdentity, _options.DumpLiveSpawns ? Console.WriteLine : null); _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( - _liveEntities, + livePresentation.LiveEntities, Objects, _datLock, projectionMaterializer, new AcDream.App.World.LiveEntityRelationshipProjection( - _equippedChildRenderer), + livePresentation.EquippedChildren), new AcDream.App.World.LiveEntityReadyPublisher( - _liveEntities, + livePresentation.LiveEntities, _entityEffects!, _liveEntityPresentation!), originCoordinator, @@ -1832,9 +1424,12 @@ public sealed class GameWindow : _localPlayerIdentity, liveEntityDeletion, _options.DumpLiveSpawns ? Console.WriteLine : null); + livePresentation.RuntimeBindings.Adopt( + "landblock-loaded hydration", + livePresentation.LandblockLoaded.Bind(_liveEntityHydration)); _liveEntityNetworkUpdates = new AcDream.App.Physics.LiveEntityNetworkUpdateController( - _liveEntities, + livePresentation.LiveEntities, Objects, _liveEntityHydration, _entityEffects!, @@ -1849,7 +1444,7 @@ public sealed class GameWindow : _remoteMovementObservations, _remotePhysicsUpdater, _remoteInboundMotion, - liveEntityMotionRuntime!, + livePresentation.MotionRuntime, _physicsEngine, _dats!, _animLoader!, @@ -1865,7 +1460,7 @@ public sealed class GameWindow : localPhysicsTimestamps.Publish, _movementTruthDiagnostics); _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, liveEntityDeletion); _liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController( @@ -1874,9 +1469,10 @@ public sealed class GameWindow : _liveEntityNetworkUpdates, _localPlayerTeleportSink, _entityEffects!); - parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection); + livePresentation.ParentAcceptance.Bind( + _liveEntityHydration.TryAcceptParentForProjection); networkUpdateBridge.Bind(_liveEntityNetworkUpdates); - _equippedChildRenderer.EntityReady += candidate => + livePresentation.EquippedChildren.EntityReady += candidate => _liveEntityHydration.OnEntityReady(candidate); _liveEntityHydration.AppearanceApplied += guid => { @@ -1940,7 +1536,7 @@ public sealed class GameWindow : _streamingController!, new AcDream.App.Streaming.LiveProjectionRescueRebucketter( _worldState, - _liveEntities)); + livePresentation.LiveEntities)); var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController( _translucencyFades, _animationHookFrames!, @@ -1961,7 +1557,7 @@ public sealed class GameWindow : _liveEntityLights!); _localPlayerAnimation = new AcDream.App.Input.LocalPlayerAnimationController( - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _animatedEntities, _animationPresenter, @@ -1969,14 +1565,14 @@ public sealed class GameWindow : _localPlayerShadowSynchronizer = new AcDream.App.Physics.LocalPlayerShadowSynchronizer( _physicsEngine, - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _liveWorldOrigin, _localPlayerShadow); var localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( new AcDream.App.Input.LiveLocalPlayerProjectionRuntime( - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _liveWorldOrigin, _localPlayerShadowSynchronizer)); @@ -1988,7 +1584,7 @@ public sealed class GameWindow : _chaseCameraInput, _movementInput, _inputCapture, - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _playerHostSlot, localPlayerProjection, @@ -2002,11 +1598,11 @@ public sealed class GameWindow : _inboundEntityEvents, localPlayerFrame, _selectionInteractions, - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _liveWorldOrigin, _liveAnimationScheduler, - _staticAnimationScheduler, + livePresentation.StaticAnimationScheduler, _animationPresenter, _animatedEntities, _equippedChildRenderer!, @@ -2018,7 +1614,7 @@ public sealed class GameWindow : _chaseCameraInput, _cameraController!, _physicsEngine, - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _liveWorldOrigin, _liveEntityMotionBindings, @@ -2040,7 +1636,7 @@ public sealed class GameWindow : _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( new AcDream.App.Input.LivePlayerModeAutoEntryContext( _liveSessionController, - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _worldReveal ?? throw new InvalidOperationException( @@ -2051,7 +1647,7 @@ public sealed class GameWindow : _localPlayerTeleport = _portalTunnelFallback.Transfer( portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController( new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority( - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity), _gameplayInputFrame, _playerModeController, @@ -2063,7 +1659,7 @@ public sealed class GameWindow : _worldReveal, new AcDream.App.Streaming.LocalPlayerTeleportPlacement( _physicsEngine, - _liveEntities, + livePresentation.LiveEntities, _localPlayerIdentity, _playerControllerSlot, _playerHostSlot, @@ -2323,7 +1919,7 @@ public sealed class GameWindow : _worldSelectionQuery!)); var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( new AcDream.App.Update.LiveEntityTeardownFramePhase( - _liveEntities), + livePresentation.LiveEntities), new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), _updateFrameClock, new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!), @@ -3039,6 +2635,15 @@ public sealed class GameWindow : ]), new ResourceShutdownStage("effect dispatch edges", [ + new("live-presentation bindings", () => + { + LivePresentationRuntimeBindings? bindings = + _livePresentationBindings; + if (bindings is null) + return; + bindings.Dispose(); + _livePresentationBindings = null; + }), new("entity-effect advance source", () => { if (_entityEffects is { } effects) @@ -3232,15 +2837,4 @@ public sealed class GameWindow : _window = null; } - /// - /// Fallback for the - /// sequencer - /// factory when neither _dats nor the entity's setup is available. - /// Returns null for all animation lookups so the sequencer silently has - /// no data (same behaviour as a new empty Setup). - /// - private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader - { - public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; - } } diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs index a838ccc8..1865152f 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectAdvanceSource.cs @@ -33,6 +33,12 @@ internal sealed class DeferredEntityEffectAdvanceSource : IEntityEffectAdvanceSo } } + public IDisposable BindOwned(IEntityEffectAdvanceSource target) + { + Bind(target); + return new Binding(this, target); + } + public void Unbind(IEntityEffectAdvanceSource target) { ArgumentNullException.ThrowIfNull(target); @@ -60,4 +66,21 @@ internal sealed class DeferredEntityEffectAdvanceSource : IEntityEffectAdvanceSo || _target?.CanAdvanceOwner(ownerLocalId) != false; } } + + private sealed class Binding : IDisposable + { + private DeferredEntityEffectAdvanceSource? _owner; + private readonly IEntityEffectAdvanceSource _target; + + public Binding( + DeferredEntityEffectAdvanceSource owner, + IEntityEffectAdvanceSource target) + { + _owner = owner; + _target = target; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_target); + } } diff --git a/src/AcDream.App/World/DeferredLiveEntityLandblockLoadedSink.cs b/src/AcDream.App/World/DeferredLiveEntityLandblockLoadedSink.cs new file mode 100644 index 00000000..b6408072 --- /dev/null +++ b/src/AcDream.App/World/DeferredLiveEntityLandblockLoadedSink.cs @@ -0,0 +1,66 @@ +namespace AcDream.App.World; + +internal interface ILiveEntityLandblockLoadedSink +{ + void OnLandblockLoaded(uint landblockId); +} + +/// +/// Phase-6 bridge for the Phase-7 hydration owner. The presentation pipeline +/// can publish a loaded landblock without capturing a future nullable owner. +/// +internal sealed class DeferredLiveEntityLandblockLoadedSink + : ILiveEntityLandblockLoadedSink +{ + private ILiveEntityLandblockLoadedSink? _target; + private bool _deactivated; + + public void OnLandblockLoaded(uint landblockId) + { + if (!_deactivated) + _target?.OnLandblockLoaded(landblockId); + } + + public IDisposable Bind(ILiveEntityLandblockLoadedSink target) + { + ArgumentNullException.ThrowIfNull(target); + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null) + { + throw new InvalidOperationException( + "Live-entity landblock hydration is already bound."); + } + + _target = target; + return new Binding(this, target); + } + + public void Deactivate() + { + _deactivated = true; + _target = null; + } + + private void Unbind(ILiveEntityLandblockLoadedSink expected) + { + if (ReferenceEquals(_target, expected)) + _target = null; + } + + private sealed class Binding : IDisposable + { + private DeferredLiveEntityLandblockLoadedSink? _owner; + private readonly ILiveEntityLandblockLoadedSink _expected; + + public Binding( + DeferredLiveEntityLandblockLoadedSink owner, + ILiveEntityLandblockLoadedSink expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } +} diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index c12cc06e..4210b436 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -137,7 +137,7 @@ internal interface ILiveEntityWorldOriginCoordinator /// Retail anchors: SmartBox::HandleCreateObject @ 0x00454C80 and /// ACCObjectMaint::CreateObject @ 0x00558870. /// -internal sealed class LiveEntityHydrationController +internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoadedSink { private readonly LiveEntityRuntime _runtime; private readonly ClientObjectTable _objects; diff --git a/src/AcDream.App/World/LiveEntityRuntimeSlot.cs b/src/AcDream.App/World/LiveEntityRuntimeSlot.cs index e9c677d3..857ca2a1 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeSlot.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeSlot.cs @@ -21,4 +21,31 @@ internal sealed class LiveEntityRuntimeSlot : ILiveEntityRuntimeSource throw new InvalidOperationException("The live entity runtime is already bound."); Current = runtime; } + + public IDisposable BindOwned(LiveEntityRuntime runtime) + { + Bind(runtime); + return new Binding(this, runtime); + } + + private void Unbind(LiveEntityRuntime expected) + { + if (ReferenceEquals(Current, expected)) + Current = null; + } + + private sealed class Binding : IDisposable + { + private LiveEntityRuntimeSlot? _owner; + private readonly LiveEntityRuntime _expected; + + public Binding(LiveEntityRuntimeSlot owner, LiveEntityRuntime expected) + { + _owner = owner; + _expected = expected; + } + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unbind(_expected); + } } diff --git a/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs b/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs index 3c1f08f6..69c3afa2 100644 --- a/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs +++ b/tests/AcDream.App.Tests/Composition/AnimationHookRegistrationSetTests.cs @@ -64,6 +64,36 @@ public sealed class AnimationHookRegistrationSetTests registrations.Register(new Sink("late"))); } + [Fact] + public void OwnedRegistrationReleasesExactlyAndRetriesItsOwnFailure() + { + int adds = 0; + int removes = 0; + bool fail = true; + var sink = new Sink("owned"); + var registrations = new AnimationHookRegistrationSet( + _ => adds++, + _ => + { + removes++; + if (fail) + throw new InvalidOperationException("retry"); + }); + + IDisposable binding = registrations.RegisterOwned(sink); + Assert.Throws(() => + registrations.RegisterOwned(sink)); + Assert.Throws(binding.Dispose); + fail = false; + binding.Dispose(); + binding.Dispose(); + registrations.Dispose(); + + Assert.Equal(1, adds); + Assert.Equal(2, removes); + Assert.True(registrations.IsCleanupComplete); + } + private sealed class Sink(string name) : IAnimationHookSink { public string Name { get; } = name; diff --git a/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs b/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs new file mode 100644 index 00000000..5e76a757 --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs @@ -0,0 +1,200 @@ +using AcDream.App.Composition; +using AcDream.App.Physics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Composition; + +public sealed class LivePresentationCompositionTests +{ + [Fact] + public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges() + { + var calls = new List(); + var bindings = new LivePresentationRuntimeBindings(); + var first = new RetryBinding("first", calls, failures: 0); + var second = new RetryBinding("second", calls, failures: 1); + bindings.Adopt("first", first); + bindings.Adopt("second", second); + + 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 CanonicalRuntimeSlotUsesExactOwnerBinding() + { + var slot = new LiveEntityRuntimeSlot(); + LiveEntityRuntime first = Runtime(); + LiveEntityRuntime second = Runtime(); + + IDisposable stale = slot.BindOwned(first); + Assert.Same(first, slot.Current); + Assert.Throws(() => slot.BindOwned(second)); + + stale.Dispose(); + IDisposable current = slot.BindOwned(second); + stale.Dispose(); + Assert.Same(second, slot.Current); + current.Dispose(); + Assert.Null(slot.Current); + } + + [Fact] + public void MotionBindingCannotBeClearedByAStaleOwner() + { + var source = new DeferredLiveEntityMotionRuntimeBindings(); + var first = new MotionRuntime(1f); + var second = new MotionRuntime(2f); + var entity = new WorldEntity + { + Id = 1u, + SourceGfxObjOrSetupId = 1u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + IDisposable stale = source.BindOwned(first); + Assert.Equal(1f, source.GetSetupCylinder(1u, entity).Radius); + Assert.Throws(() => source.BindOwned(second)); + + stale.Dispose(); + IDisposable current = source.BindOwned(second); + stale.Dispose(); + Assert.Equal(2f, source.GetSetupCylinder(1u, entity).Radius); + current.Dispose(); + Assert.Throws(() => + source.GetSetupCylinder(1u, entity)); + } + + [Fact] + public void LandblockLoadedBridgeIsInertUntilBoundAndDeactivationIsTerminal() + { + var source = new DeferredLiveEntityLandblockLoadedSink(); + var first = new LandblockSink(); + var second = new LandblockSink(); + + source.OnLandblockLoaded(1u); + IDisposable stale = source.Bind(first); + source.OnLandblockLoaded(2u); + Assert.Equal([2u], first.Landblocks); + Assert.Throws(() => source.Bind(second)); + + stale.Dispose(); + IDisposable current = source.Bind(second); + stale.Dispose(); + source.OnLandblockLoaded(3u); + Assert.Equal([3u], second.Landblocks); + + source.Deactivate(); + current.Dispose(); + source.OnLandblockLoaded(4u); + Assert.Equal([3u], second.Landblocks); + Assert.Throws(() => source.Bind(first)); + } + + [Fact] + public void GameWindowUsesLivePhaseAndContainsNoPhaseSixConstructionBody() + { + 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", + "LivePresentationComposition.cs")); + + Assert.Contains("new LivePresentationCompositionPhase(", window); + Assert.DoesNotContain("new AcDream.App.World.LiveEntityRuntime(", window); + Assert.DoesNotContain("new AcDream.App.Rendering.Wb.WbDrawDispatcher(", window); + Assert.DoesNotContain("new AcDream.App.Streaming.LandblockRenderPublisher(", window); + Assert.DoesNotContain("_portalTunnelFallback.AcquirePrepared(", window); + Assert.Contains("new LiveEntityRuntime(", phase); + Assert.Contains("new WbDrawDispatcher(", phase); + Assert.Contains("new LandblockRenderPublisher(", phase); + Assert.Contains("d.PortalTunnelFallback.AcquirePrepared(", phase); + } + + private static LiveEntityRuntime Runtime() => new( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(static _ => { }, static _ => { })); + + private sealed class MotionRuntime(float radius) + : ILiveEntityMotionRuntimeBindings + { + public (float Radius, float Height) GetSetupCylinder( + uint serverGuid, + WorldEntity entity) => (radius, 1f); + + public bool RouteServerMoveTo( + MovementManager movement, + uint cellId, + WorldSession.EntityMotionUpdate update) => false; + + public void StickToObjectFromWire( + IPhysicsObjHost? host, + uint targetGuid) + { + } + + public void ClearTargetForHiddenEntity(uint serverGuid) + { + } + + public IPhysicsObjHost? ResolvePhysicsHost( + uint serverGuid) => null; + } + + private sealed class LandblockSink : ILiveEntityLandblockLoadedSink + { + public List Landblocks { get; } = []; + public void OnLandblockLoaded(uint landblockId) => Landblocks.Add(landblockId); + } + + private sealed class RetryBinding( + string name, + List calls, + int failures) : IDisposable + { + private int _failures = failures; + + public void Dispose() + { + calls.Add(name); + if (_failures > 0) + { + _failures--; + throw new InvalidOperationException("retry"); + } + } + } + + 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/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index b370b12f..920fd070 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -93,7 +93,7 @@ public sealed class GameWindowRenderLeafCompositionTests foreach (string identifier in removed) Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); - Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); + Assert.Contains("new PaperdollFramePresenter(", LivePresentationSource()); string settingsComposition = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", @@ -193,13 +193,13 @@ public sealed class GameWindowRenderLeafCompositionTests [Fact] public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface() { - string source = GameWindowSource(); + string source = LivePresentationSource(); AssertAppearsInOrder( source, - "PaperdollViewportWidget is { } paperdollViewport", - "InventoryFrame is { } paperdollInventoryFrame", - "new AcDream.App.Rendering.PaperdollFramePresenter("); + "PaperdollViewportWidget is { } viewport", + "InventoryFrame is { } inventoryFrame", + "new PaperdollFramePresenter("); Assert.DoesNotContain("Paperdoll inventory frame is required.", source); } @@ -223,6 +223,13 @@ public sealed class GameWindowRenderLeafCompositionTests "Rendering", "GameWindow.cs")); + private static string LivePresentationSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); + private static string Source(string fileName) => File.ReadAllText(Path.Combine( FindRepoRoot(), "src", diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 2ff05b94..4585ac31 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -61,11 +61,9 @@ public sealed class GameWindowSlice8BoundaryTests "InteractionRetainedUiResult interactionUi =", "new InteractionRetainedUiCompositionPhase(", "this).Compose(", - "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", - "_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(", - "_retainedUiGameplayBinding =", - "AcDream.App.Input.RetainedUiGameplayBinding.Create(", - "_retainedUiGameplayBinding.Attach();", + "LivePresentationResult livePresentation =", + "new LivePresentationCompositionPhase(", + "this).Compose(", "var renderFrameOrchestrator =", "var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);", @@ -193,11 +191,11 @@ public sealed class GameWindowSlice8BoundaryTests "AcDream.App.Input.GameplayInputActionRouter.Create(")); Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();")); Assert.Equal(1, CountOccurrences( - load, - "AcDream.App.Input.RetainedUiGameplayBinding.Create(")); + LivePresentationSource(), + "RetainedUiGameplayBinding.Create(")); Assert.Equal(1, CountOccurrences( - load, - "_retainedUiGameplayBinding.Attach();")); + LivePresentationSource(), + "retainedGameplayLease.Resource.Attach();")); AssertAppearsInOrder( shutdown, "_liveCombatModeCommands.Deactivate", @@ -377,10 +375,9 @@ public sealed class GameWindowSlice8BoundaryTests source, "private ResourceShutdownTransaction CreateShutdownTransaction()", "private void OnFocusChanged(bool focused)"); - string dispose = Slice( - source, - "public void Dispose()", - "private sealed class NullAnimLoader"); + int disposeStart = source.IndexOf("public void Dispose()", StringComparison.Ordinal); + Assert.True(disposeStart >= 0); + string dispose = source[disposeStart..]; string[] stages = [ @@ -474,14 +471,18 @@ public sealed class GameWindowSlice8BoundaryTests "AcDream.App", "Composition", "WorldRenderComposition.cs")); + string livePhase = LivePresentationSource(); + AssertAppearsInOrder( + livePhase, + "d.PortalTunnelFallback.AcquirePrepared(", + "static tunnel => tunnel.PrepareResources());", + "d.RenderResourceLifetime.AcquireSkyShader(", + "() => new SkyRenderer("); AssertAppearsInOrder( load, "new WorldRenderCompositionPhase(", - "_portalTunnelFallback.AcquirePrepared(", - "static portalTunnel => portalTunnel.PrepareResources());", - "_renderResourceLifetime.AcquireSkyShader(", - "_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(", + "new LivePresentationCompositionPhase(", "_portalTunnelFallback.Transfer(", "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); AssertAppearsInOrder( @@ -566,6 +567,13 @@ public sealed class GameWindowSlice8BoundaryTests "Rendering", "GameWindow.cs")); + private static string LivePresentationSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); + private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index db902231..e5e49bec 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -241,6 +241,12 @@ public sealed class LandblockBuildOriginTests "AcDream.App", "Rendering", "GameWindow.cs")); + string livePresentationSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); string buildSource = File.ReadAllText(Path.Combine( root, "src", @@ -307,16 +313,16 @@ public sealed class LandblockBuildOriginTests gameWindowSource, StringComparison.Ordinal); Assert.Contains( - "new AcDream.App.Streaming.LandblockRenderPublisher(", - gameWindowSource, + "new LandblockRenderPublisher(", + livePresentationSource, StringComparison.Ordinal); Assert.Contains( - "new AcDream.App.Streaming.LandblockPhysicsPublisher(", - gameWindowSource, + "new LandblockPhysicsPublisher(", + livePresentationSource, StringComparison.Ordinal); Assert.Contains( - "new AcDream.App.Streaming.LandblockStaticPresentationPublisher(", - gameWindowSource, + "new LandblockStaticPresentationPublisher(", + livePresentationSource, StringComparison.Ordinal); Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal); Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal); diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 1c2f605f..fb1a1c7d 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -158,6 +158,12 @@ public sealed class GameWindowLiveEntityCompositionTests string source = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); + string livePresentation = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); Assert.DoesNotContain( "new LiveEntityAnimationRuntimeView(() =>", source, @@ -166,7 +172,7 @@ public sealed class GameWindowLiveEntityCompositionTests "new LiveEntityRemoteMotionRuntimeView(() =>", source, StringComparison.Ordinal); - Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source); + Assert.Contains("new DatProjectileSetupResolver", livePresentation); } [Fact] diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index d13290af..c0373787 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -337,6 +337,12 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Rendering", "GameWindow.cs")); + string livePresentation = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source); Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source); @@ -771,6 +777,12 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Rendering", "GameWindow.cs")); + string livePresentation = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Composition", + "LivePresentationComposition.cs")); Assert.DoesNotContain("PublishLocalPhysicsTimestamps", source, StringComparison.Ordinal); Assert.DoesNotContain("LoginWorldReady", source, @@ -783,9 +795,9 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.DoesNotContain("OnPlayScriptReceived", source, StringComparison.Ordinal); - Assert.Contains("_liveWorldOrigin.GetCenter", source, + Assert.Contains("d.WorldOrigin.GetCenter", livePresentation, StringComparison.Ordinal); - Assert.Contains("_liveWorldOrigin.CellLocalForSeed", source, + Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation, StringComparison.Ordinal); Assert.Contains("_liveEntitySessionEvents.CreateSink()", source, StringComparison.Ordinal);