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 e47e7d15..0b7893ce 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 @@ -16,12 +16,13 @@ retail-faithful gameplay | Unit | State | Evidence / rollback | |---|---|---| -| F0a — partition-input referee | implemented; Release gate green | Diagnostic-only `CurrentRenderSceneOracle`; 3,685 App tests / 3 skips and 8,169 complete-solution tests / 5 skips. No production draw decision changed. | -| F0b — survivor/dispatcher referee | active | Extends the same current-path oracle through PView survivor and dispatcher classification boundaries. | -| F1–F5 | pending | No render-scene storage or shadow projection exists yet. | +| F0a — partition-input referee | complete | Diagnostic-only `CurrentRenderSceneOracle`; no production draw decision changed. | +| F0b — survivor/dispatcher/selection referee | complete | Exact PView routes, dispatcher candidates and final group payloads, material/alpha/clip/light/selection fields, and accepted picking parts. Release gate: 3,690 App tests / 3 skips and 8,174 complete-solution tests / 5 skips. | +| F1 — scene types and contained adapter | active | No render-scene storage or shadow projection exists yet. | +| F2–F5 | pending | Delta publication and continuous shadow comparison have not started. | | G0–G5 | pending | No production consumer has switched. | -The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0a is +The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is non-drawing diagnostic infrastructure and therefore is not a visual-cutover rollback unit. Each later commit that changes the production draw source is listed in section 10 before it is offered for a connected gate. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 84c3caaf..4b130d8b 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -374,6 +374,10 @@ internal sealed class FrameRootCompositionPhase && d.Options.AutomationArtifactDirectory is not null ? new CurrentRenderSceneOracle() : null; + live.DrawDispatcher.SetCurrentRenderSceneObserver( + currentRenderSceneOracle); + live.SelectionScene.SetCurrentRenderSceneObserver( + currentRenderSceneOracle); var worldSceneRenderer = new WorldSceneRenderer( renderFrameResources, renderLoginState, diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 606dda46..747b90de 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.App.Rendering.Scene; using AcDream.Core.World; namespace AcDream.App.Rendering; @@ -13,6 +14,7 @@ namespace AcDream.App.Rendering; public sealed class RetailPViewRenderer { private readonly InteriorEntityPartition.IObserver? _partitionObserver; + private readonly ICurrentRenderPViewObserver? _candidateObserver; private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ViewconeCuller _viewconeScratch = new(); @@ -72,6 +74,7 @@ public sealed class RetailPViewRenderer InteriorEntityPartition.IObserver? partitionObserver) { _partitionObserver = partitionObserver; + _candidateObserver = partitionObserver as ICurrentRenderPViewObserver; } // T2 (BR-4): retail has NO distance constant on the flood-admission chain @@ -198,41 +201,51 @@ public sealed class RetailPViewRenderer ctx.ViewProjection, _viewconeScratch); - // #118: stage assignment for dynamics under an INTERIOR root. Retail - // draws the OUTSIDE world's objects inside the landscape stage — - // PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated - // full depth clear (pc:432731-432732) and the exit-portal SEALS - // (pc:432785-432786); DrawBlock draws every landcell's objects via - // DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our - // single last pass instead z-fails against the seal's true-depth stamp - // the moment it stands beyond the door plane — the house-exit - // clip+vanish (pinned by HouseExitWalkReplayTests). So under an - // interior root: outdoor-classified dynamics draw in the outside - // stage; an indoor dynamic whose sphere STRADDLES an exit portal - // draws in BOTH stages (retail's per-overlapped-cell shadow-part - // draw, DrawBlock pc:430056-430064) so neither body half clips at the - // plane. Outdoor roots keep ALL dynamics in the last pass — our - // z-buffered equivalent of retail's painter-ordered outdoor pass (the - // BR-2 punch-after-dynamics lesson, reverted 88be519). - _outsideStageDynamics.Clear(); - if (!ctx.RootCell.IsOutdoorNode) + _candidateObserver?.BeginPViewFrame(); + try { - foreach (var e in partition.Dynamics) + // #118: stage assignment for dynamics under an INTERIOR root. Retail + // draws the OUTSIDE world's objects inside the landscape stage — + // PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated + // full depth clear (pc:432731-432732) and the exit-portal SEALS + // (pc:432785-432786); DrawBlock draws every landcell's objects via + // DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our + // single last pass instead z-fails against the seal's true-depth stamp + // the moment it stands beyond the door plane — the house-exit + // clip+vanish (pinned by HouseExitWalkReplayTests). So under an + // interior root: outdoor-classified dynamics draw in the outside + // stage; an indoor dynamic whose sphere STRADDLES an exit portal + // draws in BOTH stages (retail's per-overlapped-cell shadow-part + // draw, DrawBlock pc:430056-430064) so neither body half clips at the + // plane. Outdoor roots keep ALL dynamics in the last pass — our + // z-buffered equivalent of retail's painter-ordered outdoor pass (the + // BR-2 punch-after-dynamics lesson, reverted 88be519). + _outsideStageDynamics.Clear(); + if (!ctx.RootCell.IsOutdoorNode) { - EntitySphere(e, out var c, out float r); - if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.Cells)) - _outsideStageDynamics.Add(e); + foreach (var e in partition.Dynamics) + { + EntitySphere(e, out var c, out float r); + if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.Cells)) + _outsideStageDynamics.Add(e); + } } + + DrawLandscapeThroughOutsideView(ctx, passes, clipAssembly, partition, viewcone); + passes.UseIndoorMembershipOnlyRouting(); + DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(passes, pvFrame); + DrawCellObjectLists(ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone); + DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode); + + _candidateObserver?.CompletePViewFrame(); + return result; + } + catch + { + _candidateObserver?.AbortPViewFrame(); + throw; } - - DrawLandscapeThroughOutsideView(ctx, passes, clipAssembly, partition, viewcone); - passes.UseIndoorMembershipOnlyRouting(); - DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(passes, pvFrame); - DrawCellObjectLists(ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone); - DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode); - - return result; } // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group @@ -435,6 +448,11 @@ public sealed class RetailPViewRenderer if (_cellStaticScratch.Count > 0) { + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LookInObject, + i, + cellId, + _cellStaticScratch); passes.DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); // The cell-particles pass for look-in cells — retail's @@ -489,6 +507,11 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LandscapeOutdoorStatic, + probeSliceIndex, + 0, + _outdoorStaticScratch); probeSliceIndex++; passes.DrawLandscapeSlice(ctx, new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } @@ -541,6 +564,11 @@ public sealed class RetailPViewRenderer probeSliceIndex, _outsideStageDynamics, viewcone); + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LandscapeOutsideDynamic, + probeSliceIndex, + 0, + _outdoorStaticScratch); probeSliceIndex++; passes.DrawLandscapeSliceLate(ctx, new RetailPViewLandscapeLateSliceContext( slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); @@ -693,6 +721,11 @@ public sealed class RetailPViewRenderer if (_dynamicsScratch.Count == 0) return; + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.DynamicLast, + 0, + 0, + _dynamicsScratch); passes.UseIndoorMembershipOnlyRouting(); passes.DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); @@ -779,6 +812,11 @@ public sealed class RetailPViewRenderer // cull; only the draw is batched). if (_allCellStatics.Count > 0) { + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.CellStatic, + 0, + 0, + _allCellStatics); passes.UseIndoorMembershipOnlyRouting(); passes.DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); } diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index ffd2b10e..a028328f 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -1,4 +1,6 @@ using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Selection; using AcDream.Core.World; namespace AcDream.App.Rendering.Scene; @@ -25,6 +27,48 @@ internal readonly record struct CurrentRenderProjectionFingerprint( RenderSceneHash128 Geometry, RenderSceneHash128 Appearance); +internal enum CurrentRenderPViewRoute : byte +{ + LandscapeOutdoorStatic, + LandscapeOutsideDynamic, + LookInObject, + CellStatic, + DynamicLast, +} + +internal readonly record struct CurrentRenderPViewCandidateFingerprint( + int Sequence, + CurrentRenderPViewRoute Route, + int RouteIndex, + uint CellId, + CurrentRenderProjectionFingerprint Projection); + +internal readonly record struct CurrentRenderDispatcherFingerprint( + int Sequence, + int DrawSequence, + WbDrawDispatcher.EntitySet Set, + int MeshRefIndex, + uint TupleLandblockId, + uint CacheLandblockId, + CurrentRenderProjectionFingerprint Projection); + +internal readonly record struct CurrentRenderDispatcherSubmission( + int VisibleInstanceCount, + int ImmediateInstanceCount, + int OpaqueGroupCount, + int TransparentGroupCount, + bool TransparentDeferred, + RenderSceneHash128 Digest); + +internal readonly record struct CurrentRenderSelectionFingerprint( + int Sequence, + uint ServerGuid, + uint LocalEntityId, + int PartIndex, + uint GfxObjId, + Matrix4x4 LocalToWorld, + RenderSceneHash128 Geometry); + internal readonly record struct CurrentRenderSceneOracleSnapshot( bool Enabled, ulong CompletedFrameSequence, @@ -34,7 +78,24 @@ internal readonly record struct CurrentRenderSceneOracleSnapshot( int CellStaticCount, int DynamicCount, int CellBucketCount, - RenderSceneHash128 Digest) + RenderSceneHash128 Digest, + ulong CompletedPViewFrameSequence, + int AbortedPViewFrames, + int PViewCandidateCount, + RenderSceneHash128 PViewDigest, + ulong DispatcherFrameSequence, + int AbortedDispatcherFrames, + int DispatcherDrawCount, + int DispatcherEntityCount, + int DispatcherMeshRefCount, + int DispatcherInstanceCount, + int DispatcherOpaqueGroupCount, + int DispatcherTransparentGroupCount, + RenderSceneHash128 DispatcherDigest, + ulong CompletedSelectionFrameSequence, + int AbortedSelectionFrames, + int SelectionPartCount, + RenderSceneHash128 SelectionDigest) { public static CurrentRenderSceneOracleSnapshot Disabled { get; } = new( Enabled: false, @@ -45,7 +106,24 @@ internal readonly record struct CurrentRenderSceneOracleSnapshot( CellStaticCount: 0, DynamicCount: 0, CellBucketCount: 0, - Digest: RenderSceneHash128.Empty); + Digest: RenderSceneHash128.Empty, + CompletedPViewFrameSequence: 0, + AbortedPViewFrames: 0, + PViewCandidateCount: 0, + PViewDigest: RenderSceneHash128.Empty, + DispatcherFrameSequence: 0, + AbortedDispatcherFrames: 0, + DispatcherDrawCount: 0, + DispatcherEntityCount: 0, + DispatcherMeshRefCount: 0, + DispatcherInstanceCount: 0, + DispatcherOpaqueGroupCount: 0, + DispatcherTransparentGroupCount: 0, + DispatcherDigest: RenderSceneHash128.Empty, + CompletedSelectionFrameSequence: 0, + AbortedSelectionFrames: 0, + SelectionPartCount: 0, + SelectionDigest: RenderSceneHash128.Empty); } internal interface ICurrentRenderSceneOracleSnapshotSource @@ -53,6 +131,56 @@ internal interface ICurrentRenderSceneOracleSnapshotSource CurrentRenderSceneOracleSnapshot Snapshot { get; } } +internal interface ICurrentRenderPViewObserver +{ + void BeginPViewFrame(); + + void ObservePViewBucket( + CurrentRenderPViewRoute route, + int routeIndex, + uint cellId, + IReadOnlyList entities); + + void CompletePViewFrame(); + + void AbortPViewFrame(); +} + +internal interface ICurrentRenderDispatcherObserver +{ + void BeginDispatcherFrame(); + + void ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet set, + int entitiesWalked, + IReadOnlyList<( + WorldEntity Entity, + int MeshRefIndex, + uint LandblockId)> accepted); + + void ObserveDispatcherSubmission( + in CurrentRenderDispatcherSubmission submission); + + void AbortDispatcherFrame(); +} + +internal interface ICurrentRenderSelectionObserver +{ + void BeginSelectionFrame(); + + void ObserveSelectionPart( + uint serverGuid, + uint localEntityId, + int partIndex, + uint gfxObjId, + Matrix4x4 localToWorld, + RetailSelectionMesh mesh); + + void CompleteSelectionFrame(); + + void AbortSelectionFrame(); +} + /// /// Deterministic fingerprint of the already-accepted /// output. This is the Slice-F referee: @@ -65,21 +193,59 @@ internal interface ICurrentRenderSceneOracleSnapshotSource /// internal sealed class CurrentRenderSceneOracle : InteriorEntityPartition.IObserver, + ICurrentRenderPViewObserver, + ICurrentRenderDispatcherObserver, + ICurrentRenderSelectionObserver, ICurrentRenderSceneOracleSnapshotSource { private readonly List _projections = []; + private readonly Dictionary + _projectionByEntity = + new(ReferenceEqualityComparer.Instance); + private readonly List + _pviewCandidates = []; + private readonly List + _dispatcherCandidates = []; + private readonly Dictionary + _dispatcherProjectionByEntity = + new(ReferenceEqualityComparer.Instance); + private readonly List + _selectionParts = []; private ulong _frameSequence; private int _abortedFrames; private int _outdoorStaticCount; private int _cellStaticCount; private int _dynamicCount; private bool _frameOpen; + private ulong _pviewFrameSequence; + private int _abortedPViewFrames; + private bool _pviewFrameOpen; + private StableRenderHash128 _pviewHash; + private ulong _dispatcherFrameSequence; + private int _abortedDispatcherFrames; + private int _dispatcherDrawCount; + private int _dispatcherEntityCount; + private int _dispatcherInstanceCount; + private int _dispatcherOpaqueGroupCount; + private int _dispatcherTransparentGroupCount; + private bool _dispatcherFrameOpen; + private StableRenderHash128 _dispatcherHash; + private ulong _selectionFrameSequence; + private int _abortedSelectionFrames; + private bool _selectionFrameOpen; + private StableRenderHash128 _selectionHash; public CurrentRenderSceneOracleSnapshot Snapshot { get; private set; } = CurrentRenderSceneOracleSnapshot.Disabled with { Enabled = true }; internal IReadOnlyList Projections => _projections; + internal IReadOnlyList + PViewCandidates => _pviewCandidates; + internal IReadOnlyList + DispatcherCandidates => _dispatcherCandidates; + internal IReadOnlyList + SelectionParts => _selectionParts; public void BeginFrame() { @@ -91,6 +257,7 @@ internal sealed class CurrentRenderSceneOracle : _frameOpen = true; _projections.Clear(); + _projectionByEntity.Clear(); _outdoorStaticCount = 0; _cellStaticCount = 0; _dynamicCount = 0; @@ -126,10 +293,16 @@ internal sealed class CurrentRenderSceneOracle : "Unknown current render projection class."); } - _projections.Add(CreateFingerprint( + CurrentRenderProjectionFingerprint fingerprint = CreateFingerprint( landblockId, entity, - projectionClass)); + projectionClass); + _projections.Add(fingerprint); + if (!_projectionByEntity.TryAdd(entity, fingerprint)) + { + throw new InvalidOperationException( + $"The current render partition observed entity 0x{entity.Id:X8} more than once."); + } } public void Complete(InteriorEntityPartition.Result result) @@ -170,7 +343,26 @@ internal sealed class CurrentRenderSceneOracle : CellStaticCount: _cellStaticCount, DynamicCount: _dynamicCount, CellBucketCount: result.ByCell.Count, - Digest: aggregate.Finish()); + Digest: aggregate.Finish(), + CompletedPViewFrameSequence: Snapshot.CompletedPViewFrameSequence, + AbortedPViewFrames: _abortedPViewFrames, + PViewCandidateCount: Snapshot.PViewCandidateCount, + PViewDigest: Snapshot.PViewDigest, + DispatcherFrameSequence: Snapshot.DispatcherFrameSequence, + AbortedDispatcherFrames: _abortedDispatcherFrames, + DispatcherDrawCount: Snapshot.DispatcherDrawCount, + DispatcherEntityCount: Snapshot.DispatcherEntityCount, + DispatcherMeshRefCount: Snapshot.DispatcherMeshRefCount, + DispatcherInstanceCount: Snapshot.DispatcherInstanceCount, + DispatcherOpaqueGroupCount: Snapshot.DispatcherOpaqueGroupCount, + DispatcherTransparentGroupCount: + Snapshot.DispatcherTransparentGroupCount, + DispatcherDigest: Snapshot.DispatcherDigest, + CompletedSelectionFrameSequence: + Snapshot.CompletedSelectionFrameSequence, + AbortedSelectionFrames: _abortedSelectionFrames, + SelectionPartCount: Snapshot.SelectionPartCount, + SelectionDigest: Snapshot.SelectionDigest); } public void AbortFrame() @@ -187,6 +379,328 @@ internal sealed class CurrentRenderSceneOracle : Snapshot = Snapshot with { AbortedFrames = _abortedFrames }; } + public void BeginPViewFrame() + { + if (_pviewFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle cannot begin a second PView frame before completing or aborting the first."); + } + + _pviewFrameOpen = true; + _pviewCandidates.Clear(); + _pviewHash = StableRenderHash128.Create(); + } + + public void ObservePViewBucket( + CurrentRenderPViewRoute route, + int routeIndex, + uint cellId, + IReadOnlyList entities) + { + if (!_pviewFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle received a PView bucket outside an open frame."); + } + ArgumentNullException.ThrowIfNull(entities); + + _pviewHash.Add((byte)0xB1); + _pviewHash.Add((byte)route); + _pviewHash.Add(routeIndex); + _pviewHash.Add(cellId); + _pviewHash.Add(entities.Count); + foreach (WorldEntity entity in entities) + { + if (!_projectionByEntity.TryGetValue(entity, out var projection)) + { + throw new InvalidOperationException( + $"PView routed entity 0x{entity.Id:X8} that was absent from the current partition."); + } + + var candidate = new CurrentRenderPViewCandidateFingerprint( + Sequence: _pviewCandidates.Count, + Route: route, + RouteIndex: routeIndex, + CellId: cellId, + Projection: projection); + _pviewCandidates.Add(candidate); + AddPViewCandidate(ref _pviewHash, in candidate); + } + } + + public void CompletePViewFrame() + { + if (!_pviewFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle cannot complete PView without an open frame."); + } + + _pviewFrameOpen = false; + Snapshot = Snapshot with + { + CompletedPViewFrameSequence = checked(++_pviewFrameSequence), + AbortedPViewFrames = _abortedPViewFrames, + PViewCandidateCount = _pviewCandidates.Count, + PViewDigest = _pviewHash.Finish(), + }; + } + + public void AbortPViewFrame() + { + if (!_pviewFrameOpen) + return; + + _pviewFrameOpen = false; + _pviewCandidates.Clear(); + _abortedPViewFrames = checked(_abortedPViewFrames + 1); + Snapshot = Snapshot with + { + AbortedPViewFrames = _abortedPViewFrames, + }; + } + + public void BeginDispatcherFrame() + { + _dispatcherFrameOpen = true; + _dispatcherCandidates.Clear(); + _dispatcherProjectionByEntity.Clear(); + _dispatcherDrawCount = 0; + _dispatcherEntityCount = 0; + _dispatcherInstanceCount = 0; + _dispatcherOpaqueGroupCount = 0; + _dispatcherTransparentGroupCount = 0; + _dispatcherHash = StableRenderHash128.Create(); + Snapshot = Snapshot with + { + DispatcherFrameSequence = checked(++_dispatcherFrameSequence), + AbortedDispatcherFrames = _abortedDispatcherFrames, + DispatcherDrawCount = 0, + DispatcherEntityCount = 0, + DispatcherMeshRefCount = 0, + DispatcherInstanceCount = 0, + DispatcherOpaqueGroupCount = 0, + DispatcherTransparentGroupCount = 0, + DispatcherDigest = _dispatcherHash.Finish(), + }; + } + + public void ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet set, + int entitiesWalked, + IReadOnlyList<( + WorldEntity Entity, + int MeshRefIndex, + uint LandblockId)> accepted) + { + if (!_dispatcherFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle received dispatcher input outside an open frame."); + } + ArgumentNullException.ThrowIfNull(accepted); + + int drawSequence = _dispatcherDrawCount++; + _dispatcherEntityCount = checked(_dispatcherEntityCount + entitiesWalked); + _dispatcherHash.Add((byte)0xD1); + _dispatcherHash.Add(drawSequence); + _dispatcherHash.Add((int)set); + _dispatcherHash.Add(entitiesWalked); + _dispatcherHash.Add(accepted.Count); + + foreach (var tuple in accepted) + { + uint cacheLandblockId = + WbDrawDispatcher.ResolveCacheLandblockHint( + tuple.Entity, + tuple.LandblockId); + if (!_dispatcherProjectionByEntity.TryGetValue( + tuple.Entity, + out CurrentRenderProjectionFingerprint projection) + || projection.LandblockId != cacheLandblockId) + { + projection = CreateFingerprint( + cacheLandblockId, + tuple.Entity, + ProjectionClassOf(tuple.Entity)); + _dispatcherProjectionByEntity[tuple.Entity] = projection; + } + var candidate = new CurrentRenderDispatcherFingerprint( + Sequence: _dispatcherCandidates.Count, + DrawSequence: drawSequence, + Set: set, + MeshRefIndex: tuple.MeshRefIndex, + TupleLandblockId: tuple.LandblockId, + CacheLandblockId: cacheLandblockId, + Projection: projection); + _dispatcherCandidates.Add(candidate); + AddDispatcherCandidate(ref _dispatcherHash, in candidate); + } + + Snapshot = Snapshot with + { + DispatcherFrameSequence = _dispatcherFrameSequence, + AbortedDispatcherFrames = _abortedDispatcherFrames, + DispatcherDrawCount = _dispatcherDrawCount, + DispatcherEntityCount = _dispatcherEntityCount, + DispatcherMeshRefCount = _dispatcherCandidates.Count, + DispatcherInstanceCount = _dispatcherInstanceCount, + DispatcherOpaqueGroupCount = _dispatcherOpaqueGroupCount, + DispatcherTransparentGroupCount = + _dispatcherTransparentGroupCount, + DispatcherDigest = _dispatcherHash.Finish(), + }; + } + + public void ObserveDispatcherSubmission( + in CurrentRenderDispatcherSubmission submission) + { + if (!_dispatcherFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle received dispatcher submission outside an open frame."); + } + if (_dispatcherDrawCount == 0) + { + throw new InvalidOperationException( + "A dispatcher submission cannot precede its accepted candidate set."); + } + + _dispatcherInstanceCount = checked( + _dispatcherInstanceCount + submission.VisibleInstanceCount); + _dispatcherOpaqueGroupCount = checked( + _dispatcherOpaqueGroupCount + submission.OpaqueGroupCount); + _dispatcherTransparentGroupCount = checked( + _dispatcherTransparentGroupCount + + submission.TransparentGroupCount); + _dispatcherHash.Add((byte)0xD2); + _dispatcherHash.Add(_dispatcherDrawCount - 1); + _dispatcherHash.Add(submission.VisibleInstanceCount); + _dispatcherHash.Add(submission.ImmediateInstanceCount); + _dispatcherHash.Add(submission.OpaqueGroupCount); + _dispatcherHash.Add(submission.TransparentGroupCount); + _dispatcherHash.Add(submission.TransparentDeferred); + _dispatcherHash.Add(submission.Digest.Low); + _dispatcherHash.Add(submission.Digest.High); + + Snapshot = Snapshot with + { + DispatcherInstanceCount = _dispatcherInstanceCount, + DispatcherOpaqueGroupCount = _dispatcherOpaqueGroupCount, + DispatcherTransparentGroupCount = + _dispatcherTransparentGroupCount, + DispatcherDigest = _dispatcherHash.Finish(), + }; + } + + public void AbortDispatcherFrame() + { + if (!_dispatcherFrameOpen) + return; + + _dispatcherFrameOpen = false; + _dispatcherCandidates.Clear(); + _dispatcherProjectionByEntity.Clear(); + _abortedDispatcherFrames = checked(_abortedDispatcherFrames + 1); + Snapshot = Snapshot with + { + AbortedDispatcherFrames = _abortedDispatcherFrames, + }; + } + + public void BeginSelectionFrame() + { + if (_selectionFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle cannot begin a second selection frame before completing or aborting the first."); + } + + _selectionFrameOpen = true; + _selectionParts.Clear(); + _selectionHash = StableRenderHash128.Create(); + } + + public void ObserveSelectionPart( + uint serverGuid, + uint localEntityId, + int partIndex, + uint gfxObjId, + Matrix4x4 localToWorld, + RetailSelectionMesh mesh) + { + if (!_selectionFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle received a selection part outside an open frame."); + } + ArgumentNullException.ThrowIfNull(mesh); + + StableRenderHash128 geometry = StableRenderHash128.Create(); + geometry.Add(mesh.SphereCenter); + geometry.Add(mesh.SphereRadius); + geometry.Add(mesh.Polygons.Count); + foreach (RetailSelectionPolygon polygon in mesh.Polygons) + { + geometry.Add(polygon.SingleSided); + geometry.Add(polygon.Vertices.Count); + foreach (Vector3 vertex in polygon.Vertices) + geometry.Add(vertex); + } + + var fingerprint = new CurrentRenderSelectionFingerprint( + Sequence: _selectionParts.Count, + ServerGuid: serverGuid, + LocalEntityId: localEntityId, + PartIndex: partIndex, + GfxObjId: gfxObjId, + LocalToWorld: localToWorld, + Geometry: geometry.Finish()); + _selectionParts.Add(fingerprint); + _selectionHash.Add(fingerprint.Sequence); + _selectionHash.Add(fingerprint.ServerGuid); + _selectionHash.Add(fingerprint.LocalEntityId); + _selectionHash.Add(fingerprint.PartIndex); + _selectionHash.Add(fingerprint.GfxObjId); + _selectionHash.Add(fingerprint.LocalToWorld); + _selectionHash.Add(fingerprint.Geometry.Low); + _selectionHash.Add(fingerprint.Geometry.High); + } + + public void CompleteSelectionFrame() + { + if (!_selectionFrameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle cannot complete selection without an open frame."); + } + + _selectionFrameOpen = false; + Snapshot = Snapshot with + { + CompletedSelectionFrameSequence = + checked(++_selectionFrameSequence), + AbortedSelectionFrames = _abortedSelectionFrames, + SelectionPartCount = _selectionParts.Count, + SelectionDigest = _selectionHash.Finish(), + }; + } + + public void AbortSelectionFrame() + { + if (!_selectionFrameOpen) + return; + + _selectionFrameOpen = false; + _selectionParts.Clear(); + _abortedSelectionFrames = checked(_abortedSelectionFrames + 1); + Snapshot = Snapshot with + { + AbortedSelectionFrames = _abortedSelectionFrames, + }; + } + private static CurrentRenderProjectionFingerprint CreateFingerprint( uint landblockId, WorldEntity entity, @@ -261,6 +775,40 @@ internal sealed class CurrentRenderSceneOracle : Appearance: appearance.Finish()); } + private static InteriorEntityPartition.ProjectionClass ProjectionClassOf( + WorldEntity entity) => + entity.ServerGuid != 0 + ? InteriorEntityPartition.ProjectionClass.Dynamic + : InteriorEntityPartition.IsIndoorCellId(entity.ParentCellId) + ? InteriorEntityPartition.ProjectionClass.CellStatic + : InteriorEntityPartition.ProjectionClass.OutdoorStatic; + + private static void AddPViewCandidate( + ref StableRenderHash128 hash, + in CurrentRenderPViewCandidateFingerprint candidate) + { + hash.Add(candidate.Sequence); + hash.Add((byte)candidate.Route); + hash.Add(candidate.RouteIndex); + hash.Add(candidate.CellId); + CurrentRenderProjectionFingerprint projection = candidate.Projection; + AddProjection(ref hash, in projection); + } + + private static void AddDispatcherCandidate( + ref StableRenderHash128 hash, + in CurrentRenderDispatcherFingerprint candidate) + { + hash.Add(candidate.Sequence); + hash.Add(candidate.DrawSequence); + hash.Add((int)candidate.Set); + hash.Add(candidate.MeshRefIndex); + hash.Add(candidate.TupleLandblockId); + hash.Add(candidate.CacheLandblockId); + CurrentRenderProjectionFingerprint projection = candidate.Projection; + AddProjection(ref hash, in projection); + } + private static void AddProjection( ref StableRenderHash128 hash, in CurrentRenderProjectionFingerprint projection) @@ -371,6 +919,12 @@ internal struct StableRenderHash128 Add(value.Z); } + public void Add(Vector2 value) + { + Add(value.X); + Add(value.Y); + } + public void Add(Quaternion value) { Add(value.X); diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index b0e22c02..b9e7082a 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Scene; using AcDream.Core.Selection; using AcDream.Core.World; @@ -29,6 +30,7 @@ internal sealed class RetailSelectionScene : private List _published = new(); private readonly HashSet _buildingKeys = new(); private FrustumPlanes? _viewFrustum; + private ICurrentRenderSelectionObserver? _currentRenderSceneObserver; private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId); @@ -56,9 +58,14 @@ internal sealed class RetailSelectionScene : out RetailSelectionLighting lighting) => _lightingPulse.TryGet(serverGuid, localEntityId, out lighting); + internal void SetCurrentRenderSceneObserver( + ICurrentRenderSelectionObserver? observer) => + _currentRenderSceneObserver = observer; + /// Clears every session-owned frame and material pulse. public void Reset() { + _currentRenderSceneObserver?.AbortSelectionFrame(); _building.Clear(); _published.Clear(); _buildingKeys.Clear(); @@ -71,6 +78,7 @@ internal sealed class RetailSelectionScene : _building.Clear(); _buildingKeys.Clear(); _viewFrustum = null; + _currentRenderSceneObserver?.BeginSelectionFrame(); } /// @@ -105,17 +113,26 @@ internal sealed class RetailSelectionScene : partIndex, partWorld, mesh)); + _currentRenderSceneObserver?.ObserveSelectionPart( + entity.ServerGuid, + entity.Id, + partIndex, + gfxObjId, + partWorld, + mesh); } public void CompleteFrame() { (_published, _building) = (_building, _published); + _currentRenderSceneObserver?.CompleteSelectionFrame(); } /// Discards the incomplete building frame and preserves the last /// completely published selection product. public void AbortFrame() { + _currentRenderSceneObserver?.AbortSelectionFrame(); _building.Clear(); _buildingKeys.Clear(); _viewFrustum = null; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index a887a0f5..c709a25b 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -4,6 +4,7 @@ using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using AcDream.App.Rendering.Residency; +using AcDream.App.Rendering.Scene; using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Rendering; @@ -97,6 +98,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private int _scratchPeakUnits; private readonly BindlessSupport _bindless; + private ICurrentRenderDispatcherObserver? _currentRenderSceneObserver; public readonly record struct DrawStats( EntitySet Set, @@ -843,8 +845,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _dynamicBufferSetCursor = 0; _dynamicFrameStarted = true; _activeDynamicBufferSet = null; + _currentRenderSceneObserver?.BeginDispatcherFrame(); } + internal void SetCurrentRenderSceneObserver( + ICurrentRenderDispatcherObserver? observer) => + _currentRenderSceneObserver = observer; + + internal void AbortCurrentRenderSceneObserverFrame() => + _currentRenderSceneObserver?.AbortDispatcherFrame(); + /// /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot /// (). Call once per frame BEFORE @@ -1471,6 +1481,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ref walkResult, probeState, set); + _currentRenderSceneObserver?.ObserveDispatcherDraw( + set, + walkResult.EntitiesWalked, + _walkScratch); // Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple // per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of @@ -1947,6 +1961,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (anyVao == 0) { LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0); + ObserveCurrentDispatcherSubmission( + visibleInstanceCount: 0, + immediateInstanceCount: 0, + deferTransparent: false); _cpuStopwatch.Stop(); if (diag) MaybeFlushDiag(); return; @@ -1965,6 +1983,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (totalInstances == 0) { LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0); + ObserveCurrentDispatcherSubmission( + visibleInstanceCount: 0, + immediateInstanceCount: 0, + deferTransparent); _cpuStopwatch.Stop(); if (diag) MaybeFlushDiag(); return; @@ -2085,6 +2107,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _opaqueDrawCount, _transparentDrawCount, totalTriangles); + ObserveCurrentDispatcherSubmission( + totalInstances, + immediateInstances, + deferTransparent); // ── Phase 5: upload four buffers ──────────────────────────────────── ActivateNextDynamicBufferSet(); @@ -2422,6 +2448,75 @@ public sealed unsafe class WbDrawDispatcher : IDisposable g.Translucency, g.CullMode); + private void ObserveCurrentDispatcherSubmission( + int visibleInstanceCount, + int immediateInstanceCount, + bool deferTransparent) + { + ICurrentRenderDispatcherObserver? observer = + _currentRenderSceneObserver; + if (observer is null) + return; + + int opaqueGroupCount = + visibleInstanceCount == 0 ? 0 : _opaqueDraws.Count; + int transparentGroupCount = + visibleInstanceCount == 0 ? 0 : _translucentDraws.Count; + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(visibleInstanceCount); + hash.Add(immediateInstanceCount); + hash.Add(opaqueGroupCount); + hash.Add(transparentGroupCount); + hash.Add(deferTransparent); + if (visibleInstanceCount != 0) + { + foreach (InstanceGroup group in _opaqueDraws) + AddSubmissionGroup(ref hash, group); + foreach (InstanceGroup group in _translucentDraws) + AddSubmissionGroup(ref hash, group); + } + + var submission = new CurrentRenderDispatcherSubmission( + VisibleInstanceCount: visibleInstanceCount, + ImmediateInstanceCount: immediateInstanceCount, + OpaqueGroupCount: opaqueGroupCount, + TransparentGroupCount: transparentGroupCount, + TransparentDeferred: deferTransparent, + Digest: hash.Finish()); + observer.ObserveDispatcherSubmission(in submission); + } + + private static void AddSubmissionGroup( + ref StableRenderHash128 hash, + InstanceGroup group) + { + GroupKey key = ToKey(group); + hash.Add(key.FirstIndex); + hash.Add(key.BaseVertex); + hash.Add(key.IndexCount); + hash.Add(key.BindlessTextureHandle); + hash.Add(key.TextureLayer); + hash.Add((int)key.Translucency); + hash.Add((int)key.CullMode); + hash.Add(group.Matrices.Count); + for (int index = 0; index < group.Matrices.Count; index++) + { + hash.Add(group.Matrices[index]); + hash.Add(group.LocalSortCenters[index]); + hash.Add(group.Slots[index]); + InstanceLightSet lights = group.LightSets[index]; + for (int lightIndex = 0; + lightIndex < LightManager.MaxLightsPerObject; + lightIndex++) + { + hash.Add(lights[lightIndex]); + } + hash.Add(group.IndoorFlags[index]); + hash.Add(group.Opacities[index]); + hash.Add(group.SelectionLighting[index]); + } + } + private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection) { RetailAlphaQueue queue = _alphaQueue!; diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs index cb5e9a8a..081cd5e9 100644 --- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -299,6 +299,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor TryAbort(_frameGlState.RestoreFrameDefaults); TryAbort(_clipFrame.Reset); TryAbort(_entities.ClearClipRouting); + TryAbort(_entities.AbortCurrentRenderSceneObserverFrame); TryAbort(() => _environmentCells.SetClipRouting(null)); _visibleParticleOwners.Clear(); if (failures is { Count: > 0 }) diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 355f7302..9c2dfb2f 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -126,7 +126,24 @@ public sealed class WorldLifecycleAutomationControllerTests CellBucketCount: 4, Digest: new RenderSceneHash128( Low: 0x0123456789ABCDEF, - High: 0xFEDCBA9876543210)), + High: 0xFEDCBA9876543210), + CompletedPViewFrameSequence: 19, + AbortedPViewFrames: 0, + PViewCandidateCount: 61, + PViewDigest: new RenderSceneHash128(11, 12), + DispatcherFrameSequence: 19, + AbortedDispatcherFrames: 0, + DispatcherDrawCount: 3, + DispatcherEntityCount: 52, + DispatcherMeshRefCount: 80, + DispatcherInstanceCount: 96, + DispatcherOpaqueGroupCount: 11, + DispatcherTransparentGroupCount: 4, + DispatcherDigest: new RenderSceneHash128(13, 14), + CompletedSelectionFrameSequence: 19, + AbortedSelectionFrames: 0, + SelectionPartCount: 7, + SelectionDigest: new RenderSceneHash128(15, 16)), TrackedGpuBytes = 1234, Residency = new ResidencySnapshot( [ diff --git a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs index 6eff1724..62435b46 100644 --- a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Wb; using AcDream.Core.World; namespace AcDream.App.Tests.Rendering; @@ -225,6 +226,143 @@ public sealed class CurrentRenderSceneOracleTests Assert.Empty(oracle.Projections); } + [Fact] + public void PViewDigestNamesRouteOrderAndRejectsObjectsOutsidePartition() + { + WorldEntity cellStatic = Entity( + id: 50, + serverGuid: 0, + parentCell: CellA); + WorldEntity dynamic = Entity( + id: 1_000_050, + serverGuid: 0x8000_0050, + parentCell: CellA); + var oracle = new CurrentRenderSceneOracle(); + var result = new InteriorEntityPartition.Result(); + InteriorEntityPartition.Partition( + result, + new HashSet { CellA }, + new[] { Entry(LandblockA, cellStatic, dynamic) }, + oracle); + + oracle.BeginPViewFrame(); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.CellStatic, + routeIndex: 0, + CellA, + [cellStatic]); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.DynamicLast, + routeIndex: 0, + cellId: 0, + [dynamic]); + oracle.CompletePViewFrame(); + CurrentRenderSceneOracleSnapshot first = oracle.Snapshot; + + oracle.BeginPViewFrame(); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.DynamicLast, + routeIndex: 0, + cellId: 0, + [dynamic]); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.CellStatic, + routeIndex: 0, + CellA, + [cellStatic]); + oracle.CompletePViewFrame(); + CurrentRenderSceneOracleSnapshot second = oracle.Snapshot; + + Assert.Equal(2, first.PViewCandidateCount); + Assert.NotEqual(first.PViewDigest, second.PViewDigest); + Assert.Equal( + first.CompletedPViewFrameSequence + 1, + second.CompletedPViewFrameSequence); + + oracle.BeginPViewFrame(); + InvalidOperationException error = Assert.Throws( + () => oracle.ObservePViewBucket( + CurrentRenderPViewRoute.CellStatic, + routeIndex: 0, + CellA, + [Entity(51, 0, CellA)])); + Assert.Contains("absent from the current partition", error.Message); + oracle.AbortPViewFrame(); + } + + [Fact] + public void DispatcherDigestResetsPerFrameAndRecordsAcceptedMeshRefs() + { + WorldEntity entity = Entity( + id: 60, + serverGuid: 0, + parentCell: CellA); + var accepted = new List<( + WorldEntity Entity, + int MeshRefIndex, + uint LandblockId)> + { + (entity, 0, LandblockB), + }; + var oracle = new CurrentRenderSceneOracle(); + + oracle.BeginDispatcherFrame(); + oracle.ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet.All, + entitiesWalked: 1, + accepted); + var submission = new CurrentRenderDispatcherSubmission( + VisibleInstanceCount: 3, + ImmediateInstanceCount: 2, + OpaqueGroupCount: 1, + TransparentGroupCount: 1, + TransparentDeferred: true, + Digest: new RenderSceneHash128(101, 202)); + oracle.ObserveDispatcherSubmission(in submission); + CurrentRenderSceneOracleSnapshot first = oracle.Snapshot; + + Assert.Equal(1uL, first.DispatcherFrameSequence); + Assert.Equal(1, first.DispatcherDrawCount); + Assert.Equal(1, first.DispatcherEntityCount); + Assert.Equal(1, first.DispatcherMeshRefCount); + Assert.Equal(3, first.DispatcherInstanceCount); + Assert.Equal(1, first.DispatcherOpaqueGroupCount); + Assert.Equal(1, first.DispatcherTransparentGroupCount); + CurrentRenderDispatcherFingerprint fingerprint = + Assert.Single(oracle.DispatcherCandidates); + Assert.Equal(LandblockB, fingerprint.TupleLandblockId); + Assert.Equal(LandblockA, fingerprint.CacheLandblockId); + + oracle.BeginDispatcherFrame(); + CurrentRenderSceneOracleSnapshot reset = oracle.Snapshot; + + Assert.Equal(2uL, reset.DispatcherFrameSequence); + Assert.Equal(0, reset.DispatcherDrawCount); + Assert.Equal(0, reset.DispatcherMeshRefCount); + Assert.Empty(oracle.DispatcherCandidates); + Assert.NotEqual(first.DispatcherDigest, reset.DispatcherDigest); + } + + [Fact] + public void DispatcherAbortDiscardsIncompleteCandidates() + { + WorldEntity entity = Entity( + id: 70, + serverGuid: 0x8000_0070, + parentCell: CellA); + var oracle = new CurrentRenderSceneOracle(); + + oracle.BeginDispatcherFrame(); + oracle.ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet.All, + entitiesWalked: 1, + [(entity, 0, LandblockA)]); + oracle.AbortDispatcherFrame(); + + Assert.Equal(1, oracle.Snapshot.AbortedDispatcherFrames); + Assert.Empty(oracle.DispatcherCandidates); + } + private static CurrentRenderProjectionFingerprint CaptureSingle( WorldEntity entity, uint landblockId, diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index d60fb5af..fcb705d3 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using System.Reflection; using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.Core.World; namespace AcDream.App.Tests.Rendering; @@ -126,6 +127,37 @@ public sealed class RetailPViewPassExecutorTests Assert.Contains("phantom-objects", executor.Operations); } + [Fact] + public void DrawInside_referee_records_the_exact_routed_entity_buckets() + { + var oracle = new CurrentRenderSceneOracle(); + var renderer = new RetailPViewRenderer(oracle); + using var executor = new RecordingExecutor(); + LoadedCell interior = InteriorWithExit(0xA9B40100u); + WorldEntity cellStatic = Entity(20u, parentCellId: interior.CellId); + WorldEntity dynamic = Entity( + 21u, + serverGuid: 0x80000021u, + parentCellId: interior.CellId); + + renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor); + + Assert.Equal(1uL, oracle.Snapshot.CompletedPViewFrameSequence); + Assert.Equal(2, oracle.Snapshot.PViewCandidateCount); + Assert.Collection( + oracle.PViewCandidates, + candidate => + { + Assert.Equal(CurrentRenderPViewRoute.CellStatic, candidate.Route); + Assert.Equal(cellStatic.Id, candidate.Projection.EntityId); + }, + candidate => + { + Assert.Equal(CurrentRenderPViewRoute.DynamicLast, candidate.Route); + Assert.Equal(dynamic.Id, candidate.Projection.EntityId); + }); + } + [Fact] public void DrawInside_interior_root_executes_the_nearby_building_look_in_punch() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs index 5b02b0f3..88ed009a 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; using AcDream.Core.Selection; using AcDream.Core.World; @@ -68,6 +69,31 @@ public sealed class RetailSelectionSceneTests Assert.Equal(published.Id, hit.Value.LocalEntityId); } + [Fact] + public void CurrentPathRefereeRecordsOnlyAcceptedSelectionParts() + { + var oracle = new CurrentRenderSceneOracle(); + var scene = CreateScene(); + scene.SetCurrentRenderSceneObserver(oracle); + var entity = Entity(localId: 48u, serverGuid: 0x5000_0048u); + + Publish(scene, entity); + + Assert.Equal(1uL, oracle.Snapshot.CompletedSelectionFrameSequence); + Assert.Equal(1, oracle.Snapshot.SelectionPartCount); + CurrentRenderSelectionFingerprint part = + Assert.Single(oracle.SelectionParts); + Assert.Equal(entity.ServerGuid, part.ServerGuid); + Assert.Equal(entity.Id, part.LocalEntityId); + Assert.Equal(0, part.PartIndex); + Assert.Equal(0x0100_0001u, part.GfxObjId); + + scene.BeginFrame(); + scene.AbortFrame(); + Assert.Equal(1, oracle.Snapshot.AbortedSelectionFrames); + Assert.Empty(oracle.SelectionParts); + } + private static RetailSelectionScene CreateScene( RetailSelectionLightingPulse? pulse = null) {