The first commit converted the four members the issue named and left the other sites alone, reasoning that none had been observed failing. A 20-run complete-solution baseline disproved that within minutes: run 2 LiveEntityRuntimeTests.AnimationView_HotSpatialTraversal… run 14 StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization… run 18 StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization… run 19 CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint… Both new names are the same shape as the four — one warm call, then a thousand-iteration loop inside the measured window — and neither had been recorded anywhere. "Not observed failing" only ever meant "not yet observed", and leaving known-shape sites in place would have guaranteed the acceptance gate failed. Run 19 is the sharper lesson: the issue named `SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing`, and the first commit converted a *different* test in that same file, so the actually-named member was still on the old shape. Matching by file was not matching by test. Every strict-zero site in the assembly is now on the probe — ten tests. Two came out stricter rather than merely steadier: `StaticRenderProjectionJournalTests` was measuring a synchronise whose journal does **not** coalesce. Repeating it grew the journal by 1,000 entries per call — 192,000 by the end of a probe run — so the steady state the test claimed to measure did not exist and the single-call window had been hiding it. Its step is now the whole frame cycle, synchronise *and* drain, which puts `DrainTo` inside the measured window for the first time and asserts the journal ends empty. `RetailInboundEventDispatcherTests` asserted a hard-coded 1,001 callbacks. It now counts its own dispatches and pins the callback count against that, so the assertion still proves the fast path ran the callback every time without being coupled to a loop bound that no longer exists. Left alone deliberately: the four sites asserting a tolerance rather than zero — `CellViewDedupTests` and `PortalProjectionTests`. Their ceilings already absorb this noise and none has flaked; changing a bound in either direction is a separate decision from fixing a measurement. Worth noting that `PortalProjectionTests`' ceiling exists explicitly to tolerate "a tiered-JIT/ArrayPool bookkeeping transition ... to the first measured batch", which is exactly what the probe removes, so it could probably be tightened to zero now — recorded in the issue rather than done here. Solution build 0 warnings / 0 errors; App suite 3,941 passed / 3 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
463 lines
17 KiB
C#
463 lines
17 KiB
C#
using System.Collections.Immutable;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Scene.Arch;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class StaticRenderProjectionJournalTests
|
|
{
|
|
private const uint LandblockId = 0x1234FFFFu;
|
|
|
|
[Fact]
|
|
public void Reconcile_RegistersOnlyStaticEntitiesAndEnvCellShells()
|
|
{
|
|
RenderSceneGeneration generation = Generation(1);
|
|
var journal = new RenderProjectionJournal(generation);
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
WorldEntity staticEntity = Entity(7, position: new Vector3(1, 2, 3));
|
|
WorldEntity liveEntity = Entity(8, serverGuid: 0x80000008u);
|
|
LandblockBuild build = Build(
|
|
LandblockId,
|
|
[staticEntity, liveEntity],
|
|
includeShell: true);
|
|
var publication = Publication(build);
|
|
|
|
statics.Reconcile(build, publication);
|
|
|
|
Assert.Equal(2, statics.ProjectionCount);
|
|
Assert.Equal(2, journal.Count);
|
|
Assert.All(
|
|
journal.Pending.ToArray(),
|
|
delta => Assert.Equal(
|
|
RenderProjectionDeltaKind.Register,
|
|
delta.Kind));
|
|
|
|
using var scene = new ArchRenderScene(generation);
|
|
RenderDeltaApplyResult applied = journal.DrainTo(scene);
|
|
Assert.Equal(2, applied.Registered);
|
|
Assert.Equal(0, journal.Count);
|
|
Assert.Equal(2, scene.Counts.Total);
|
|
Assert.Equal(1, scene.Counts.OutdoorStatic);
|
|
Assert.Equal(1, scene.Counts.IndoorCellStatic);
|
|
|
|
RenderProjectionId staticId =
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
staticEntity.Id);
|
|
Assert.True(scene.OpenQuery().TryGet(staticId, out var projected));
|
|
CurrentRenderProjectionFingerprint expected =
|
|
CurrentRenderSceneOracle.CreateProjectionFingerprint(
|
|
LandblockId,
|
|
staticEntity);
|
|
Assert.Equal(expected.Transform, projected.Source.TransformFingerprint);
|
|
Assert.Equal(expected.Geometry, projected.Source.GeometryFingerprint);
|
|
Assert.Equal(expected.Appearance, projected.Source.AppearanceFingerprint);
|
|
Assert.Equal(LandblockId, projected.Residency.OwnerLandblockId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reconcile_SameLandblockRetainsUpdatesOmitsAndAddsDeterministically()
|
|
{
|
|
RenderSceneGeneration generation = Generation(2);
|
|
var journal = new RenderProjectionJournal(generation);
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
WorldEntity retained = Entity(1);
|
|
WorldEntity omitted = Entity(2);
|
|
LandblockBuild first = Build(
|
|
LandblockId,
|
|
[retained, omitted],
|
|
includeShell: true);
|
|
statics.Reconcile(first, Publication(first));
|
|
using var scene = new ArchRenderScene(generation);
|
|
journal.DrainTo(scene);
|
|
RenderProjectionId retainedId =
|
|
StaticRenderProjectionJournal.StaticEntityId(LandblockId, 1);
|
|
Assert.True(scene.OpenQuery().TryGet(retainedId, out var before));
|
|
|
|
WorldEntity moved = Entity(1, position: new Vector3(10, 0, 0));
|
|
WorldEntity added = Entity(3);
|
|
LandblockBuild replacement = Build(
|
|
LandblockId,
|
|
[moved, added],
|
|
includeShell: false);
|
|
statics.Reconcile(replacement, Publication(replacement));
|
|
|
|
Assert.Equal(4, journal.Count);
|
|
Assert.Equal(
|
|
[
|
|
RenderProjectionDeltaKind.UpdateTransform,
|
|
RenderProjectionDeltaKind.Register,
|
|
RenderProjectionDeltaKind.Unregister,
|
|
RenderProjectionDeltaKind.Unregister,
|
|
],
|
|
journal.Pending.ToArray().Select(delta => delta.Kind).ToArray());
|
|
|
|
journal.DrainTo(scene);
|
|
Assert.Equal(2, scene.Counts.Total);
|
|
Assert.True(scene.OpenQuery().TryGet(retainedId, out var after));
|
|
Assert.Equal(before.OwnerIncarnation, after.OwnerIncarnation);
|
|
Assert.Equal(moved.Position, after.Transform.Position);
|
|
Assert.False(scene.OpenQuery().TryGet(
|
|
StaticRenderProjectionJournal.StaticEntityId(LandblockId, 2),
|
|
out _));
|
|
Assert.False(scene.OpenQuery().TryGet(
|
|
StaticRenderProjectionJournal.EnvCellShellId(
|
|
LandblockId,
|
|
(LandblockId & 0xFFFF0000u) | 0x0100u),
|
|
out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectionIdentity_DistinguishesDuplicateLocalIdsAcrossLandblocks()
|
|
{
|
|
const uint otherLandblock = 0x5678FFFFu;
|
|
var journal = new RenderProjectionJournal(Generation(3));
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
LandblockBuild first = Build(
|
|
LandblockId,
|
|
[Entity(42)],
|
|
includeShell: false);
|
|
LandblockBuild second = Build(
|
|
otherLandblock,
|
|
[Entity(42)],
|
|
includeShell: false);
|
|
|
|
statics.Reconcile(first, Publication(first));
|
|
statics.Reconcile(second, Publication(second));
|
|
|
|
Assert.Equal(2, statics.ProjectionCount);
|
|
Assert.NotEqual(
|
|
StaticRenderProjectionJournal.StaticEntityId(LandblockId, 42),
|
|
StaticRenderProjectionJournal.StaticEntityId(otherLandblock, 42));
|
|
}
|
|
|
|
[Fact]
|
|
public void Reconcile_PreservesResidentTraversalOrderInSortKeys()
|
|
{
|
|
const uint secondLandblock = 0x5678FFFFu;
|
|
var journal = new RenderProjectionJournal(Generation(10));
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
WorldEntity first = Entity(99);
|
|
WorldEntity liveGap = Entity(77, serverGuid: 0x80000077u);
|
|
WorldEntity second = Entity(1);
|
|
LandblockBuild firstBuild = Build(
|
|
LandblockId,
|
|
[first, liveGap, second],
|
|
includeShell: false);
|
|
LandblockBuild secondBuild = Build(
|
|
secondLandblock,
|
|
[Entity(500)],
|
|
includeShell: false);
|
|
|
|
statics.Reconcile(firstBuild, Publication(firstBuild));
|
|
statics.Reconcile(
|
|
secondBuild,
|
|
Publication(secondBuild, renderTraversalOrder: 2));
|
|
|
|
Assert.True(statics.TryGet(
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
first.Id),
|
|
out RenderProjectionRecord firstRecord));
|
|
Assert.True(statics.TryGet(
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
second.Id),
|
|
out RenderProjectionRecord secondRecord));
|
|
Assert.True(statics.TryGet(
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
secondLandblock,
|
|
500),
|
|
out RenderProjectionRecord laterLandblockRecord));
|
|
Assert.True(firstRecord.SortKey.Value < secondRecord.SortKey.Value);
|
|
Assert.True(
|
|
secondRecord.SortKey.Value
|
|
< laterLandblockRecord.SortKey.Value);
|
|
Assert.Equal(
|
|
2u,
|
|
unchecked((uint)secondRecord.SortKey.Value));
|
|
|
|
LandblockBuild reordered = Build(
|
|
LandblockId,
|
|
[second, first],
|
|
includeShell: false);
|
|
statics.Reconcile(reordered, Publication(reordered));
|
|
|
|
Assert.True(statics.TryGet(
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
first.Id),
|
|
out firstRecord));
|
|
Assert.True(statics.TryGet(
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
second.Id),
|
|
out secondRecord));
|
|
Assert.True(secondRecord.SortKey.Value < firstRecord.SortKey.Value);
|
|
Assert.Equal(
|
|
2uL,
|
|
laterLandblockRecord.SortKey.Value >> 32);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleFarPublication_DoesNotMutateProjectionJournal()
|
|
{
|
|
var journal = new RenderProjectionJournal(Generation(4));
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
LandblockBuild build = Build(
|
|
LandblockId,
|
|
[Entity(1)],
|
|
includeShell: true);
|
|
var stale = Publication(build) with { RequiresActivation = false };
|
|
|
|
statics.Reconcile(build, stale);
|
|
|
|
Assert.Equal(0, statics.ProjectionCount);
|
|
Assert.Equal(0, journal.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retire_IsExactAndIdempotentForFullAndNearLayerReceipts()
|
|
{
|
|
var journal = new RenderProjectionJournal(Generation(5));
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
LandblockBuild build = Build(
|
|
LandblockId,
|
|
[Entity(1), Entity(2)],
|
|
includeShell: true);
|
|
statics.Reconcile(build, Publication(build));
|
|
using var scene = new ArchRenderScene(Generation(5));
|
|
journal.DrainTo(scene);
|
|
|
|
var retirement = new GpuLandblockRetirement(
|
|
LandblockId,
|
|
LandblockRetirementKind.NearLayer,
|
|
build.Landblock.Entities);
|
|
statics.Retire(retirement);
|
|
statics.Retire(retirement);
|
|
|
|
Assert.Equal(3, journal.Count);
|
|
Assert.Equal(0, statics.ProjectionCount);
|
|
Assert.All(
|
|
journal.Pending.ToArray(),
|
|
delta => Assert.Equal(
|
|
RenderProjectionDeltaKind.Unregister,
|
|
delta.Kind));
|
|
RenderDeltaApplyResult result = journal.DrainTo(scene);
|
|
Assert.Equal(3, result.Unregistered);
|
|
Assert.Equal(0, scene.Counts.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_AdvancesExactGenerationAndDropsRetainedState()
|
|
{
|
|
var journal = new RenderProjectionJournal(Generation(6));
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
LandblockBuild build = Build(
|
|
LandblockId,
|
|
[Entity(1)],
|
|
includeShell: true);
|
|
statics.Reconcile(build, Publication(build));
|
|
|
|
statics.Clear(Generation(7));
|
|
|
|
Assert.Equal(Generation(7), journal.Generation);
|
|
Assert.Equal(0, journal.Count);
|
|
Assert.Equal(0, statics.ProjectionCount);
|
|
Assert.Equal(0, statics.LandblockCount);
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => statics.Clear(Generation(7)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ActiveAnimatedSynchronization_ReclassifiesAndUpdatesOnlyActiveSource()
|
|
{
|
|
RenderSceneGeneration generation = Generation(8);
|
|
var journal = new RenderProjectionJournal(generation);
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
WorldEntity animated = Entity(1);
|
|
WorldEntity ordinary = Entity(2);
|
|
LandblockBuild build = Build(
|
|
LandblockId,
|
|
[animated, ordinary],
|
|
includeShell: false);
|
|
statics.Reconcile(build, Publication(build));
|
|
using var scene = new ArchRenderScene(generation);
|
|
journal.DrainTo(scene);
|
|
RenderProjectionId animatedId =
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
animated.Id);
|
|
Assert.True(scene.OpenQuery().TryGet(animatedId, out var before));
|
|
|
|
statics.SynchronizeActiveAnimatedSources([animated]);
|
|
Assert.Single(journal.Pending.ToArray());
|
|
Assert.Equal(
|
|
RenderProjectionDeltaKind.Register,
|
|
journal.Pending[0].Kind);
|
|
Assert.Equal(
|
|
RenderProjectionClass.ActiveAnimatedStatic,
|
|
journal.Pending[0].Record.ProjectionClass);
|
|
Assert.Equal(
|
|
before.OwnerIncarnation,
|
|
journal.Pending[0].Record.OwnerIncarnation);
|
|
journal.DrainTo(scene);
|
|
|
|
statics.SynchronizeActiveAnimatedSources([animated]);
|
|
Assert.Equal(0, journal.Count);
|
|
|
|
animated.Rotation =
|
|
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
|
|
statics.SynchronizeActiveAnimatedSources([animated]);
|
|
Assert.Single(journal.Pending.ToArray());
|
|
Assert.Equal(
|
|
RenderProjectionDeltaKind.UpdateTransform,
|
|
journal.Pending[0].Kind);
|
|
Assert.True(statics.TryGet(animatedId, out var updated));
|
|
Assert.Equal(animated.Rotation, updated.Transform.Rotation);
|
|
RenderProjectionId ordinaryId =
|
|
StaticRenderProjectionJournal.StaticEntityId(
|
|
LandblockId,
|
|
ordinary.Id);
|
|
Assert.True(statics.TryGet(ordinaryId, out var unchanged));
|
|
Assert.Equal(
|
|
RenderProjectionClass.OutdoorStatic,
|
|
unchanged.ProjectionClass);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActiveAnimatedSynchronization_ReusesRetainedJournalStorage()
|
|
{
|
|
const int count = 1_000;
|
|
RenderSceneGeneration generation = Generation(9);
|
|
var journal = new RenderProjectionJournal(generation);
|
|
var statics = new StaticRenderProjectionJournal(journal);
|
|
var animated = new WorldEntity[count];
|
|
for (int index = 0; index < animated.Length; index++)
|
|
animated[index] = Entity((uint)index + 1);
|
|
LandblockBuild build = Build(
|
|
LandblockId,
|
|
animated,
|
|
includeShell: false);
|
|
statics.Reconcile(build, Publication(build));
|
|
using var scene = new ArchRenderScene(generation);
|
|
journal.DrainTo(scene);
|
|
statics.SynchronizeActiveAnimatedSources(animated);
|
|
journal.DrainTo(scene);
|
|
for (int index = 0; index < animated.Length; index++)
|
|
{
|
|
animated[index].Rotation = Quaternion.CreateFromAxisAngle(
|
|
Vector3.UnitZ,
|
|
0.25f);
|
|
}
|
|
statics.SynchronizeActiveAnimatedSources(animated);
|
|
journal.DrainTo(scene);
|
|
// #250: one warm call is not enough to have paid the callee tree's
|
|
// first-call costs. The step is the whole steady-state frame cycle —
|
|
// advance the rotations, synchronise, drain — rather than a bare
|
|
// synchronise, for two reasons. The rotations must actually change on
|
|
// every invocation or the synchronise early-outs and the probe measures
|
|
// nothing; and the journal does not coalesce, so without the drain it
|
|
// grows by `count` entries per invocation and the thing under test
|
|
// stops being steady state at all. Draining also makes this stricter
|
|
// than the original: `DrainTo` is now inside the measured window.
|
|
float angle = 0.5f;
|
|
ZeroAllocationProbe.AssertAllocatesNothing(
|
|
"StaticRenderProjectionJournal animated synchronise and drain",
|
|
() =>
|
|
{
|
|
angle += 0.01f;
|
|
for (int index = 0; index < animated.Length; index++)
|
|
{
|
|
animated[index].Rotation = Quaternion.CreateFromAxisAngle(
|
|
Vector3.UnitZ,
|
|
angle);
|
|
}
|
|
|
|
statics.SynchronizeActiveAnimatedSources(animated);
|
|
journal.DrainTo(scene);
|
|
});
|
|
|
|
// Retained storage: the cycle leaves nothing behind to grow.
|
|
Assert.Equal(0, journal.Count);
|
|
}
|
|
|
|
private static GpuLandblockSpatialPublication Publication(
|
|
LandblockBuild build,
|
|
bool requiresActivation = true,
|
|
uint renderTraversalOrder = 1) =>
|
|
new(
|
|
build.LandblockId,
|
|
build.Landblock,
|
|
build.EnvCells?.Shells.Select(shell => shell.GeometryId).ToArray()
|
|
?? [],
|
|
build.Landblock.Entities.Where(entity => entity.ServerGuid == 0).ToArray(),
|
|
requiresActivation,
|
|
renderTraversalOrder);
|
|
|
|
private static LandblockBuild Build(
|
|
uint landblockId,
|
|
IReadOnlyList<WorldEntity> entities,
|
|
bool includeShell)
|
|
{
|
|
EnvCellLandblockBuild? envCells = null;
|
|
if (includeShell)
|
|
{
|
|
uint cellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
|
envCells = new EnvCellLandblockBuild(
|
|
landblockId,
|
|
Array.Empty<LoadedCell>(),
|
|
[
|
|
new EnvCellShellPlacement(
|
|
cellId,
|
|
0x2_0000_0001UL,
|
|
0x0D000001u,
|
|
1,
|
|
ImmutableArray<ushort>.Empty,
|
|
new Vector3(4, 5, 6),
|
|
Quaternion.Identity,
|
|
Matrix4x4.CreateTranslation(4, 5, 6),
|
|
new WbBoundingBox(Vector3.Zero, Vector3.One),
|
|
new WbBoundingBox(
|
|
new Vector3(4, 5, 6),
|
|
new Vector3(5, 6, 7))),
|
|
]);
|
|
}
|
|
|
|
return new LandblockBuild(
|
|
new LoadedLandblock(
|
|
landblockId,
|
|
new LandBlock(),
|
|
entities,
|
|
PhysicsDatBundle.Empty),
|
|
envCells);
|
|
}
|
|
|
|
private static WorldEntity Entity(
|
|
uint id,
|
|
uint serverGuid = 0,
|
|
Vector3 position = default) =>
|
|
new()
|
|
{
|
|
Id = id,
|
|
ServerGuid = serverGuid,
|
|
SourceGfxObjOrSetupId = 0x01000000u + id,
|
|
Position = position,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs =
|
|
[
|
|
new MeshRef(
|
|
0x01010000u + id,
|
|
Matrix4x4.CreateTranslation(0, 0, id)),
|
|
],
|
|
};
|
|
|
|
private static RenderSceneGeneration Generation(ulong value) =>
|
|
RenderSceneGeneration.FromRaw(value);
|
|
}
|