diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index 88ddcbb2..7adf6bc0 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -22,7 +22,7 @@ retail-faithful gameplay | F2 — static projection journal | complete | Static and EnvCell-shell projections now append ordered deltas only after the final activation receipt and exact detach receipt. Same-landblock rehydrate reconciles retained/new/omitted identities; stale Far completions are inert. Seven focused lifecycle tests pass inside the 3,708-App / 8,192-solution Release gate. The scene remains unconstructed and non-drawing in production. | | F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. | | F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. | -| F5 | active | Continuous lifecycle-automation shadow comparison is the next unit; no production consumer has switched. | +| F5 | active — connected gate | Lifecycle automation now constructs the otherwise-absent non-drawing scene, drains ordered static/live deltas after post-network spatial reconciliation, compares the accepted current partition every 30 rendered frames and at every canonical checkpoint, retains only the first field-level mismatch, and publishes scene/journal/index/memory evidence in checkpoint JSON. Focused and complete App suites are green; capped/uncapped nine-stop and dense-Arwic physical gates remain. | | G0–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 4b130d8b..61c55cb9 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -374,6 +374,20 @@ internal sealed class FrameRootCompositionPhase && d.Options.AutomationArtifactDirectory is not null ? new CurrentRenderSceneOracle() : null; + if ((currentRenderSceneOracle is null) + != (live.RenderSceneShadow is null)) + { + throw new InvalidOperationException( + "Lifecycle automation must compose the current-path referee and shadow render scene together."); + } + RenderSceneShadowComparisonController? renderSceneShadowComparison = + currentRenderSceneOracle is not null + && live.RenderSceneShadow is not null + ? new RenderSceneShadowComparisonController( + live.RenderSceneShadow, + currentRenderSceneOracle, + message => d.Log("[UI-PROBE] " + message)) + : null; live.DrawDispatcher.SetCurrentRenderSceneObserver( currentRenderSceneOracle); live.SelectionScene.SetCurrentRenderSceneObserver( @@ -421,7 +435,8 @@ internal sealed class FrameRootCompositionPhase d.FrameProfiler, content.Dats, foundation.Residency, - currentRenderSceneOracle); + currentRenderSceneOracle, + renderSceneShadowComparison); lifecycleAutomation = new WorldLifecycleAutomationController( () => session.WorldReveal.Snapshot, @@ -463,6 +478,14 @@ internal sealed class FrameRootCompositionPhase renderFrameResources, settings.DevTools?.Presenter, renderWeatherFrame); + IRenderFramePostDiagnosticsPhase postDiagnostics = + renderSceneShadowComparison is not null + && lifecycleAutomation is not null + ? new SerialRenderFramePostDiagnosticsPhase( + renderSceneShadowComparison, + lifecycleAutomation) + : (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation + ?? NullRenderFramePostDiagnosticsPhase.Instance; var renderFrame = new RenderFrameOrchestrator( host.GpuFrameFlights, new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl), @@ -470,8 +493,7 @@ internal sealed class FrameRootCompositionPhase worldSceneRenderer, privatePresentation, live.FrameDiagnostics, - (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation - ?? NullRenderFramePostDiagnosticsPhase.Instance, + postDiagnostics, (IRenderFrameFailureRecovery?)settings.DevTools?.Presenter ?? NullRenderFrameFailureRecovery.Instance); Fault(FrameRootCompositionPoint.RenderRootCreated); diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 0d04b40d..af169341 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -6,6 +6,7 @@ using AcDream.App.Interaction; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Residency; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Vfx; @@ -80,6 +81,7 @@ internal sealed record LivePresentationResult( RetailStaticAnimatingObjectScheduler StaticAnimationScheduler, WorldGenerationAvailabilityState WorldAvailability, GpuWorldState WorldState, + RenderSceneShadowRuntime? RenderSceneShadow, LiveEntityRuntime LiveEntities, ProjectileController ProjectileController, LiveEntityProjectionWithdrawalController ProjectionWithdrawal, @@ -196,6 +198,20 @@ internal sealed class LivePresentationCompositionPhase try { + CompositionAcquisitionScope.CompositionAcquisitionLease< + RenderSceneShadowRuntime>? renderSceneShadowLease = null; + if (interaction.RetainedUi?.Screenshots is not null + && d.Options.AutomationArtifactDirectory is not null) + { + renderSceneShadowLease = scope.Acquire( + "shadow render scene", + () => new RenderSceneShadowRuntime( + RenderSceneGeneration.FromRaw(1)), + static value => value.Dispose()); + } + RenderSceneShadowRuntime? renderSceneShadow = + renderSceneShadowLease?.Resource; + var componentLifecycle = new DeferredLiveEntityRuntimeComponentLifecycle(); var wbSpawnAdapter = new LandblockSpawnAdapter(foundation.MeshAdapter); @@ -333,20 +349,33 @@ internal sealed class LivePresentationCompositionPhase "developer world-entity source", devWorldEntities.BindOwned(worldState)); } - liveEntities = new LiveEntityRuntime( - worldState, - new CompositeLiveEntityResourceLifecycle( + var resourceOwners = + new List + { new( entity => _ = entitySpawnAdapter.OnCreate(entity), entity => _ = entitySpawnAdapter.OnRemove(entity)), new( entityScriptActivator.OnCreate, - entityScriptActivator.OnRemove)), + entityScriptActivator.OnRemove), + }; + if (renderSceneShadow is not null) + { + resourceOwners.Add(new( + renderSceneShadow.OnLiveResourceRegistered, + renderSceneShadow.OnLiveResourceUnregistered)); + } + liveEntities = new LiveEntityRuntime( + worldState, + new CompositeLiveEntityResourceLifecycle( + [.. resourceOwners]), componentLifecycle); var liveRuntimeLease = scope.Own( "canonical live-entity runtime", liveEntities, static runtime => runtime.Clear()); + LiveRenderProjectionJournal? liveRenderProjections = + renderSceneShadow?.BindLiveRuntime(liveEntities); Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated); bindings.Adopt( @@ -376,6 +405,13 @@ internal sealed class LivePresentationCompositionPhase liveEntities, wbVisibility, "WB projection visibility"); + if (liveRenderProjections is not null) + { + bindings.BindProjectionVisibility( + liveEntities, + liveRenderProjections.OnProjectionVisibilityChanged, + "shadow render projection visibility"); + } Action particleVisibility = (record, visible) => { if (record.WorldEntity is { } entity) @@ -511,6 +547,15 @@ internal sealed class LivePresentationCompositionPhase bindings.BindProjectionPoseReady( equippedLease.Resource, lightsLease.Resource.OnAttachedPoseReady); + if (liveRenderProjections is not null) + { + bindings.BindProjectionPoseReady( + equippedLease.Resource, + liveRenderProjections.OnProjectionPoseReady); + bindings.BindProjectionRemoved( + equippedLease.Resource, + liveRenderProjections.OnProjectionRemoved); + } bindings.Adopt( "entity-effect animation hooks", content.HookRegistrations.RegisterOwned(entityEffects)); @@ -529,6 +574,8 @@ internal sealed class LivePresentationCompositionPhase staticAnimationScheduler, worldAvailability, worldState, + renderSceneShadow, + renderSceneShadowLease, liveEntities, projectileController, projectionWithdrawal, @@ -574,6 +621,9 @@ internal sealed class LivePresentationCompositionPhase RetailStaticAnimatingObjectScheduler staticAnimationScheduler, WorldGenerationAvailabilityState worldAvailability, GpuWorldState worldState, + RenderSceneShadowRuntime? renderSceneShadow, + CompositionAcquisitionScope.CompositionAcquisitionLease< + RenderSceneShadowRuntime>? renderSceneShadowLease, LiveEntityRuntime liveEntities, ProjectileController projectileController, LiveEntityProjectionWithdrawalController projectionWithdrawal, @@ -818,7 +868,8 @@ internal sealed class LivePresentationCompositionPhase worldState, landblockRetirementOwner, landblockLoaded.OnLandblockLoaded, - landblockRenderPublisher.PrepareAfterRenderPins); + landblockRenderPublisher.PrepareAfterRenderPins, + renderSceneShadow?.StaticProjections); Fault(LivePresentationCompositionPoint.LandblockPipelineCreated); var clipFrameLease = scope.Acquire( @@ -965,6 +1016,7 @@ internal sealed class LivePresentationCompositionPhase staticAnimationScheduler, worldAvailability, worldState, + renderSceneShadow, liveEntities, projectileController, projectionWithdrawal, @@ -1010,6 +1062,7 @@ internal sealed class LivePresentationCompositionPhase _publication.PublishLivePresentation(result); liveRuntimeLease.Transfer(); + renderSceneShadowLease?.Transfer(); lightsLease.Transfer(); equippedLease.Transfer(); presentationLease.Transfer(); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 098a9a12..4bfafab9 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -5,6 +5,7 @@ using AcDream.App.Diagnostics; using AcDream.App.Net; using AcDream.App.Physics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.App.Settings; @@ -454,7 +455,8 @@ internal sealed class SessionPlayerCompositionPhase new LiveEntityReadyPublisher( live.LiveEntities, live.EntityEffects, - live.Presentation), + live.Presentation, + live.RenderSceneShadow?.LiveProjections), originCoordinator, networkUpdateBridge, localPhysicsTimestamps, @@ -603,7 +605,9 @@ internal sealed class SessionPlayerCompositionPhase live.EntityEffects, live.EquippedChildren, content.ParticleSink, - live.Lights); + live.Lights, + live.RenderSceneShadow?.LiveProjections, + live.RenderSceneShadow); var localPlayerAnimation = new LocalPlayerAnimationController( live.LiveEntities, d.PlayerIdentity, @@ -650,7 +654,9 @@ internal sealed class SessionPlayerCompositionPhase live.AnimationPresenter, d.AnimatedEntities, live.EquippedChildren, - liveEffectFrame); + liveEffectFrame, + live.RenderSceneShadow?.LiveProjections, + live.RenderSceneShadow?.StaticProjections); Fault(SessionPlayerCompositionPoint.UpdateLeavesCreated); var playerMode = new PlayerModeController( @@ -812,7 +818,8 @@ internal sealed class SessionPlayerCompositionPhase live.EntityEffects, content.AnimationHookFrames, live.Presentation, - d.RemoteMovementObservations), + d.RemoteMovementObservations, + live.RenderSceneShadow), d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create(liveSession); Fault(SessionPlayerCompositionPoint.SessionHostCreated); diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 7b653595..69306001 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -40,6 +40,7 @@ internal sealed record WorldLifecycleResourceSnapshot( int LiveEntities, int MaterializedLiveEntities, CurrentRenderSceneOracleSnapshot RenderSceneOracle, + RenderSceneShadowComparisonSnapshot RenderSceneShadow, int PendingLiveTeardowns, int PendingLandblockRetirements, int ParticleEmitters, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index 7371b824..5937e125 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -41,6 +41,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource private readonly ResidencyManager _residency; private readonly ICurrentRenderSceneOracleSnapshotSource? _renderSceneOracle; + private readonly IRenderSceneShadowSnapshotSource? + _renderSceneShadow; public WorldLifecycleResourceSnapshotSource( GpuWorldState world, @@ -59,7 +61,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource FrameProfiler frameProfiler, IDatReaderWriter dats, ResidencyManager residency, - ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null) + ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null, + IRenderSceneShadowSnapshotSource? renderSceneShadow = null) { _world = world ?? throw new ArgumentNullException(nameof(world)); _animations = animations @@ -87,6 +90,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource _residency = residency ?? throw new ArgumentNullException(nameof(residency)); _renderSceneOracle = renderSceneOracle; + _renderSceneShadow = renderSceneShadow; } public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome) @@ -129,6 +133,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource MaterializedLiveEntities: _liveEntities.MaterializedCount, RenderSceneOracle: _renderSceneOracle?.Snapshot ?? CurrentRenderSceneOracleSnapshot.Disabled, + RenderSceneShadow: + _renderSceneShadow?.CaptureCheckpointSnapshot() + ?? RenderSceneShadowComparisonSnapshot.Disabled, PendingLiveTeardowns: _liveEntities.PendingTeardownCount, PendingLandblockRetirements: _streaming.PendingRetirementCount, ParticleEmitters: _particles.ActiveEmitterCount, diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 310a3e2d..4d6a9708 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -33,6 +33,7 @@ internal sealed class LiveSessionResetBindings public required Action InboundEventFifo { get; init; } public required Action LiveLiveness { get; init; } public required Action LiveRuntime { get; init; } + public required Action RenderSceneProjection { get; init; } public required Action SessionIdentity { get; init; } public required Action RemoteTeleport { get; init; } public required Action NetworkEffects { get; init; } @@ -81,6 +82,10 @@ internal static class LiveSessionResetManifest new("inbound event fifo", bindings.InboundEventFifo), new("live liveness", bindings.LiveLiveness), new("live runtime", bindings.LiveRuntime), + // Canonical teardown appends exact projection removals. Drain them + // before identity changes so no prior-session scene record or + // journal entry can carry into the next character generation. + new("render scene projection", bindings.RenderSceneProjection), // Identity must remain A until live teardown has converged so // player-specific teardown can still classify the old record. new("session identity", bindings.SessionIdentity), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6c34e35c..96c19546 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -5,6 +5,7 @@ using AcDream.App.Input; using AcDream.App.Interaction; using AcDream.App.Physics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Vfx; using AcDream.App.Settings; @@ -86,7 +87,8 @@ internal sealed record LiveSessionWorldRuntime( EntityEffectController EntityEffects, AnimationHookFrameQueue AnimationHookFrames, LiveEntityPresentationController Presentation, - RemoteMovementObservationTracker RemoteMovementObservations); + RemoteMovementObservationTracker RemoteMovementObservations, + RenderSceneShadowRuntime? RenderSceneShadow); /// /// Builds the exact per-generation route/reset graph for the canonical live @@ -177,6 +179,8 @@ internal sealed class LiveSessionRuntimeFactory InboundEventFifo = _world.InboundEvents.Clear, LiveLiveness = _world.Liveness.Clear, LiveRuntime = ResetLiveRuntime, + RenderSceneProjection = + () => _world.RenderSceneShadow?.DrainUpdateBoundary(), SessionIdentity = ResetIdentity, RemoteTeleport = _world.RemoteTeleport.Clear, NetworkEffects = _world.EntityEffects.ClearNetworkState, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1bd2627d..c38499b6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,6 +1,7 @@ using AcDream.Core.Plugins; using AcDream.App.Composition; using AcDream.App.Physics; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Wb; using AcDream.App.Settings; using AcDream.App.World; @@ -57,6 +58,7 @@ public sealed class GameWindow : private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator; private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler; + private RenderSceneShadowRuntime? _renderSceneShadow; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery; @@ -949,6 +951,7 @@ public sealed class GameWindow : || _wbEntitySpawnAdapter is not null || _entityScriptActivator is not null || _staticAnimationScheduler is not null + || _renderSceneShadow is not null || _wbDrawDispatcher is not null || _retailSelectionScene is not null || _worldSelectionQuery is not null @@ -985,6 +988,7 @@ public sealed class GameWindow : _entityScriptActivator = result.EntityScriptActivator; _staticAnimationScheduler = result.StaticAnimationScheduler; _worldState = result.WorldState; + _renderSceneShadow = result.RenderSceneShadow; _liveEntities = result.LiveEntities; _projectileController = result.ProjectileController; _liveEntityProjectionWithdrawal = result.ProjectionWithdrawal; @@ -1589,6 +1593,7 @@ public sealed class GameWindow : _streamer, _equippedChildRenderer, _liveEntities, + _renderSceneShadow, _livePresentationBindings, _entityEffectAdvance, _entityEffects, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 2ebecb3c..fc94fd37 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -6,6 +6,7 @@ using AcDream.App.Input; using AcDream.App.Net; using AcDream.App.Physics; using AcDream.App.Rendering.Sky; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.App.Settings; @@ -79,6 +80,7 @@ internal sealed record LiveShutdownRoots( LandblockStreamer? Streamer, EquippedChildRenderController? EquippedChildren, LiveEntityRuntime? LiveEntities, + RenderSceneShadowRuntime? RenderSceneShadow, LivePresentationRuntimeBindings? PresentationBindings, DeferredEntityEffectAdvanceSource EffectAdvance, EntityEffectController? EntityEffects, @@ -377,6 +379,9 @@ internal static class GameWindowShutdownManifest new ResourceShutdownStage("live entities", [ Hard("live entity runtime", () => live.LiveEntities?.Clear()), + Hard( + "shadow render scene", + () => live.RenderSceneShadow?.Dispose()), ]), new ResourceShutdownStage("effect dispatch edges", [ diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index a687cfe8..df5f0e0a 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -91,6 +91,36 @@ internal sealed class NullRenderFramePostDiagnosticsPhase : } } +/// +/// Fixed-order post-diagnostic fan-out. The shadow referee must run before +/// lifecycle checkpoint publication so each artifact observes the comparison +/// made against that exact completed render frame. +/// +internal sealed class SerialRenderFramePostDiagnosticsPhase : + IRenderFramePostDiagnosticsPhase +{ + private readonly IRenderFramePostDiagnosticsPhase[] _phases; + + public SerialRenderFramePostDiagnosticsPhase( + params IRenderFramePostDiagnosticsPhase[] phases) + { + ArgumentNullException.ThrowIfNull(phases); + if (phases.Length == 0 || phases.Any(static phase => phase is null)) + { + throw new ArgumentException( + "At least one non-null post-diagnostic phase is required.", + nameof(phases)); + } + _phases = [.. phases]; + } + + public void Process(RenderFrameInput input, RenderFrameOutcome outcome) + { + for (int i = 0; i < _phases.Length; i++) + _phases[i].Process(input, outcome); + } +} + /// /// Closes presentation transactions which may have opened before a later /// render phase failed. Recovery is idempotent so failures before the diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs index d6e9f529..3e9e6e31 100644 --- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -863,6 +863,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource hash.Add(record.Source.GeometryFingerprint.High); hash.Add(record.Source.AppearanceFingerprint.Low); hash.Add(record.Source.AppearanceFingerprint.High); + hash.Add(record.Source.CurrentProjectionFlags); } private readonly record struct ProjectionIdentity(RenderProjectionId Id); diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs index e9f4635f..7f12457a 100644 --- a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs @@ -27,7 +27,8 @@ internal static class RenderProjectionRecordFactory ownerLandblockId, entity); RenderProjectionFlags flags = RenderProjectionFlags.Selectable; - if (spatiallyVisible + if (entity.MeshRefs.Count > 0 + && spatiallyVisible && entity.IsDrawVisible && entity.IsAncestorDrawVisible) { @@ -76,7 +77,8 @@ internal static class RenderProjectionRecordFactory entity.BuildingShellAnchorCellId ?? 0, fingerprint.Transform, fingerprint.Geometry, - fingerprint.Appearance)); + fingerprint.Appearance, + fingerprint.Flags)); } private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds( diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index 2f2aa863..2047c0eb 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -11,6 +11,7 @@ internal readonly record struct RenderProjectionId internal static RenderProjectionId FromRaw(ulong value) => new(value); internal ulong RawValue => _value; + internal byte Domain => (byte)(_value >> 56); internal RenderSortKey ToSortKey() => new(_value); public int CompareTo(RenderProjectionId other) => @@ -182,7 +183,8 @@ internal readonly record struct RenderSourceMetadata( uint BuildingShellAnchorCellId, RenderSceneHash128 TransformFingerprint, RenderSceneHash128 GeometryFingerprint, - RenderSceneHash128 AppearanceFingerprint); + RenderSceneHash128 AppearanceFingerprint, + uint CurrentProjectionFlags = 0); internal readonly record struct RenderProjectionRecord( RenderProjectionId Id, diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs new file mode 100644 index 00000000..5f21f5b1 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs @@ -0,0 +1,516 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Scene.Arch; +using AcDream.App.World; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Scene; + +internal readonly record struct RenderSceneShadowComparisonSnapshot( + bool Enabled, + ulong RenderFrameSequence, + ulong ComparisonCount, + ulong SuccessfulComparisonCount, + ulong ComparedOracleFrameSequence, + int MatchedProjectionCount, + long MismatchCount, + string? FirstMismatch, + int PendingDeltaCount, + ulong DrainSequence, + RenderDeltaApplyResult LastApply, + RenderDeltaApplyResult CumulativeApply, + RenderProjectionCounts SceneCounts, + RenderSceneIndexCounts SceneIndexCounts, + RenderSceneMemoryAccounting Memory, + RenderSceneDigest Digest) +{ + public static RenderSceneShadowComparisonSnapshot Disabled { get; } = + new( + Enabled: false, + RenderFrameSequence: 0, + ComparisonCount: 0, + SuccessfulComparisonCount: 0, + ComparedOracleFrameSequence: 0, + MatchedProjectionCount: 0, + MismatchCount: 0, + FirstMismatch: null, + PendingDeltaCount: 0, + DrainSequence: 0, + LastApply: default, + CumulativeApply: default, + SceneCounts: default, + SceneIndexCounts: default, + Memory: default, + Digest: default); +} + +internal interface IRenderSceneShadowSnapshotSource +{ + RenderSceneShadowComparisonSnapshot CaptureCheckpointSnapshot(); +} + +/// +/// Diagnostic-only owner of Slice F's non-drawing render projection. The +/// normal runtime never constructs this type. Accepted world/live lifecycle +/// edges append to one ordered journal, and the update thread drains it only +/// after current-frame animation, network, and attachment reconciliation. +/// +internal sealed class RenderSceneShadowRuntime : IDisposable +{ + private readonly ArchRenderScene _scene; + private readonly RenderProjectionJournal _journal; + private readonly RenderSceneDigestBuffer _digestBuffer = new(); + private LiveRenderProjectionJournal? _live; + private RenderDeltaApplyResult _lastApply; + private RenderDeltaApplyResult _cumulativeApply; + private ulong _drainSequence; + private bool _disposed; + + public RenderSceneShadowRuntime(RenderSceneGeneration generation) + { + _scene = new ArchRenderScene(generation); + _journal = new RenderProjectionJournal(generation); + StaticProjections = new StaticRenderProjectionJournal(_journal); + } + + public StaticRenderProjectionJournal StaticProjections { get; } + + public ILiveRenderProjectionSink? LiveProjections => _live; + + public int PendingDeltaCount => _journal.Count; + + public ulong DrainSequence => _drainSequence; + + public RenderDeltaApplyResult LastApply => _lastApply; + + public RenderDeltaApplyResult CumulativeApply => _cumulativeApply; + + public RenderProjectionCounts Counts => _scene.Counts; + + internal RenderProjectionJournal Journal => _journal; + + internal RenderSceneQuery Query => _scene.OpenQuery(); + + public LiveRenderProjectionJournal BindLiveRuntime( + LiveEntityRuntime runtime) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(runtime); + if (_live is not null) + { + throw new InvalidOperationException( + "The shadow render scene is already bound to a live runtime."); + } + + _live = new LiveRenderProjectionJournal(runtime, _journal); + return _live; + } + + public void OnLiveResourceRegistered(WorldEntity entity) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(entity); + // EntityReady is the exact registration edge after all render, + // script, effect, and presentation owners have committed. + } + + public void OnLiveResourceUnregistered(WorldEntity entity) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(entity); + (_live ?? throw new InvalidOperationException( + "The shadow render scene was not bound before live teardown.")) + .OnResourceUnregister(entity); + } + + public RenderDeltaApplyResult DrainUpdateBoundary() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _lastApply = _journal.DrainTo(_scene); + _cumulativeApply = Add(_cumulativeApply, _lastApply); + _drainSequence = checked(_drainSequence + 1); + return _lastApply; + } + + public RenderSceneMemoryAccounting CaptureMemory() + { + ObjectDisposedException.ThrowIf(_disposed, this); + RenderSceneMemoryAccounting memory = _scene.Memory; + return memory with + { + EstimatedJournalBufferBytes = checked( + (long)_journal.Capacity + * Unsafe.SizeOf()), + EstimatedSynchronizationSourceBytes = checked( + (long)(StaticProjections.ProjectionCount + + (_live?.ProjectionCount ?? 0)) + * Unsafe.SizeOf()), + }; + } + + public RenderSceneDigest BuildDigest() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _scene.BuildDigest(_digestBuffer); + } + + public void ClearDirty() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _scene.ClearDirty(); + } + + public void Clear(RenderSceneGeneration replacementGeneration) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _live?.ResetTracking(); + StaticProjections.Clear(replacementGeneration); + _scene.Clear(replacementGeneration); + _lastApply = default; + _cumulativeApply = default; + _drainSequence = 0; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _live?.ResetTracking(); + _scene.Dispose(); + } + + private static RenderDeltaApplyResult Add( + RenderDeltaApplyResult left, + RenderDeltaApplyResult right) => + new( + Applied: checked(left.Applied + right.Applied), + Registered: checked(left.Registered + right.Registered), + Updated: checked(left.Updated + right.Updated), + Replaced: checked(left.Replaced + right.Replaced), + Unregistered: checked(left.Unregistered + right.Unregistered), + RejectedGeneration: checked( + left.RejectedGeneration + right.RejectedGeneration), + RejectedOutOfOrderSequence: checked( + left.RejectedOutOfOrderSequence + + right.RejectedOutOfOrderSequence), + RejectedStaleIncarnation: checked( + left.RejectedStaleIncarnation + + right.RejectedStaleIncarnation), + RejectedMissing: checked( + left.RejectedMissing + right.RejectedMissing)); +} + +/// +/// Post-render referee between the exact current partition output and the +/// non-drawing incremental scene. It records only the first mismatch, so a +/// persistent fault cannot create an unbounded log storm. +/// +internal sealed class RenderSceneShadowComparisonController : + IRenderFramePostDiagnosticsPhase, + IRenderSceneShadowSnapshotSource +{ + private const ulong ComparisonCadenceFrames = 30; + private const byte StaticProjectionDomain = 1; + private const byte EnvCellProjectionDomain = 2; + private const byte LiveProjectionDomain = 3; + + private readonly RenderSceneShadowRuntime _shadow; + private readonly CurrentRenderSceneOracle _oracle; + private readonly Action? _log; + private readonly List _sceneRecords = []; + private readonly HashSet _expectedIds = []; + private ulong _renderFrameSequence; + private ulong _comparisonCount; + private ulong _successfulComparisonCount; + private long _mismatchCount; + private string? _firstMismatch; + private int _matchedProjectionCount; + private ulong _comparedOracleFrameSequence; + + public RenderSceneShadowComparisonController( + RenderSceneShadowRuntime shadow, + CurrentRenderSceneOracle oracle, + Action? log = null) + { + _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); + _oracle = oracle ?? throw new ArgumentNullException(nameof(oracle)); + _log = log; + Snapshot = BuildSnapshot(); + } + + public RenderSceneShadowComparisonSnapshot Snapshot { get; private set; } + + public void Process(RenderFrameInput input, RenderFrameOutcome outcome) + { + _renderFrameSequence = checked(_renderFrameSequence + 1); + if (outcome.World.NormalWorldDrawn + && _renderFrameSequence % ComparisonCadenceFrames == 0) + { + Compare(force: false); + } + else + { + Snapshot = BuildSnapshot(); + } + } + + public RenderSceneShadowComparisonSnapshot CaptureCheckpointSnapshot() + { + Compare(force: true); + return Snapshot; + } + + internal void CompareNow() => Compare(force: true); + + private void Compare(bool force) + { + CurrentRenderSceneOracleSnapshot current = _oracle.Snapshot; + if (current.CompletedFrameSequence == 0 + || (!force + && current.CompletedFrameSequence + == _comparedOracleFrameSequence)) + { + Snapshot = BuildSnapshot(); + return; + } + + _comparisonCount = checked(_comparisonCount + 1); + _comparedOracleFrameSequence = current.CompletedFrameSequence; + _matchedProjectionCount = 0; + string? mismatch = CompareCurrentProjection(); + if (mismatch is null) + { + _successfulComparisonCount = + checked(_successfulComparisonCount + 1); + } + else + { + _mismatchCount = checked(_mismatchCount + 1); + if (_firstMismatch is null) + { + _firstMismatch = mismatch; + _log?.Invoke( + "[render-scene-shadow] first mismatch: " + mismatch); + } + } + + _shadow.ClearDirty(); + Snapshot = BuildSnapshot(); + } + + private string? CompareCurrentProjection() + { + if (_shadow.PendingDeltaCount != 0) + { + return $"sourceChannel=journal pendingDeltas={_shadow.PendingDeltaCount}"; + } + if (_shadow.CumulativeApply.Rejected != 0) + { + return "sourceChannel=journal rejectedDeltas=" + + _shadow.CumulativeApply.Rejected; + } + + RenderSceneQuery query = _shadow.Query; + _expectedIds.Clear(); + IReadOnlyList expected = + _oracle.Projections; + for (int i = 0; i < expected.Count; i++) + { + CurrentRenderProjectionFingerprint fingerprint = expected[i]; + RenderProjectionId id = ExpectedId(in fingerprint); + _expectedIds.Add(id); + if (!query.TryGet(id, out RenderProjectionRecord actual)) + { + return $"sourceChannel={Channel(id)} id={id} field=presence " + + "expected=present actual=missing"; + } + + string? mismatch = CompareProjection( + in fingerprint, + in actual); + if (mismatch is not null) + return mismatch; + _matchedProjectionCount++; + } + + int total = query.Counts.Total; + if (_sceneRecords.Capacity < total) + _sceneRecords.Capacity = total; + CollectionsMarshal.SetCount(_sceneRecords, total); + int copied = query.CopyTo(CollectionsMarshal.AsSpan(_sceneRecords)); + if (copied != total) + { + return $"sourceChannel=scene field=count expected={total} " + + $"actual={copied}"; + } + + for (int i = 0; i < copied; i++) + { + RenderProjectionRecord record = _sceneRecords[i]; + if (_expectedIds.Contains(record.Id) + || Domain(record.Id) == EnvCellProjectionDomain) + { + continue; + } + + bool indoorStatic = + record.ProjectionClass + is RenderProjectionClass.IndoorCellStatic + || (record.ProjectionClass + is RenderProjectionClass.ActiveAnimatedStatic + && InteriorEntityPartition.IsIndoorCellId( + record.Source.ParentCellId)); + if (indoorStatic + || (record.Flags & RenderProjectionFlags.Draw) == 0) + { + continue; + } + + return $"sourceChannel={Channel(record.Id)} id={record.Id} " + + "field=presence expected=absent actual=drawn"; + } + + return null; + } + + private static string? CompareProjection( + in CurrentRenderProjectionFingerprint expected, + in RenderProjectionRecord actual) + { + RenderProjectionId id = actual.Id; + string channel = Channel(id); + string? classMismatch = CompareClass( + expected.ProjectionClass, + in actual); + if (classMismatch is not null) + return Prefix(channel, id, "projectionClass", classMismatch); + if (actual.Residency.OwnerLandblockId != expected.LandblockId) + { + return Prefix( + channel, + id, + "landblock", + $"{expected.LandblockId:X8}/{actual.Residency.OwnerLandblockId:X8}"); + } + if (actual.Source.LocalEntityId != expected.EntityId) + return Values(channel, id, "entityId", expected.EntityId, actual.Source.LocalEntityId); + if (actual.Source.ServerGuid != expected.ServerGuid) + return Values(channel, id, "serverGuid", expected.ServerGuid, actual.Source.ServerGuid); + if (actual.Source.SourceId != expected.SourceId) + return Values(channel, id, "sourceId", expected.SourceId, actual.Source.SourceId); + if (actual.Source.ParentCellId != expected.ParentCellId) + return Values(channel, id, "parentCell", expected.ParentCellId, actual.Source.ParentCellId); + if (actual.Source.EffectCellId != expected.EffectCellId) + return Values(channel, id, "effectCell", expected.EffectCellId, actual.Source.EffectCellId); + if (actual.Source.BuildingShellAnchorCellId + != expected.BuildingShellAnchorCellId) + { + return Values( + channel, + id, + "buildingShellAnchor", + expected.BuildingShellAnchorCellId, + actual.Source.BuildingShellAnchorCellId); + } + if (actual.Source.CurrentProjectionFlags != expected.Flags) + return Values(channel, id, "flags", expected.Flags, actual.Source.CurrentProjectionFlags); + if (actual.MeshSet.MeshCount != expected.MeshCount) + return Values(channel, id, "meshCount", expected.MeshCount, actual.MeshSet.MeshCount); + if (actual.Source.TransformFingerprint != expected.Transform) + return Prefix(channel, id, "transform", $"{expected.Transform}/{actual.Source.TransformFingerprint}"); + if (actual.Source.GeometryFingerprint != expected.Geometry) + return Prefix(channel, id, "geometry", $"{expected.Geometry}/{actual.Source.GeometryFingerprint}"); + if (actual.Source.AppearanceFingerprint != expected.Appearance) + return Prefix(channel, id, "appearance", $"{expected.Appearance}/{actual.Source.AppearanceFingerprint}"); + return null; + } + + private static string? CompareClass( + InteriorEntityPartition.ProjectionClass expected, + in RenderProjectionRecord actual) + { + bool matches = expected switch + { + InteriorEntityPartition.ProjectionClass.OutdoorStatic => + actual.ProjectionClass + is RenderProjectionClass.OutdoorStatic + or RenderProjectionClass.ActiveAnimatedStatic + && !InteriorEntityPartition.IsIndoorCellId( + actual.Source.ParentCellId), + InteriorEntityPartition.ProjectionClass.CellStatic => + actual.ProjectionClass + is RenderProjectionClass.IndoorCellStatic + or RenderProjectionClass.ActiveAnimatedStatic + && InteriorEntityPartition.IsIndoorCellId( + actual.Source.ParentCellId), + InteriorEntityPartition.ProjectionClass.Dynamic => + actual.ProjectionClass + is RenderProjectionClass.LiveDynamicRoot + or RenderProjectionClass.EquippedChild, + _ => false, + }; + return matches + ? null + : $"{expected}/{actual.ProjectionClass}"; + } + + private RenderSceneShadowComparisonSnapshot BuildSnapshot() + { + RenderSceneQuery query = _shadow.Query; + return new RenderSceneShadowComparisonSnapshot( + Enabled: true, + RenderFrameSequence: _renderFrameSequence, + ComparisonCount: _comparisonCount, + SuccessfulComparisonCount: _successfulComparisonCount, + ComparedOracleFrameSequence: _comparedOracleFrameSequence, + MatchedProjectionCount: _matchedProjectionCount, + MismatchCount: _mismatchCount, + FirstMismatch: _firstMismatch, + PendingDeltaCount: _shadow.PendingDeltaCount, + DrainSequence: _shadow.DrainSequence, + LastApply: _shadow.LastApply, + CumulativeApply: _shadow.CumulativeApply, + SceneCounts: query.Counts, + SceneIndexCounts: query.IndexCounts, + Memory: _shadow.CaptureMemory(), + Digest: _shadow.BuildDigest()); + } + + private static RenderProjectionId ExpectedId( + in CurrentRenderProjectionFingerprint fingerprint) => + fingerprint.ServerGuid == 0 + ? StaticRenderProjectionJournal.StaticEntityId( + fingerprint.LandblockId, + fingerprint.EntityId) + : LiveRenderProjectionJournal.ProjectionId( + fingerprint.EntityId); + + private static byte Domain(RenderProjectionId id) => + id.Domain; + + private static string Channel(RenderProjectionId id) => + Domain(id) switch + { + StaticProjectionDomain => "static", + EnvCellProjectionDomain => "envCell", + LiveProjectionDomain => "live", + byte domain => $"unknown:{domain}", + }; + + private static string Values( + string channel, + RenderProjectionId id, + string field, + T expected, + T actual) => + Prefix(channel, id, field, $"{expected}/{actual}"); + + private static string Prefix( + string channel, + RenderProjectionId id, + string field, + string values) => + $"sourceChannel={channel} id={id} field={field} " + + $"expected/actual={values}"; +} diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index 8a3f6a7c..dd62774d 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -238,13 +238,15 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP private readonly ParticleHookSink _particleSink; private readonly LiveEntityLightController _lights; private readonly ILiveRenderProjectionSink? _renderProjections; + private readonly RenderSceneShadowRuntime? _renderSceneShadow; public LiveSpatialPresentationReconciler( EntityEffectController entityEffects, EquippedChildRenderController equippedChildren, ParticleHookSink particleSink, LiveEntityLightController lights, - ILiveRenderProjectionSink? renderProjections = null) + ILiveRenderProjectionSink? renderProjections = null, + RenderSceneShadowRuntime? renderSceneShadow = null) { _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects)); _equippedChildren = equippedChildren @@ -252,6 +254,7 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); _lights = lights ?? throw new ArgumentNullException(nameof(lights)); _renderProjections = renderProjections; + _renderSceneShadow = renderSceneShadow; } public void Reconcile() @@ -261,5 +264,6 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP _renderProjections?.SynchronizeActiveSources(); _particleSink.RefreshAttachedEmitters(); _lights.Refresh(); + _renderSceneShadow?.DrainUpdateBoundary(); } } diff --git a/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs b/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs index 99539f1e..e2b5fc2f 100644 --- a/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs @@ -38,12 +38,14 @@ public sealed class FrameRootCompositionTests AssertAppearsInOrder( source, "new RenderFrameResourceController(", + "new RenderSceneShadowComparisonController(", "new WorldSceneRenderer(", "new WorldLifecycleAutomationController(", "\"world lifecycle automation owner\"", "\"world lifecycle automation binding\"", + "new SerialRenderFramePostDiagnosticsPhase(", "new RenderFrameOrchestrator(", - "(IRenderFramePostDiagnosticsPhase?)lifecycleAutomation", + "postDiagnostics,", "new RetailLiveFrameCoordinator(", "new UpdateFrameOrchestrator(", "d.FrameGraphs.PublishOwned(", diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 9c2dfb2f..40205b88 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -144,6 +144,15 @@ public sealed class WorldLifecycleAutomationControllerTests AbortedSelectionFrames: 0, SelectionPartCount: 7, SelectionDigest: new RenderSceneHash128(15, 16)), + RenderSceneShadow = + RenderSceneShadowComparisonSnapshot.Disabled with + { + Enabled = true, + ComparisonCount = 19, + SuccessfulComparisonCount = 19, + ComparedOracleFrameSequence = 19, + MatchedProjectionCount = 73, + }, TrackedGpuBytes = 1234, Residency = new ResidencySnapshot( [ @@ -214,6 +223,20 @@ public sealed class WorldLifecycleAutomationControllerTests 0xFEDCBA9876543210uL, renderSceneOracle.GetProperty("digest").GetProperty("high") .GetUInt64()); + JsonElement renderSceneShadow = json.RootElement + .GetProperty("resources") + .GetProperty("renderSceneShadow"); + Assert.True(renderSceneShadow.GetProperty("enabled").GetBoolean()); + Assert.Equal( + 19, + renderSceneShadow.GetProperty("comparisonCount").GetInt64()); + Assert.Equal( + 73, + renderSceneShadow.GetProperty("matchedProjectionCount") + .GetInt32()); + Assert.Equal( + 0, + renderSceneShadow.GetProperty("mismatchCount").GetInt64()); JsonElement residency = json.RootElement .GetProperty("resources") .GetProperty("residency"); @@ -353,6 +376,7 @@ public sealed class WorldLifecycleAutomationControllerTests private static WorldLifecycleResourceSnapshot EmptyResources() => new( 0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled, + RenderSceneShadowComparisonSnapshot.Disabled, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs index 1bf94684..7f8729c0 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs @@ -269,6 +269,7 @@ public sealed class LiveSessionHostTests InboundEventFifo = noop, LiveLiveness = noop, LiveRuntime = noop, + RenderSceneProjection = noop, SessionIdentity = noop, RemoteTeleport = noop, NetworkEffects = noop, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index b77db2cc..38b24dd1 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -298,6 +298,7 @@ public sealed class LiveSessionResetPlanTests LiveRuntime = Stage( "live runtime", () => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)), + RenderSceneProjection = Stage("render scene projection"), SessionIdentity = Stage("session identity", () => { LiveSessionEntityRuntimeReset.RequireConvergence(live); @@ -378,6 +379,7 @@ public sealed class LiveSessionResetPlanTests "inbound event fifo", "live liveness", "live runtime", + "render scene projection", "session identity", "remote teleport", "network effects", diff --git a/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs b/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs new file mode 100644 index 00000000..c8f352c1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs @@ -0,0 +1,224 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderSceneShadowRuntimeTests +{ + private const uint Landblock = 0xA9B4FFFF; + private const uint Cell = 0xA9B40170; + + [Fact] + public void ExactCurrentProjection_MatchesAndAcknowledgesDirtyWorkset() + { + WorldEntity entity = Entity(17, serverGuid: 0, parentCell: null); + CurrentRenderSceneOracle oracle = Capture( + entity, + visibleCells: []); + using var shadow = new RenderSceneShadowRuntime(Generation(1)); + Register(shadow, entity, RenderProjectionClass.OutdoorStatic); + Assert.Equal(1, shadow.DrainUpdateBoundary().Registered); + + var controller = new RenderSceneShadowComparisonController( + shadow, + oracle); + RenderSceneShadowComparisonSnapshot snapshot = + controller.CaptureCheckpointSnapshot(); + + Assert.True(snapshot.Enabled); + Assert.Equal(1uL, snapshot.ComparisonCount); + Assert.Equal(1uL, snapshot.SuccessfulComparisonCount); + Assert.Equal(0, snapshot.MismatchCount); + Assert.Equal(1, snapshot.MatchedProjectionCount); + Assert.Equal(0, snapshot.PendingDeltaCount); + Assert.Equal(0, snapshot.SceneIndexCounts.Dirty); + Assert.Equal(1, snapshot.SceneCounts.Total); + Assert.True(snapshot.Memory.TotalEstimatedBytes > 0); + } + + [Fact] + public void FirstExactMismatch_IsNamedAndLoggedOnlyOnce() + { + WorldEntity entity = Entity(18, serverGuid: 0, parentCell: null); + CurrentRenderSceneOracle oracle = Capture(entity, []); + using var shadow = new RenderSceneShadowRuntime(Generation(1)); + RenderProjectionRecord record = Project( + entity, + RenderProjectionClass.OutdoorStatic) with + { + Source = Project( + entity, + RenderProjectionClass.OutdoorStatic).Source with + { + TransformFingerprint = new RenderSceneHash128(91, 92), + }, + }; + shadow.Journal.Register(in record); + shadow.DrainUpdateBoundary(); + var logs = new List(); + var controller = new RenderSceneShadowComparisonController( + shadow, + oracle, + logs.Add); + + controller.CompareNow(); + controller.CompareNow(); + RenderSceneShadowComparisonSnapshot snapshot = controller.Snapshot; + + Assert.Equal(2uL, snapshot.ComparisonCount); + Assert.Equal(0uL, snapshot.SuccessfulComparisonCount); + Assert.Equal(2, snapshot.MismatchCount); + Assert.Contains("sourceChannel=static", snapshot.FirstMismatch); + Assert.Contains("field=transform", snapshot.FirstMismatch); + Assert.Single(logs); + } + + [Fact] + public void NonFloodedIndoorStatic_IsAValidRetainedSceneExtra() + { + WorldEntity entity = Entity(19, serverGuid: 0, parentCell: Cell); + CurrentRenderSceneOracle oracle = Capture(entity, []); + Assert.Empty(oracle.Projections); + using var shadow = new RenderSceneShadowRuntime(Generation(1)); + Register(shadow, entity, RenderProjectionClass.IndoorCellStatic); + shadow.DrainUpdateBoundary(); + var controller = new RenderSceneShadowComparisonController( + shadow, + oracle); + + controller.CompareNow(); + + Assert.Equal(1uL, controller.Snapshot.SuccessfulComparisonCount); + Assert.Equal(0, controller.Snapshot.MismatchCount); + } + + [Fact] + public void DrawnOutdoorExtra_IsReportedAsStaleProjection() + { + WorldEntity expected = Entity(20, serverGuid: 0, parentCell: null); + WorldEntity extra = Entity(21, serverGuid: 0, parentCell: null); + CurrentRenderSceneOracle oracle = Capture(expected, []); + using var shadow = new RenderSceneShadowRuntime(Generation(1)); + Register(shadow, expected, RenderProjectionClass.OutdoorStatic); + Register(shadow, extra, RenderProjectionClass.OutdoorStatic); + shadow.DrainUpdateBoundary(); + var controller = new RenderSceneShadowComparisonController( + shadow, + oracle); + + controller.CompareNow(); + + Assert.Equal(1, controller.Snapshot.MismatchCount); + Assert.Contains("expected=absent actual=drawn", controller.Snapshot.FirstMismatch); + } + + [Fact] + public void RejectedDelta_IsAComparisonFailureEvenWhenSceneIsEmpty() + { + CurrentRenderSceneOracle oracle = CaptureNoEntities(); + using var shadow = new RenderSceneShadowRuntime(Generation(1)); + WorldEntity missing = Entity(22, serverGuid: 0, parentCell: null); + RenderProjectionRecord record = Project( + missing, + RenderProjectionClass.OutdoorStatic); + shadow.Journal.Update( + RenderProjectionDeltaKind.UpdateTransform, + in record); + RenderDeltaApplyResult apply = shadow.DrainUpdateBoundary(); + Assert.Equal(1, apply.RejectedMissing); + var controller = new RenderSceneShadowComparisonController( + shadow, + oracle); + + controller.CompareNow(); + + Assert.Equal(1, controller.Snapshot.MismatchCount); + Assert.Contains("rejectedDeltas=1", controller.Snapshot.FirstMismatch); + } + + private static void Register( + RenderSceneShadowRuntime shadow, + WorldEntity entity, + RenderProjectionClass projectionClass) + { + RenderProjectionRecord record = Project(entity, projectionClass); + shadow.Journal.Register(in record); + } + + private static RenderProjectionRecord Project( + WorldEntity entity, + RenderProjectionClass projectionClass) => + RenderProjectionRecordFactory.ProjectEntity( + StaticRenderProjectionJournal.StaticEntityId( + Landblock, + entity.Id), + projectionClass, + RenderOwnerIncarnation.FromRaw(entity.Id), + Landblock, + entity.ParentCellId ?? Landblock, + entity, + spatiallyVisible: true); + + private static CurrentRenderSceneOracle Capture( + WorldEntity entity, + HashSet visibleCells) + { + var oracle = new CurrentRenderSceneOracle(); + var result = new InteriorEntityPartition.Result(); + InteriorEntityPartition.Partition( + result, + visibleCells, + [ + ( + Landblock, + Vector3.Zero, + Vector3.One, + (IReadOnlyList)[entity], + (IReadOnlyDictionary?)null), + ], + oracle); + return oracle; + } + + private static CurrentRenderSceneOracle CaptureNoEntities() + { + var oracle = new CurrentRenderSceneOracle(); + var result = new InteriorEntityPartition.Result(); + InteriorEntityPartition.Partition( + result, + [], + Array.Empty<( + uint LandblockId, + Vector3 AabbMin, + Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)>(), + oracle); + return oracle; + } + + private static RenderSceneGeneration Generation(ulong value) => + RenderSceneGeneration.FromRaw(value); + + private static WorldEntity Entity( + uint id, + uint serverGuid, + uint? parentCell) => + new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(1, 2, 3), + Rotation = Quaternion.Identity, + MeshRefs = + [ + new MeshRef( + 0x01000001u, + Matrix4x4.CreateTranslation(0.25f, 0.5f, 0.75f)), + ], + ParentCellId = parentCell, + }; +}