From b2b67341ac1016daca979e6a3dd0ca48ff9da074 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 21:13:11 +0200 Subject: [PATCH] feat(rendering): establish current-path scene referee Capture deterministic, landblock-aware fingerprints from the accepted partition only during lifecycle automation. This gives the F/G shadow scene an independent oracle without changing normal draw decisions or adding disabled-path per-entity cost. --- ...-modern-runtime-slices-f-g-render-scene.md | 14 + .../Composition/FrameRootComposition.cs | 11 +- .../WorldLifecycleAutomationController.cs | 2 + .../WorldLifecycleResourceSnapshotSource.cs | 9 +- .../Rendering/InteriorEntityPartition.cs | 94 +++++ .../Rendering/RetailPViewRenderer.cs | 17 +- .../Scene/CurrentRenderSceneOracle.cs | 389 ++++++++++++++++++ ...WorldLifecycleAutomationControllerTests.cs | 34 +- .../CurrentRenderSceneOracleTests.cs | 280 +++++++++++++ 9 files changed, 845 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs create mode 100644 tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs 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 179199b6..e47e7d15 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 @@ -12,6 +12,20 @@ visibility, clipping, draw order, selection, lighting, animation, or retail-faithful gameplay +## 0. Execution ledger + +| 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. | +| G0–G5 | pending | No production consumer has switched. | + +The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0a 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. + ## 1. Decision record ### 1.1 Why this work has a separate approval gate diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 034d063e..84c3caaf 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -1,6 +1,7 @@ using AcDream.App.Diagnostics; using AcDream.App.Input; using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; using AcDream.App.Settings; using AcDream.App.Update; @@ -368,6 +369,11 @@ internal sealed class FrameRootCompositionPhase live.SkyRenderer, content.ParticleSystem, live.ParticleRenderer); + CurrentRenderSceneOracle? currentRenderSceneOracle = + interaction.RetainedUi?.Screenshots is not null + && d.Options.AutomationArtifactDirectory is not null + ? new CurrentRenderSceneOracle() + : null; var worldSceneRenderer = new WorldSceneRenderer( renderFrameResources, renderLoginState, @@ -378,7 +384,7 @@ internal sealed class FrameRootCompositionPhase d.RetailAlphaQueue, d.ParticleVisibility, new WorldScenePViewRenderer( - new RetailPViewRenderer(), + new RetailPViewRenderer(currentRenderSceneOracle), retailPViewPassExecutor, retailPViewPassExecutor), retailPViewCells, @@ -410,7 +416,8 @@ internal sealed class FrameRootCompositionPhase live.DrawDispatcher, d.FrameProfiler, content.Dats, - foundation.Residency); + foundation.Residency, + currentRenderSceneOracle); lifecycleAutomation = new WorldLifecycleAutomationController( () => session.WorldReveal.Snapshot, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 3a19133e..7b653595 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -1,6 +1,7 @@ using System.Text.Json; using AcDream.App.Rendering; using AcDream.App.Rendering.Residency; +using AcDream.App.Rendering.Scene; using AcDream.App.Streaming; using AcDream.App.UI.Testing; @@ -38,6 +39,7 @@ internal sealed record WorldLifecycleResourceSnapshot( int TotalLandblocks, int LiveEntities, int MaterializedLiveEntities, + CurrentRenderSceneOracleSnapshot RenderSceneOracle, int PendingLiveTeardowns, int PendingLandblockRetirements, int ParticleEmitters, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index 124242b4..7371b824 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -1,4 +1,5 @@ using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Residency; @@ -38,6 +39,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource private readonly FrameProfiler _frameProfiler; private readonly IDatReaderWriter _dats; private readonly ResidencyManager _residency; + private readonly ICurrentRenderSceneOracleSnapshotSource? + _renderSceneOracle; public WorldLifecycleResourceSnapshotSource( GpuWorldState world, @@ -55,7 +58,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource WbDrawDispatcher dispatcher, FrameProfiler frameProfiler, IDatReaderWriter dats, - ResidencyManager residency) + ResidencyManager residency, + ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null) { _world = world ?? throw new ArgumentNullException(nameof(world)); _animations = animations @@ -82,6 +86,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _residency = residency ?? throw new ArgumentNullException(nameof(residency)); + _renderSceneOracle = renderSceneOracle; } public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome) @@ -122,6 +127,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource TotalLandblocks: outcome.World.TotalLandblocks, LiveEntities: _liveEntities.Count, MaterializedLiveEntities: _liveEntities.MaterializedCount, + RenderSceneOracle: _renderSceneOracle?.Snapshot + ?? CurrentRenderSceneOracleSnapshot.Disabled, PendingLiveTeardowns: _liveEntities.PendingTeardownCount, PendingLandblockRetirements: _streaming.PendingRetirementCount, ParticleEmitters: _particles.ActiveEmitterCount, diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 150345da..08da6b15 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -30,6 +30,27 @@ namespace AcDream.App.Rendering; /// public static class InteriorEntityPartition { + internal enum ProjectionClass : byte + { + OutdoorStatic, + CellStatic, + Dynamic, + } + + internal interface IObserver + { + void BeginFrame(); + + void Observe( + uint landblockId, + WorldEntity entity, + ProjectionClass projectionClass); + + void Complete(Result result); + + void AbortFrame(); + } + public sealed class Result { public Dictionary> ByCell { get; } = new(); @@ -143,6 +164,79 @@ public static class InteriorEntityPartition result.PruneEmptyCellBuckets(); } + /// + /// Current-path referee overload. The observer sees the exact branch that + /// populated each accepted partition entry, including its owning + /// landblock. A null observer is the production fast path and performs no + /// diagnostic allocation or hashing. + /// + internal static void Partition( + Result result, + HashSet visibleCells, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries, + IObserver? observer) + { + if (observer is null) + { + Partition(result, visibleCells, landblockEntries); + return; + } + + observer.BeginFrame(); + try + { + result.ClearForReuse(); + foreach (var entry in landblockEntries) + { + foreach (var e in entry.Entities) + { + if (e.MeshRefs.Count == 0) continue; + + // Retail contract: every server-spawned entity is a + // DYNAMIC and draws in the last pass, regardless of cell. + if (e.ServerGuid != 0) + { + result.Dynamics.Add(e); + observer.Observe( + entry.LandblockId, + e, + ProjectionClass.Dynamic); + } + else if (e.ParentCellId is uint cell && IsIndoorCellId(cell)) + { + if (!visibleCells.Contains(cell)) + continue; + if (!result.ByCell.TryGetValue(cell, out var list)) + result.ByCell[cell] = list = new List(); + list.Add(e); + observer.Observe( + entry.LandblockId, + e, + ProjectionClass.CellStatic); + } + else + { + result.OutdoorStatic.Add(e); + observer.Observe( + entry.LandblockId, + e, + ProjectionClass.OutdoorStatic); + } + } + } + + result.PruneEmptyCellBuckets(); + observer.Complete(result); + } + catch + { + observer.AbortFrame(); + throw; + } + } + /// Shared indoor classification — keep DrawDynamicsLast, the /// outside-stage assignment (#118), and the partition in lockstep. public static bool IsIndoorCellId(uint cellId) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 70832eef..606dda46 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -12,6 +12,7 @@ namespace AcDream.App.Rendering; /// public sealed class RetailPViewRenderer { + private readonly InteriorEntityPartition.IObserver? _partitionObserver; private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ViewconeCuller _viewconeScratch = new(); @@ -63,6 +64,16 @@ public sealed class RetailPViewRenderer private readonly HashSet _drawableCellsScratch = new(); private readonly RetailPViewScratchRetention _scratchRetention = new(); + public RetailPViewRenderer() + { + } + + internal RetailPViewRenderer( + InteriorEntityPartition.IObserver? partitionObserver) + { + _partitionObserver = partitionObserver; + } + // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the @@ -156,7 +167,11 @@ public sealed class RetailPViewRenderer passes.PrepareCellBatches(ctx, prepareCells); - InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); + InteriorEntityPartition.Partition( + _partitionResult, + prepareCells, + ctx.LandblockEntries, + _partitionObserver); var partition = _partitionResult; RetailPViewFrameResult result = _frameResultScratch.Reset( pvFrame, diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs new file mode 100644 index 00000000..ffd2b10e --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -0,0 +1,389 @@ +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Scene; + +internal readonly record struct RenderSceneHash128(ulong Low, ulong High) +{ + public static RenderSceneHash128 Empty { get; } = new(0, 0); + + public override string ToString() => $"{High:X16}{Low:X16}"; +} + +internal readonly record struct CurrentRenderProjectionFingerprint( + InteriorEntityPartition.ProjectionClass ProjectionClass, + uint LandblockId, + uint EntityId, + uint ServerGuid, + uint SourceId, + uint ParentCellId, + uint EffectCellId, + uint BuildingShellAnchorCellId, + uint Flags, + int MeshCount, + RenderSceneHash128 Transform, + RenderSceneHash128 Geometry, + RenderSceneHash128 Appearance); + +internal readonly record struct CurrentRenderSceneOracleSnapshot( + bool Enabled, + ulong CompletedFrameSequence, + int AbortedFrames, + int ProjectionCount, + int OutdoorStaticCount, + int CellStaticCount, + int DynamicCount, + int CellBucketCount, + RenderSceneHash128 Digest) +{ + public static CurrentRenderSceneOracleSnapshot Disabled { get; } = new( + Enabled: false, + CompletedFrameSequence: 0, + AbortedFrames: 0, + ProjectionCount: 0, + OutdoorStaticCount: 0, + CellStaticCount: 0, + DynamicCount: 0, + CellBucketCount: 0, + Digest: RenderSceneHash128.Empty); +} + +internal interface ICurrentRenderSceneOracleSnapshotSource +{ + CurrentRenderSceneOracleSnapshot Snapshot { get; } +} + +/// +/// Deterministic fingerprint of the already-accepted +/// output. This is the Slice-F referee: +/// it records current behavior before a shadow render scene exists, and never +/// makes a draw or visibility decision itself. +/// +/// Collection storage is retained across frames. Production composition does +/// not construct this owner unless lifecycle automation is enabled, so normal +/// rendering pays no hashing, sorting, or allocation cost. +/// +internal sealed class CurrentRenderSceneOracle : + InteriorEntityPartition.IObserver, + ICurrentRenderSceneOracleSnapshotSource +{ + private readonly List _projections = []; + private ulong _frameSequence; + private int _abortedFrames; + private int _outdoorStaticCount; + private int _cellStaticCount; + private int _dynamicCount; + private bool _frameOpen; + + public CurrentRenderSceneOracleSnapshot Snapshot { get; private set; } = + CurrentRenderSceneOracleSnapshot.Disabled with { Enabled = true }; + + internal IReadOnlyList Projections => + _projections; + + public void BeginFrame() + { + if (_frameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle cannot begin a second frame before completing or aborting the first."); + } + + _frameOpen = true; + _projections.Clear(); + _outdoorStaticCount = 0; + _cellStaticCount = 0; + _dynamicCount = 0; + } + + public void Observe( + uint landblockId, + WorldEntity entity, + InteriorEntityPartition.ProjectionClass projectionClass) + { + if (!_frameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle received a projection outside an open frame."); + } + ArgumentNullException.ThrowIfNull(entity); + + switch (projectionClass) + { + case InteriorEntityPartition.ProjectionClass.OutdoorStatic: + _outdoorStaticCount++; + break; + case InteriorEntityPartition.ProjectionClass.CellStatic: + _cellStaticCount++; + break; + case InteriorEntityPartition.ProjectionClass.Dynamic: + _dynamicCount++; + break; + default: + throw new ArgumentOutOfRangeException( + nameof(projectionClass), + projectionClass, + "Unknown current render projection class."); + } + + _projections.Add(CreateFingerprint( + landblockId, + entity, + projectionClass)); + } + + public void Complete(InteriorEntityPartition.Result result) + { + ArgumentNullException.ThrowIfNull(result); + if (!_frameOpen) + { + throw new InvalidOperationException( + "The current render-scene oracle cannot complete without an open frame."); + } + + int cellStaticCount = 0; + foreach (List entities in result.ByCell.Values) + cellStaticCount = checked(cellStaticCount + entities.Count); + if (result.OutdoorStatic.Count != _outdoorStaticCount + || cellStaticCount != _cellStaticCount + || result.Dynamics.Count != _dynamicCount + || _projections.Count + != checked(_outdoorStaticCount + _cellStaticCount + _dynamicCount)) + { + throw new InvalidOperationException( + "The current render-scene oracle diverged from the partition it observed."); + } + + _projections.Sort(CurrentRenderProjectionFingerprintComparer.Instance); + StableRenderHash128 aggregate = StableRenderHash128.Create(); + aggregate.Add(_projections.Count); + foreach (CurrentRenderProjectionFingerprint projection in _projections) + AddProjection(ref aggregate, in projection); + + _frameOpen = false; + Snapshot = new CurrentRenderSceneOracleSnapshot( + Enabled: true, + CompletedFrameSequence: checked(++_frameSequence), + AbortedFrames: _abortedFrames, + ProjectionCount: _projections.Count, + OutdoorStaticCount: _outdoorStaticCount, + CellStaticCount: _cellStaticCount, + DynamicCount: _dynamicCount, + CellBucketCount: result.ByCell.Count, + Digest: aggregate.Finish()); + } + + public void AbortFrame() + { + if (!_frameOpen) + return; + + _frameOpen = false; + _projections.Clear(); + _outdoorStaticCount = 0; + _cellStaticCount = 0; + _dynamicCount = 0; + _abortedFrames = checked(_abortedFrames + 1); + Snapshot = Snapshot with { AbortedFrames = _abortedFrames }; + } + + private static CurrentRenderProjectionFingerprint CreateFingerprint( + uint landblockId, + WorldEntity entity, + InteriorEntityPartition.ProjectionClass projectionClass) + { + StableRenderHash128 transform = StableRenderHash128.Create(); + transform.Add(entity.Position); + transform.Add(entity.Rotation); + transform.Add(entity.Scale); + + StableRenderHash128 geometry = StableRenderHash128.Create(); + geometry.Add(entity.MeshRefs.Count); + foreach (MeshRef mesh in entity.MeshRefs) + { + geometry.Add(mesh.GfxObjId); + geometry.Add(mesh.PartTransform); + } + + StableRenderHash128 appearance = StableRenderHash128.Create(); + if (entity.PaletteOverride is { } palette) + { + appearance.Add(true); + appearance.Add(palette.BasePaletteId); + appearance.Add(palette.SubPalettes.Count); + foreach (PaletteOverride.SubPaletteRange range in palette.SubPalettes) + { + appearance.Add(range.SubPaletteId); + appearance.Add(range.Offset); + appearance.Add(range.Length); + } + } + else + { + appearance.Add(false); + } + + appearance.Add(entity.PartOverrides.Count); + foreach (PartOverride part in entity.PartOverrides) + { + appearance.Add(part.PartIndex); + appearance.Add(part.GfxObjId); + } + appearance.Add(entity.HiddenPartsMask); + + uint flags = 0; + if (entity.IsDrawVisible) + flags |= 1u << 0; + if (entity.IsAncestorDrawVisible) + flags |= 1u << 1; + if (entity.IsBuildingShell) + flags |= 1u << 2; + if (entity.ParentCellId.HasValue) + flags |= 1u << 3; + if (entity.EffectCellId.HasValue) + flags |= 1u << 4; + if (entity.BuildingShellAnchorCellId.HasValue) + flags |= 1u << 5; + + return new CurrentRenderProjectionFingerprint( + ProjectionClass: projectionClass, + LandblockId: landblockId, + EntityId: entity.Id, + ServerGuid: entity.ServerGuid, + SourceId: entity.SourceGfxObjOrSetupId, + ParentCellId: entity.ParentCellId ?? 0, + EffectCellId: entity.EffectCellId ?? 0, + BuildingShellAnchorCellId: entity.BuildingShellAnchorCellId ?? 0, + Flags: flags, + MeshCount: entity.MeshRefs.Count, + Transform: transform.Finish(), + Geometry: geometry.Finish(), + Appearance: appearance.Finish()); + } + + private static void AddProjection( + ref StableRenderHash128 hash, + in CurrentRenderProjectionFingerprint projection) + { + hash.Add((byte)projection.ProjectionClass); + hash.Add(projection.LandblockId); + hash.Add(projection.EntityId); + hash.Add(projection.ServerGuid); + hash.Add(projection.SourceId); + hash.Add(projection.ParentCellId); + hash.Add(projection.EffectCellId); + hash.Add(projection.BuildingShellAnchorCellId); + hash.Add(projection.Flags); + hash.Add(projection.MeshCount); + hash.Add(projection.Transform.Low); + hash.Add(projection.Transform.High); + hash.Add(projection.Geometry.Low); + hash.Add(projection.Geometry.High); + hash.Add(projection.Appearance.Low); + hash.Add(projection.Appearance.High); + } + + private sealed class CurrentRenderProjectionFingerprintComparer : + IComparer + { + public static CurrentRenderProjectionFingerprintComparer Instance { get; } = + new(); + + public int Compare( + CurrentRenderProjectionFingerprint x, + CurrentRenderProjectionFingerprint y) + { + int value = x.ProjectionClass.CompareTo(y.ProjectionClass); + if (value != 0) return value; + value = x.LandblockId.CompareTo(y.LandblockId); + if (value != 0) return value; + value = x.ParentCellId.CompareTo(y.ParentCellId); + if (value != 0) return value; + value = x.EntityId.CompareTo(y.EntityId); + if (value != 0) return value; + value = x.ServerGuid.CompareTo(y.ServerGuid); + if (value != 0) return value; + value = x.SourceId.CompareTo(y.SourceId); + if (value != 0) return value; + value = x.EffectCellId.CompareTo(y.EffectCellId); + if (value != 0) return value; + value = x.BuildingShellAnchorCellId.CompareTo( + y.BuildingShellAnchorCellId); + if (value != 0) return value; + value = x.Flags.CompareTo(y.Flags); + if (value != 0) return value; + value = x.MeshCount.CompareTo(y.MeshCount); + if (value != 0) return value; + value = x.Transform.High.CompareTo(y.Transform.High); + if (value != 0) return value; + value = x.Transform.Low.CompareTo(y.Transform.Low); + if (value != 0) return value; + value = x.Geometry.High.CompareTo(y.Geometry.High); + if (value != 0) return value; + value = x.Geometry.Low.CompareTo(y.Geometry.Low); + if (value != 0) return value; + value = x.Appearance.High.CompareTo(y.Appearance.High); + if (value != 0) return value; + return x.Appearance.Low.CompareTo(y.Appearance.Low); + } + } +} + +internal struct StableRenderHash128 +{ + private const ulong LowOffset = 14695981039346656037UL; + private const ulong LowPrime = 1099511628211UL; + private const ulong HighOffset = 7809847782465536322UL; + private const ulong HighPrime = 14029467366897019727UL; + + private ulong _low; + private ulong _high; + + public static StableRenderHash128 Create() => new() + { + _low = LowOffset, + _high = HighOffset, + }; + + public readonly RenderSceneHash128 Finish() => new(_low, _high); + + public void Add(bool value) => Add(value ? (byte)1 : (byte)0); + + public void Add(byte value) => Add((ulong)value); + + public void Add(int value) => Add(unchecked((uint)value)); + + public void Add(uint value) => Add((ulong)value); + + public void Add(float value) => Add(BitConverter.SingleToUInt32Bits(value)); + + public void Add(ulong value) + { + _low = unchecked((_low ^ value) * LowPrime); + ulong folded = value ^ BitOperations.RotateLeft(value, 29); + _high = unchecked((_high ^ folded) * HighPrime); + } + + public void Add(Vector3 value) + { + Add(value.X); + Add(value.Y); + Add(value.Z); + } + + public void Add(Quaternion value) + { + Add(value.X); + Add(value.Y); + Add(value.Z); + Add(value.W); + } + + public void Add(Matrix4x4 value) + { + Add(value.M11); Add(value.M12); Add(value.M13); Add(value.M14); + Add(value.M21); Add(value.M22); Add(value.M23); Add(value.M24); + Add(value.M31); Add(value.M32); Add(value.M33); Add(value.M34); + Add(value.M41); Add(value.M42); Add(value.M43); Add(value.M44); + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index bf9450af..355f7302 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using AcDream.App.Diagnostics; using AcDream.App.Rendering; using AcDream.App.Rendering.Residency; +using AcDream.App.Rendering.Scene; using AcDream.App.Streaming; using AcDream.App.UI.Testing; using SixLabors.ImageSharp; @@ -114,6 +115,18 @@ public sealed class WorldLifecycleAutomationControllerTests { LoadedLandblocks = 1, WorldEntities = 42, + RenderSceneOracle = new CurrentRenderSceneOracleSnapshot( + Enabled: true, + CompletedFrameSequence: 19, + AbortedFrames: 1, + ProjectionCount: 73, + OutdoorStaticCount: 41, + CellStaticCount: 20, + DynamicCount: 12, + CellBucketCount: 4, + Digest: new RenderSceneHash128( + Low: 0x0123456789ABCDEF, + High: 0xFEDCBA9876543210)), TrackedGpuBytes = 1234, Residency = new ResidencySnapshot( [ @@ -166,6 +179,24 @@ public sealed class WorldLifecycleAutomationControllerTests Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation") .GetProperty("screenshotCaptured").GetBoolean()); Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32()); + JsonElement renderSceneOracle = json.RootElement + .GetProperty("resources") + .GetProperty("renderSceneOracle"); + Assert.True(renderSceneOracle.GetProperty("enabled").GetBoolean()); + Assert.Equal( + 19, + renderSceneOracle.GetProperty("completedFrameSequence").GetInt64()); + Assert.Equal( + 73, + renderSceneOracle.GetProperty("projectionCount").GetInt32()); + Assert.Equal( + 0x0123456789ABCDEFuL, + renderSceneOracle.GetProperty("digest").GetProperty("low") + .GetUInt64()); + Assert.Equal( + 0xFEDCBA9876543210uL, + renderSceneOracle.GetProperty("digest").GetProperty("high") + .GetUInt64()); JsonElement residency = json.RootElement .GetProperty("resources") .GetProperty("residency"); @@ -304,7 +335,8 @@ public sealed class WorldLifecycleAutomationControllerTests directory); private static WorldLifecycleResourceSnapshot EmptyResources() => new( - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.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, default, default, 0d, 0d, null); diff --git a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs new file mode 100644 index 00000000..6eff1724 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs @@ -0,0 +1,280 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class CurrentRenderSceneOracleTests +{ + private const uint LandblockA = 0xA9B4FFFF; + private const uint LandblockB = 0xA9B5FFFF; + private const uint CellA = 0xA9B40170; + private const uint CellB = 0xA9B50170; + + [Fact] + public void DigestIsStableAcrossLandblockEnumerationOrder() + { + WorldEntity outdoorA = Entity(id: 7, serverGuid: 0, parentCell: null); + WorldEntity cellStatic = Entity(id: 8, serverGuid: 0, parentCell: CellA); + WorldEntity live = Entity( + id: 1_000_001, + serverGuid: 0x8000_0001, + parentCell: CellA); + // Static IDs are landblock-local. The same id in another landblock is + // a distinct projection and must survive the referee sort. + WorldEntity outdoorB = Entity(id: 7, serverGuid: 0, parentCell: null); + + var firstEntries = new[] + { + Entry(LandblockA, outdoorA, cellStatic, live), + Entry(LandblockB, outdoorB), + }; + var secondEntries = new[] + { + Entry(LandblockB, outdoorB), + Entry(LandblockA, outdoorA, cellStatic, live), + }; + var visible = new HashSet { CellA }; + var result = new InteriorEntityPartition.Result(); + var oracle = new CurrentRenderSceneOracle(); + + InteriorEntityPartition.Partition( + result, + visible, + firstEntries, + oracle); + CurrentRenderSceneOracleSnapshot first = oracle.Snapshot; + + InteriorEntityPartition.Partition( + result, + visible, + secondEntries, + oracle); + CurrentRenderSceneOracleSnapshot second = oracle.Snapshot; + + Assert.True(first.Enabled); + Assert.Equal(4, first.ProjectionCount); + Assert.Equal(2, first.OutdoorStaticCount); + Assert.Equal(1, first.CellStaticCount); + Assert.Equal(1, first.DynamicCount); + Assert.Equal(1, first.CellBucketCount); + Assert.Equal(first.Digest, second.Digest); + Assert.Equal(first.CompletedFrameSequence + 1, second.CompletedFrameSequence); + Assert.Equal(2, oracle.Projections.Count( + projection => projection.EntityId == 7)); + Assert.Contains( + oracle.Projections, + projection => projection.EntityId == 7 + && projection.LandblockId == LandblockA); + Assert.Contains( + oracle.Projections, + projection => projection.EntityId == 7 + && projection.LandblockId == LandblockB); + } + + [Fact] + public void ObservedPartitionMatchesUninstrumentedProductionPath() + { + WorldEntity outdoor = Entity(id: 10, serverGuid: 0, parentCell: null); + WorldEntity cellStatic = Entity(id: 11, serverGuid: 0, parentCell: CellA); + WorldEntity dynamic = Entity( + id: 1_000_011, + serverGuid: 0x8000_0011, + parentCell: CellB); + WorldEntity hiddenStatic = Entity( + id: 12, + serverGuid: 0, + parentCell: CellB); + var entries = new[] + { + Entry(LandblockA, outdoor, cellStatic), + Entry(LandblockB, dynamic, hiddenStatic), + }; + var visible = new HashSet { CellA }; + var production = new InteriorEntityPartition.Result(); + var observed = new InteriorEntityPartition.Result(); + var oracle = new CurrentRenderSceneOracle(); + + InteriorEntityPartition.Partition(production, visible, entries); + InteriorEntityPartition.Partition(observed, visible, entries, oracle); + + Assert.Equal(production.ByCell.Keys, observed.ByCell.Keys); + foreach (uint cell in production.ByCell.Keys) + Assert.Equal(production.ByCell[cell], observed.ByCell[cell]); + Assert.Equal(production.OutdoorStatic, observed.OutdoorStatic); + Assert.Equal(production.Dynamics, observed.Dynamics); + } + + [Fact] + public void FingerprintsNameTransformGeometryAppearanceAndFlagChanges() + { + var visible = new HashSet { CellA, CellB }; + + CurrentRenderProjectionFingerprint baseline = CaptureSingle( + Entity(id: 20, serverGuid: 0, parentCell: CellA), + LandblockA, + visible); + CurrentRenderProjectionFingerprint transform = CaptureSingle( + Entity( + id: 20, + serverGuid: 0, + parentCell: CellA, + position: new Vector3(1, 2, 3)), + LandblockA, + visible); + CurrentRenderProjectionFingerprint geometry = CaptureSingle( + Entity( + id: 20, + serverGuid: 0, + parentCell: CellA, + meshId: 0x0100_2222), + LandblockA, + visible); + CurrentRenderProjectionFingerprint appearance = CaptureSingle( + Entity( + id: 20, + serverGuid: 0, + parentCell: CellA, + palette: new PaletteOverride( + 0x0400_0001, + [new PaletteOverride.SubPaletteRange(0x0400_0002, 3, 4)])), + LandblockA, + visible); + WorldEntity hidden = Entity(id: 20, serverGuid: 0, parentCell: CellA); + hidden.IsDrawVisible = false; + CurrentRenderProjectionFingerprint flags = CaptureSingle( + hidden, + LandblockA, + visible); + CurrentRenderProjectionFingerprint cell = CaptureSingle( + Entity(id: 20, serverGuid: 0, parentCell: CellB), + LandblockB, + visible); + + Assert.NotEqual(baseline.Transform, transform.Transform); + Assert.Equal(baseline.Geometry, transform.Geometry); + Assert.Equal(baseline.Appearance, transform.Appearance); + + Assert.NotEqual(baseline.Geometry, geometry.Geometry); + Assert.Equal(baseline.Transform, geometry.Transform); + + Assert.NotEqual(baseline.Appearance, appearance.Appearance); + Assert.NotEqual(baseline.Flags, flags.Flags); + Assert.NotEqual(baseline.ParentCellId, cell.ParentCellId); + Assert.NotEqual(baseline.LandblockId, cell.LandblockId); + } + + [Fact] + public void NonFloodedStaticIsAbsentButNonFloodedDynamicRemains() + { + WorldEntity hiddenStatic = Entity( + id: 30, + serverGuid: 0, + parentCell: CellB); + WorldEntity hiddenDynamic = Entity( + id: 1_000_030, + serverGuid: 0x8000_0030, + parentCell: CellB); + var oracle = new CurrentRenderSceneOracle(); + var result = new InteriorEntityPartition.Result(); + + InteriorEntityPartition.Partition( + result, + new HashSet { CellA }, + new[] { Entry(LandblockB, hiddenStatic, hiddenDynamic) }, + oracle); + + Assert.Equal(1, oracle.Snapshot.ProjectionCount); + Assert.Equal(0, oracle.Snapshot.CellStaticCount); + Assert.Equal(1, oracle.Snapshot.DynamicCount); + CurrentRenderProjectionFingerprint fingerprint = + Assert.Single(oracle.Projections); + Assert.Equal(hiddenDynamic.Id, fingerprint.EntityId); + Assert.Equal( + InteriorEntityPartition.ProjectionClass.Dynamic, + fingerprint.ProjectionClass); + } + + [Fact] + public void AbortPreservesLastCompleteDigestAndRecordsFailure() + { + var oracle = new CurrentRenderSceneOracle(); + var result = new InteriorEntityPartition.Result(); + WorldEntity entity = Entity(id: 40, serverGuid: 0, parentCell: null); + + InteriorEntityPartition.Partition( + result, + [], + new[] { Entry(LandblockA, entity) }, + oracle); + CurrentRenderSceneOracleSnapshot complete = oracle.Snapshot; + + oracle.BeginFrame(); + oracle.Observe( + LandblockA, + entity, + InteriorEntityPartition.ProjectionClass.OutdoorStatic); + oracle.AbortFrame(); + + Assert.Equal(complete.Digest, oracle.Snapshot.Digest); + Assert.Equal( + complete.CompletedFrameSequence, + oracle.Snapshot.CompletedFrameSequence); + Assert.Equal(complete.AbortedFrames + 1, oracle.Snapshot.AbortedFrames); + Assert.Empty(oracle.Projections); + } + + private static CurrentRenderProjectionFingerprint CaptureSingle( + WorldEntity entity, + uint landblockId, + HashSet visible) + { + var oracle = new CurrentRenderSceneOracle(); + var result = new InteriorEntityPartition.Result(); + InteriorEntityPartition.Partition( + result, + visible, + new[] { Entry(landblockId, entity) }, + oracle); + return Assert.Single(oracle.Projections); + } + + private static WorldEntity Entity( + uint id, + uint serverGuid, + uint? parentCell, + Vector3? position = null, + uint meshId = 0x0100_0001, + PaletteOverride? palette = null) => + new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = meshId, + Position = position ?? Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = + [ + new MeshRef( + meshId, + Matrix4x4.CreateTranslation(0.25f, 0.5f, 0.75f)), + ], + ParentCellId = parentCell, + PaletteOverride = palette, + }; + + private static ( + uint LandblockId, + Vector3 AabbMin, + Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById) + Entry(uint landblockId, params WorldEntity[] entities) => + ( + landblockId, + Vector3.Zero, + Vector3.One, + entities, + null); +}