feat(rendering): compare the incremental shadow scene

Construct Slice F's non-drawing scene only for lifecycle automation, drain accepted static and live deltas at the final update boundary, and compare exact current-path fingerprints at cadence and checkpoints. Publish bounded mismatch, journal, index, digest, and memory evidence without changing normal launches or draw submission.

Release: 8,211 passed, 5 skipped.
This commit is contained in:
Erik 2026-07-24 22:24:30 +02:00
parent ff5d86175f
commit 0eb6648589
21 changed files with 937 additions and 20 deletions

View file

@ -22,7 +22,7 @@ retail-faithful gameplay
| F2 — static projection journal | complete | Static and EnvCell-shell projections now append ordered deltas only after the final activation receipt and exact detach receipt. Same-landblock rehydrate reconciles retained/new/omitted identities; stale Far completions are inert. Seven focused lifecycle tests pass inside the 3,708-App / 8,192-solution Release gate. The scene remains unconstructed and non-drawing in production. |
| F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. |
| F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. |
| F5 | active | Continuous lifecycle-automation shadow comparison is the next unit; no production consumer has switched. |
| F5 | active — connected gate | Lifecycle automation now constructs the otherwise-absent non-drawing scene, drains ordered static/live deltas after post-network spatial reconciliation, compares the accepted current partition every 30 rendered frames and at every canonical checkpoint, retains only the first field-level mismatch, and publishes scene/journal/index/memory evidence in checkpoint JSON. Focused and complete App suites are green; capped/uncapped nine-stop and dense-Arwic physical gates remain. |
| G0G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is

View file

@ -374,6 +374,20 @@ internal sealed class FrameRootCompositionPhase
&& d.Options.AutomationArtifactDirectory is not null
? new CurrentRenderSceneOracle()
: null;
if ((currentRenderSceneOracle is null)
!= (live.RenderSceneShadow is null))
{
throw new InvalidOperationException(
"Lifecycle automation must compose the current-path referee and shadow render scene together.");
}
RenderSceneShadowComparisonController? renderSceneShadowComparison =
currentRenderSceneOracle is not null
&& live.RenderSceneShadow is not null
? new RenderSceneShadowComparisonController(
live.RenderSceneShadow,
currentRenderSceneOracle,
message => d.Log("[UI-PROBE] " + message))
: null;
live.DrawDispatcher.SetCurrentRenderSceneObserver(
currentRenderSceneOracle);
live.SelectionScene.SetCurrentRenderSceneObserver(
@ -421,7 +435,8 @@ internal sealed class FrameRootCompositionPhase
d.FrameProfiler,
content.Dats,
foundation.Residency,
currentRenderSceneOracle);
currentRenderSceneOracle,
renderSceneShadowComparison);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,
@ -463,6 +478,14 @@ internal sealed class FrameRootCompositionPhase
renderFrameResources,
settings.DevTools?.Presenter,
renderWeatherFrame);
IRenderFramePostDiagnosticsPhase postDiagnostics =
renderSceneShadowComparison is not null
&& lifecycleAutomation is not null
? new SerialRenderFramePostDiagnosticsPhase(
renderSceneShadowComparison,
lifecycleAutomation)
: (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance;
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameFlights,
new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl),
@ -470,8 +493,7 @@ internal sealed class FrameRootCompositionPhase
worldSceneRenderer,
privatePresentation,
live.FrameDiagnostics,
(IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance,
postDiagnostics,
(IRenderFrameFailureRecovery?)settings.DevTools?.Presenter
?? NullRenderFrameFailureRecovery.Instance);
Fault(FrameRootCompositionPoint.RenderRootCreated);

View file

@ -6,6 +6,7 @@ using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Vfx;
@ -80,6 +81,7 @@ internal sealed record LivePresentationResult(
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
WorldGenerationAvailabilityState WorldAvailability,
GpuWorldState WorldState,
RenderSceneShadowRuntime? RenderSceneShadow,
LiveEntityRuntime LiveEntities,
ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
@ -196,6 +198,20 @@ internal sealed class LivePresentationCompositionPhase
try
{
CompositionAcquisitionScope.CompositionAcquisitionLease<
RenderSceneShadowRuntime>? renderSceneShadowLease = null;
if (interaction.RetainedUi?.Screenshots is not null
&& d.Options.AutomationArtifactDirectory is not null)
{
renderSceneShadowLease = scope.Acquire(
"shadow render scene",
() => new RenderSceneShadowRuntime(
RenderSceneGeneration.FromRaw(1)),
static value => value.Dispose());
}
RenderSceneShadowRuntime? renderSceneShadow =
renderSceneShadowLease?.Resource;
var componentLifecycle =
new DeferredLiveEntityRuntimeComponentLifecycle();
var wbSpawnAdapter = new LandblockSpawnAdapter(foundation.MeshAdapter);
@ -333,20 +349,33 @@ internal sealed class LivePresentationCompositionPhase
"developer world-entity source",
devWorldEntities.BindOwned(worldState));
}
liveEntities = new LiveEntityRuntime(
worldState,
new CompositeLiveEntityResourceLifecycle(
var resourceOwners =
new List<CompositeLiveEntityResourceLifecycle.Owner>
{
new(
entity => _ = entitySpawnAdapter.OnCreate(entity),
entity => _ = entitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
entityScriptActivator.OnRemove),
};
if (renderSceneShadow is not null)
{
resourceOwners.Add(new(
renderSceneShadow.OnLiveResourceRegistered,
renderSceneShadow.OnLiveResourceUnregistered));
}
liveEntities = new LiveEntityRuntime(
worldState,
new CompositeLiveEntityResourceLifecycle(
[.. resourceOwners]),
componentLifecycle);
var liveRuntimeLease = scope.Own(
"canonical live-entity runtime",
liveEntities,
static runtime => runtime.Clear());
LiveRenderProjectionJournal? liveRenderProjections =
renderSceneShadow?.BindLiveRuntime(liveEntities);
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt(
@ -376,6 +405,13 @@ internal sealed class LivePresentationCompositionPhase
liveEntities,
wbVisibility,
"WB projection visibility");
if (liveRenderProjections is not null)
{
bindings.BindProjectionVisibility(
liveEntities,
liveRenderProjections.OnProjectionVisibilityChanged,
"shadow render projection visibility");
}
Action<LiveEntityRecord, bool> particleVisibility = (record, visible) =>
{
if (record.WorldEntity is { } entity)
@ -511,6 +547,15 @@ internal sealed class LivePresentationCompositionPhase
bindings.BindProjectionPoseReady(
equippedLease.Resource,
lightsLease.Resource.OnAttachedPoseReady);
if (liveRenderProjections is not null)
{
bindings.BindProjectionPoseReady(
equippedLease.Resource,
liveRenderProjections.OnProjectionPoseReady);
bindings.BindProjectionRemoved(
equippedLease.Resource,
liveRenderProjections.OnProjectionRemoved);
}
bindings.Adopt(
"entity-effect animation hooks",
content.HookRegistrations.RegisterOwned(entityEffects));
@ -529,6 +574,8 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler,
worldAvailability,
worldState,
renderSceneShadow,
renderSceneShadowLease,
liveEntities,
projectileController,
projectionWithdrawal,
@ -574,6 +621,9 @@ internal sealed class LivePresentationCompositionPhase
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
WorldGenerationAvailabilityState worldAvailability,
GpuWorldState worldState,
RenderSceneShadowRuntime? renderSceneShadow,
CompositionAcquisitionScope.CompositionAcquisitionLease<
RenderSceneShadowRuntime>? renderSceneShadowLease,
LiveEntityRuntime liveEntities,
ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal,
@ -818,7 +868,8 @@ internal sealed class LivePresentationCompositionPhase
worldState,
landblockRetirementOwner,
landblockLoaded.OnLandblockLoaded,
landblockRenderPublisher.PrepareAfterRenderPins);
landblockRenderPublisher.PrepareAfterRenderPins,
renderSceneShadow?.StaticProjections);
Fault(LivePresentationCompositionPoint.LandblockPipelineCreated);
var clipFrameLease = scope.Acquire(
@ -965,6 +1016,7 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler,
worldAvailability,
worldState,
renderSceneShadow,
liveEntities,
projectileController,
projectionWithdrawal,
@ -1010,6 +1062,7 @@ internal sealed class LivePresentationCompositionPhase
_publication.PublishLivePresentation(result);
liveRuntimeLease.Transfer();
renderSceneShadowLease?.Transfer();
lightsLease.Transfer();
equippedLease.Transfer();
presentationLease.Transfer();

View file

@ -5,6 +5,7 @@ using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
@ -454,7 +455,8 @@ internal sealed class SessionPlayerCompositionPhase
new LiveEntityReadyPublisher(
live.LiveEntities,
live.EntityEffects,
live.Presentation),
live.Presentation,
live.RenderSceneShadow?.LiveProjections),
originCoordinator,
networkUpdateBridge,
localPhysicsTimestamps,
@ -603,7 +605,9 @@ internal sealed class SessionPlayerCompositionPhase
live.EntityEffects,
live.EquippedChildren,
content.ParticleSink,
live.Lights);
live.Lights,
live.RenderSceneShadow?.LiveProjections,
live.RenderSceneShadow);
var localPlayerAnimation = new LocalPlayerAnimationController(
live.LiveEntities,
d.PlayerIdentity,
@ -650,7 +654,9 @@ internal sealed class SessionPlayerCompositionPhase
live.AnimationPresenter,
d.AnimatedEntities,
live.EquippedChildren,
liveEffectFrame);
liveEffectFrame,
live.RenderSceneShadow?.LiveProjections,
live.RenderSceneShadow?.StaticProjections);
Fault(SessionPlayerCompositionPoint.UpdateLeavesCreated);
var playerMode = new PlayerModeController(
@ -812,7 +818,8 @@ internal sealed class SessionPlayerCompositionPhase
live.EntityEffects,
content.AnimationHookFrames,
live.Presentation,
d.RemoteMovementObservations),
d.RemoteMovementObservations,
live.RenderSceneShadow),
d.Log);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(liveSession);
Fault(SessionPlayerCompositionPoint.SessionHostCreated);

View file

@ -40,6 +40,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
int LiveEntities,
int MaterializedLiveEntities,
CurrentRenderSceneOracleSnapshot RenderSceneOracle,
RenderSceneShadowComparisonSnapshot RenderSceneShadow,
int PendingLiveTeardowns,
int PendingLandblockRetirements,
int ParticleEmitters,

View file

@ -41,6 +41,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly ResidencyManager _residency;
private readonly ICurrentRenderSceneOracleSnapshotSource?
_renderSceneOracle;
private readonly IRenderSceneShadowSnapshotSource?
_renderSceneShadow;
public WorldLifecycleResourceSnapshotSource(
GpuWorldState world,
@ -59,7 +61,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
FrameProfiler frameProfiler,
IDatReaderWriter dats,
ResidencyManager residency,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null)
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null,
IRenderSceneShadowSnapshotSource? renderSceneShadow = null)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations
@ -87,6 +90,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_residency = residency
?? throw new ArgumentNullException(nameof(residency));
_renderSceneOracle = renderSceneOracle;
_renderSceneShadow = renderSceneShadow;
}
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@ -129,6 +133,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource
MaterializedLiveEntities: _liveEntities.MaterializedCount,
RenderSceneOracle: _renderSceneOracle?.Snapshot
?? CurrentRenderSceneOracleSnapshot.Disabled,
RenderSceneShadow:
_renderSceneShadow?.CaptureCheckpointSnapshot()
?? RenderSceneShadowComparisonSnapshot.Disabled,
PendingLiveTeardowns: _liveEntities.PendingTeardownCount,
PendingLandblockRetirements: _streaming.PendingRetirementCount,
ParticleEmitters: _particles.ActiveEmitterCount,

View file

@ -33,6 +33,7 @@ internal sealed class LiveSessionResetBindings
public required Action InboundEventFifo { get; init; }
public required Action LiveLiveness { get; init; }
public required Action LiveRuntime { get; init; }
public required Action RenderSceneProjection { get; init; }
public required Action SessionIdentity { get; init; }
public required Action RemoteTeleport { get; init; }
public required Action NetworkEffects { get; init; }
@ -81,6 +82,10 @@ internal static class LiveSessionResetManifest
new("inbound event fifo", bindings.InboundEventFifo),
new("live liveness", bindings.LiveLiveness),
new("live runtime", bindings.LiveRuntime),
// Canonical teardown appends exact projection removals. Drain them
// before identity changes so no prior-session scene record or
// journal entry can carry into the next character generation.
new("render scene projection", bindings.RenderSceneProjection),
// Identity must remain A until live teardown has converged so
// player-specific teardown can still classify the old record.
new("session identity", bindings.SessionIdentity),

View file

@ -5,6 +5,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
@ -86,7 +87,8 @@ internal sealed record LiveSessionWorldRuntime(
EntityEffectController EntityEffects,
AnimationHookFrameQueue AnimationHookFrames,
LiveEntityPresentationController Presentation,
RemoteMovementObservationTracker RemoteMovementObservations);
RemoteMovementObservationTracker RemoteMovementObservations,
RenderSceneShadowRuntime? RenderSceneShadow);
/// <summary>
/// Builds the exact per-generation route/reset graph for the canonical live
@ -177,6 +179,8 @@ internal sealed class LiveSessionRuntimeFactory
InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear,
LiveRuntime = ResetLiveRuntime,
RenderSceneProjection =
() => _world.RenderSceneShadow?.DrainUpdateBoundary(),
SessionIdentity = ResetIdentity,
RemoteTeleport = _world.RemoteTeleport.Clear,
NetworkEffects = _world.EntityEffects.ClearNetworkState,

View file

@ -1,6 +1,7 @@
using AcDream.Core.Plugins;
using AcDream.App.Composition;
using AcDream.App.Physics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.World;
@ -57,6 +58,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
private RenderSceneShadowRuntime? _renderSceneShadow;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
@ -949,6 +951,7 @@ public sealed class GameWindow :
|| _wbEntitySpawnAdapter is not null
|| _entityScriptActivator is not null
|| _staticAnimationScheduler is not null
|| _renderSceneShadow is not null
|| _wbDrawDispatcher is not null
|| _retailSelectionScene is not null
|| _worldSelectionQuery is not null
@ -985,6 +988,7 @@ public sealed class GameWindow :
_entityScriptActivator = result.EntityScriptActivator;
_staticAnimationScheduler = result.StaticAnimationScheduler;
_worldState = result.WorldState;
_renderSceneShadow = result.RenderSceneShadow;
_liveEntities = result.LiveEntities;
_projectileController = result.ProjectileController;
_liveEntityProjectionWithdrawal = result.ProjectionWithdrawal;
@ -1589,6 +1593,7 @@ public sealed class GameWindow :
_streamer,
_equippedChildRenderer,
_liveEntities,
_renderSceneShadow,
_livePresentationBindings,
_entityEffectAdvance,
_entityEffects,

View file

@ -6,6 +6,7 @@ using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
@ -79,6 +80,7 @@ internal sealed record LiveShutdownRoots(
LandblockStreamer? Streamer,
EquippedChildRenderController? EquippedChildren,
LiveEntityRuntime? LiveEntities,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
EntityEffectController? EntityEffects,
@ -377,6 +379,9 @@ internal static class GameWindowShutdownManifest
new ResourceShutdownStage("live entities",
[
Hard("live entity runtime", () => live.LiveEntities?.Clear()),
Hard(
"shadow render scene",
() => live.RenderSceneShadow?.Dispose()),
]),
new ResourceShutdownStage("effect dispatch edges",
[

View file

@ -91,6 +91,36 @@ internal sealed class NullRenderFramePostDiagnosticsPhase :
}
}
/// <summary>
/// Fixed-order post-diagnostic fan-out. The shadow referee must run before
/// lifecycle checkpoint publication so each artifact observes the comparison
/// made against that exact completed render frame.
/// </summary>
internal sealed class SerialRenderFramePostDiagnosticsPhase :
IRenderFramePostDiagnosticsPhase
{
private readonly IRenderFramePostDiagnosticsPhase[] _phases;
public SerialRenderFramePostDiagnosticsPhase(
params IRenderFramePostDiagnosticsPhase[] phases)
{
ArgumentNullException.ThrowIfNull(phases);
if (phases.Length == 0 || phases.Any(static phase => phase is null))
{
throw new ArgumentException(
"At least one non-null post-diagnostic phase is required.",
nameof(phases));
}
_phases = [.. phases];
}
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
for (int i = 0; i < _phases.Length; i++)
_phases[i].Process(input, outcome);
}
}
/// <summary>
/// Closes presentation transactions which may have opened before a later
/// render phase failed. Recovery is idempotent so failures before the

View file

@ -863,6 +863,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
hash.Add(record.Source.GeometryFingerprint.High);
hash.Add(record.Source.AppearanceFingerprint.Low);
hash.Add(record.Source.AppearanceFingerprint.High);
hash.Add(record.Source.CurrentProjectionFlags);
}
private readonly record struct ProjectionIdentity(RenderProjectionId Id);

View file

@ -27,7 +27,8 @@ internal static class RenderProjectionRecordFactory
ownerLandblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (spatiallyVisible
if (entity.MeshRefs.Count > 0
&& spatiallyVisible
&& entity.IsDrawVisible
&& entity.IsAncestorDrawVisible)
{
@ -76,7 +77,8 @@ internal static class RenderProjectionRecordFactory
entity.BuildingShellAnchorCellId ?? 0,
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance));
fingerprint.Appearance,
fingerprint.Flags));
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(

View file

@ -11,6 +11,7 @@ internal readonly record struct RenderProjectionId
internal static RenderProjectionId FromRaw(ulong value) => new(value);
internal ulong RawValue => _value;
internal byte Domain => (byte)(_value >> 56);
internal RenderSortKey ToSortKey() => new(_value);
public int CompareTo(RenderProjectionId other) =>
@ -182,7 +183,8 @@ internal readonly record struct RenderSourceMetadata(
uint BuildingShellAnchorCellId,
RenderSceneHash128 TransformFingerprint,
RenderSceneHash128 GeometryFingerprint,
RenderSceneHash128 AppearanceFingerprint);
RenderSceneHash128 AppearanceFingerprint,
uint CurrentProjectionFlags = 0);
internal readonly record struct RenderProjectionRecord(
RenderProjectionId Id,

View file

@ -0,0 +1,516 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
internal readonly record struct RenderSceneShadowComparisonSnapshot(
bool Enabled,
ulong RenderFrameSequence,
ulong ComparisonCount,
ulong SuccessfulComparisonCount,
ulong ComparedOracleFrameSequence,
int MatchedProjectionCount,
long MismatchCount,
string? FirstMismatch,
int PendingDeltaCount,
ulong DrainSequence,
RenderDeltaApplyResult LastApply,
RenderDeltaApplyResult CumulativeApply,
RenderProjectionCounts SceneCounts,
RenderSceneIndexCounts SceneIndexCounts,
RenderSceneMemoryAccounting Memory,
RenderSceneDigest Digest)
{
public static RenderSceneShadowComparisonSnapshot Disabled { get; } =
new(
Enabled: false,
RenderFrameSequence: 0,
ComparisonCount: 0,
SuccessfulComparisonCount: 0,
ComparedOracleFrameSequence: 0,
MatchedProjectionCount: 0,
MismatchCount: 0,
FirstMismatch: null,
PendingDeltaCount: 0,
DrainSequence: 0,
LastApply: default,
CumulativeApply: default,
SceneCounts: default,
SceneIndexCounts: default,
Memory: default,
Digest: default);
}
internal interface IRenderSceneShadowSnapshotSource
{
RenderSceneShadowComparisonSnapshot CaptureCheckpointSnapshot();
}
/// <summary>
/// Diagnostic-only owner of Slice F's non-drawing render projection. The
/// normal runtime never constructs this type. Accepted world/live lifecycle
/// edges append to one ordered journal, and the update thread drains it only
/// after current-frame animation, network, and attachment reconciliation.
/// </summary>
internal sealed class RenderSceneShadowRuntime : IDisposable
{
private readonly ArchRenderScene _scene;
private readonly RenderProjectionJournal _journal;
private readonly RenderSceneDigestBuffer _digestBuffer = new();
private LiveRenderProjectionJournal? _live;
private RenderDeltaApplyResult _lastApply;
private RenderDeltaApplyResult _cumulativeApply;
private ulong _drainSequence;
private bool _disposed;
public RenderSceneShadowRuntime(RenderSceneGeneration generation)
{
_scene = new ArchRenderScene(generation);
_journal = new RenderProjectionJournal(generation);
StaticProjections = new StaticRenderProjectionJournal(_journal);
}
public StaticRenderProjectionJournal StaticProjections { get; }
public ILiveRenderProjectionSink? LiveProjections => _live;
public int PendingDeltaCount => _journal.Count;
public ulong DrainSequence => _drainSequence;
public RenderDeltaApplyResult LastApply => _lastApply;
public RenderDeltaApplyResult CumulativeApply => _cumulativeApply;
public RenderProjectionCounts Counts => _scene.Counts;
internal RenderProjectionJournal Journal => _journal;
internal RenderSceneQuery Query => _scene.OpenQuery();
public LiveRenderProjectionJournal BindLiveRuntime(
LiveEntityRuntime runtime)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(runtime);
if (_live is not null)
{
throw new InvalidOperationException(
"The shadow render scene is already bound to a live runtime.");
}
_live = new LiveRenderProjectionJournal(runtime, _journal);
return _live;
}
public void OnLiveResourceRegistered(WorldEntity entity)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entity);
// EntityReady is the exact registration edge after all render,
// script, effect, and presentation owners have committed.
}
public void OnLiveResourceUnregistered(WorldEntity entity)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entity);
(_live ?? throw new InvalidOperationException(
"The shadow render scene was not bound before live teardown."))
.OnResourceUnregister(entity);
}
public RenderDeltaApplyResult DrainUpdateBoundary()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_lastApply = _journal.DrainTo(_scene);
_cumulativeApply = Add(_cumulativeApply, _lastApply);
_drainSequence = checked(_drainSequence + 1);
return _lastApply;
}
public RenderSceneMemoryAccounting CaptureMemory()
{
ObjectDisposedException.ThrowIf(_disposed, this);
RenderSceneMemoryAccounting memory = _scene.Memory;
return memory with
{
EstimatedJournalBufferBytes = checked(
(long)_journal.Capacity
* Unsafe.SizeOf<RenderProjectionDelta>()),
EstimatedSynchronizationSourceBytes = checked(
(long)(StaticProjections.ProjectionCount
+ (_live?.ProjectionCount ?? 0))
* Unsafe.SizeOf<RenderProjectionRecord>()),
};
}
public RenderSceneDigest BuildDigest()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _scene.BuildDigest(_digestBuffer);
}
public void ClearDirty()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_scene.ClearDirty();
}
public void Clear(RenderSceneGeneration replacementGeneration)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_live?.ResetTracking();
StaticProjections.Clear(replacementGeneration);
_scene.Clear(replacementGeneration);
_lastApply = default;
_cumulativeApply = default;
_drainSequence = 0;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_live?.ResetTracking();
_scene.Dispose();
}
private static RenderDeltaApplyResult Add(
RenderDeltaApplyResult left,
RenderDeltaApplyResult right) =>
new(
Applied: checked(left.Applied + right.Applied),
Registered: checked(left.Registered + right.Registered),
Updated: checked(left.Updated + right.Updated),
Replaced: checked(left.Replaced + right.Replaced),
Unregistered: checked(left.Unregistered + right.Unregistered),
RejectedGeneration: checked(
left.RejectedGeneration + right.RejectedGeneration),
RejectedOutOfOrderSequence: checked(
left.RejectedOutOfOrderSequence
+ right.RejectedOutOfOrderSequence),
RejectedStaleIncarnation: checked(
left.RejectedStaleIncarnation
+ right.RejectedStaleIncarnation),
RejectedMissing: checked(
left.RejectedMissing + right.RejectedMissing));
}
/// <summary>
/// Post-render referee between the exact current partition output and the
/// non-drawing incremental scene. It records only the first mismatch, so a
/// persistent fault cannot create an unbounded log storm.
/// </summary>
internal sealed class RenderSceneShadowComparisonController :
IRenderFramePostDiagnosticsPhase,
IRenderSceneShadowSnapshotSource
{
private const ulong ComparisonCadenceFrames = 30;
private const byte StaticProjectionDomain = 1;
private const byte EnvCellProjectionDomain = 2;
private const byte LiveProjectionDomain = 3;
private readonly RenderSceneShadowRuntime _shadow;
private readonly CurrentRenderSceneOracle _oracle;
private readonly Action<string>? _log;
private readonly List<RenderProjectionRecord> _sceneRecords = [];
private readonly HashSet<RenderProjectionId> _expectedIds = [];
private ulong _renderFrameSequence;
private ulong _comparisonCount;
private ulong _successfulComparisonCount;
private long _mismatchCount;
private string? _firstMismatch;
private int _matchedProjectionCount;
private ulong _comparedOracleFrameSequence;
public RenderSceneShadowComparisonController(
RenderSceneShadowRuntime shadow,
CurrentRenderSceneOracle oracle,
Action<string>? log = null)
{
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
_oracle = oracle ?? throw new ArgumentNullException(nameof(oracle));
_log = log;
Snapshot = BuildSnapshot();
}
public RenderSceneShadowComparisonSnapshot Snapshot { get; private set; }
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
_renderFrameSequence = checked(_renderFrameSequence + 1);
if (outcome.World.NormalWorldDrawn
&& _renderFrameSequence % ComparisonCadenceFrames == 0)
{
Compare(force: false);
}
else
{
Snapshot = BuildSnapshot();
}
}
public RenderSceneShadowComparisonSnapshot CaptureCheckpointSnapshot()
{
Compare(force: true);
return Snapshot;
}
internal void CompareNow() => Compare(force: true);
private void Compare(bool force)
{
CurrentRenderSceneOracleSnapshot current = _oracle.Snapshot;
if (current.CompletedFrameSequence == 0
|| (!force
&& current.CompletedFrameSequence
== _comparedOracleFrameSequence))
{
Snapshot = BuildSnapshot();
return;
}
_comparisonCount = checked(_comparisonCount + 1);
_comparedOracleFrameSequence = current.CompletedFrameSequence;
_matchedProjectionCount = 0;
string? mismatch = CompareCurrentProjection();
if (mismatch is null)
{
_successfulComparisonCount =
checked(_successfulComparisonCount + 1);
}
else
{
_mismatchCount = checked(_mismatchCount + 1);
if (_firstMismatch is null)
{
_firstMismatch = mismatch;
_log?.Invoke(
"[render-scene-shadow] first mismatch: " + mismatch);
}
}
_shadow.ClearDirty();
Snapshot = BuildSnapshot();
}
private string? CompareCurrentProjection()
{
if (_shadow.PendingDeltaCount != 0)
{
return $"sourceChannel=journal pendingDeltas={_shadow.PendingDeltaCount}";
}
if (_shadow.CumulativeApply.Rejected != 0)
{
return "sourceChannel=journal rejectedDeltas="
+ _shadow.CumulativeApply.Rejected;
}
RenderSceneQuery query = _shadow.Query;
_expectedIds.Clear();
IReadOnlyList<CurrentRenderProjectionFingerprint> expected =
_oracle.Projections;
for (int i = 0; i < expected.Count; i++)
{
CurrentRenderProjectionFingerprint fingerprint = expected[i];
RenderProjectionId id = ExpectedId(in fingerprint);
_expectedIds.Add(id);
if (!query.TryGet(id, out RenderProjectionRecord actual))
{
return $"sourceChannel={Channel(id)} id={id} field=presence "
+ "expected=present actual=missing";
}
string? mismatch = CompareProjection(
in fingerprint,
in actual);
if (mismatch is not null)
return mismatch;
_matchedProjectionCount++;
}
int total = query.Counts.Total;
if (_sceneRecords.Capacity < total)
_sceneRecords.Capacity = total;
CollectionsMarshal.SetCount(_sceneRecords, total);
int copied = query.CopyTo(CollectionsMarshal.AsSpan(_sceneRecords));
if (copied != total)
{
return $"sourceChannel=scene field=count expected={total} "
+ $"actual={copied}";
}
for (int i = 0; i < copied; i++)
{
RenderProjectionRecord record = _sceneRecords[i];
if (_expectedIds.Contains(record.Id)
|| Domain(record.Id) == EnvCellProjectionDomain)
{
continue;
}
bool indoorStatic =
record.ProjectionClass
is RenderProjectionClass.IndoorCellStatic
|| (record.ProjectionClass
is RenderProjectionClass.ActiveAnimatedStatic
&& InteriorEntityPartition.IsIndoorCellId(
record.Source.ParentCellId));
if (indoorStatic
|| (record.Flags & RenderProjectionFlags.Draw) == 0)
{
continue;
}
return $"sourceChannel={Channel(record.Id)} id={record.Id} "
+ "field=presence expected=absent actual=drawn";
}
return null;
}
private static string? CompareProjection(
in CurrentRenderProjectionFingerprint expected,
in RenderProjectionRecord actual)
{
RenderProjectionId id = actual.Id;
string channel = Channel(id);
string? classMismatch = CompareClass(
expected.ProjectionClass,
in actual);
if (classMismatch is not null)
return Prefix(channel, id, "projectionClass", classMismatch);
if (actual.Residency.OwnerLandblockId != expected.LandblockId)
{
return Prefix(
channel,
id,
"landblock",
$"{expected.LandblockId:X8}/{actual.Residency.OwnerLandblockId:X8}");
}
if (actual.Source.LocalEntityId != expected.EntityId)
return Values(channel, id, "entityId", expected.EntityId, actual.Source.LocalEntityId);
if (actual.Source.ServerGuid != expected.ServerGuid)
return Values(channel, id, "serverGuid", expected.ServerGuid, actual.Source.ServerGuid);
if (actual.Source.SourceId != expected.SourceId)
return Values(channel, id, "sourceId", expected.SourceId, actual.Source.SourceId);
if (actual.Source.ParentCellId != expected.ParentCellId)
return Values(channel, id, "parentCell", expected.ParentCellId, actual.Source.ParentCellId);
if (actual.Source.EffectCellId != expected.EffectCellId)
return Values(channel, id, "effectCell", expected.EffectCellId, actual.Source.EffectCellId);
if (actual.Source.BuildingShellAnchorCellId
!= expected.BuildingShellAnchorCellId)
{
return Values(
channel,
id,
"buildingShellAnchor",
expected.BuildingShellAnchorCellId,
actual.Source.BuildingShellAnchorCellId);
}
if (actual.Source.CurrentProjectionFlags != expected.Flags)
return Values(channel, id, "flags", expected.Flags, actual.Source.CurrentProjectionFlags);
if (actual.MeshSet.MeshCount != expected.MeshCount)
return Values(channel, id, "meshCount", expected.MeshCount, actual.MeshSet.MeshCount);
if (actual.Source.TransformFingerprint != expected.Transform)
return Prefix(channel, id, "transform", $"{expected.Transform}/{actual.Source.TransformFingerprint}");
if (actual.Source.GeometryFingerprint != expected.Geometry)
return Prefix(channel, id, "geometry", $"{expected.Geometry}/{actual.Source.GeometryFingerprint}");
if (actual.Source.AppearanceFingerprint != expected.Appearance)
return Prefix(channel, id, "appearance", $"{expected.Appearance}/{actual.Source.AppearanceFingerprint}");
return null;
}
private static string? CompareClass(
InteriorEntityPartition.ProjectionClass expected,
in RenderProjectionRecord actual)
{
bool matches = expected switch
{
InteriorEntityPartition.ProjectionClass.OutdoorStatic =>
actual.ProjectionClass
is RenderProjectionClass.OutdoorStatic
or RenderProjectionClass.ActiveAnimatedStatic
&& !InteriorEntityPartition.IsIndoorCellId(
actual.Source.ParentCellId),
InteriorEntityPartition.ProjectionClass.CellStatic =>
actual.ProjectionClass
is RenderProjectionClass.IndoorCellStatic
or RenderProjectionClass.ActiveAnimatedStatic
&& InteriorEntityPartition.IsIndoorCellId(
actual.Source.ParentCellId),
InteriorEntityPartition.ProjectionClass.Dynamic =>
actual.ProjectionClass
is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild,
_ => false,
};
return matches
? null
: $"{expected}/{actual.ProjectionClass}";
}
private RenderSceneShadowComparisonSnapshot BuildSnapshot()
{
RenderSceneQuery query = _shadow.Query;
return new RenderSceneShadowComparisonSnapshot(
Enabled: true,
RenderFrameSequence: _renderFrameSequence,
ComparisonCount: _comparisonCount,
SuccessfulComparisonCount: _successfulComparisonCount,
ComparedOracleFrameSequence: _comparedOracleFrameSequence,
MatchedProjectionCount: _matchedProjectionCount,
MismatchCount: _mismatchCount,
FirstMismatch: _firstMismatch,
PendingDeltaCount: _shadow.PendingDeltaCount,
DrainSequence: _shadow.DrainSequence,
LastApply: _shadow.LastApply,
CumulativeApply: _shadow.CumulativeApply,
SceneCounts: query.Counts,
SceneIndexCounts: query.IndexCounts,
Memory: _shadow.CaptureMemory(),
Digest: _shadow.BuildDigest());
}
private static RenderProjectionId ExpectedId(
in CurrentRenderProjectionFingerprint fingerprint) =>
fingerprint.ServerGuid == 0
? StaticRenderProjectionJournal.StaticEntityId(
fingerprint.LandblockId,
fingerprint.EntityId)
: LiveRenderProjectionJournal.ProjectionId(
fingerprint.EntityId);
private static byte Domain(RenderProjectionId id) =>
id.Domain;
private static string Channel(RenderProjectionId id) =>
Domain(id) switch
{
StaticProjectionDomain => "static",
EnvCellProjectionDomain => "envCell",
LiveProjectionDomain => "live",
byte domain => $"unknown:{domain}",
};
private static string Values<T>(
string channel,
RenderProjectionId id,
string field,
T expected,
T actual) =>
Prefix(channel, id, field, $"{expected}/{actual}");
private static string Prefix(
string channel,
RenderProjectionId id,
string field,
string values) =>
$"sourceChannel={channel} id={id} field={field} "
+ $"expected/actual={values}";
}

View file

@ -238,13 +238,15 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
private readonly ParticleHookSink _particleSink;
private readonly LiveEntityLightController _lights;
private readonly ILiveRenderProjectionSink? _renderProjections;
private readonly RenderSceneShadowRuntime? _renderSceneShadow;
public LiveSpatialPresentationReconciler(
EntityEffectController entityEffects,
EquippedChildRenderController equippedChildren,
ParticleHookSink particleSink,
LiveEntityLightController lights,
ILiveRenderProjectionSink? renderProjections = null)
ILiveRenderProjectionSink? renderProjections = null,
RenderSceneShadowRuntime? renderSceneShadow = null)
{
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_equippedChildren = equippedChildren
@ -252,6 +254,7 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
_renderProjections = renderProjections;
_renderSceneShadow = renderSceneShadow;
}
public void Reconcile()
@ -261,5 +264,6 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
_renderProjections?.SynchronizeActiveSources();
_particleSink.RefreshAttachedEmitters();
_lights.Refresh();
_renderSceneShadow?.DrainUpdateBoundary();
}
}

View file

@ -38,12 +38,14 @@ public sealed class FrameRootCompositionTests
AssertAppearsInOrder(
source,
"new RenderFrameResourceController(",
"new RenderSceneShadowComparisonController(",
"new WorldSceneRenderer(",
"new WorldLifecycleAutomationController(",
"\"world lifecycle automation owner\"",
"\"world lifecycle automation binding\"",
"new SerialRenderFramePostDiagnosticsPhase(",
"new RenderFrameOrchestrator(",
"(IRenderFramePostDiagnosticsPhase?)lifecycleAutomation",
"postDiagnostics,",
"new RetailLiveFrameCoordinator(",
"new UpdateFrameOrchestrator(",
"d.FrameGraphs.PublishOwned(",

View file

@ -144,6 +144,15 @@ public sealed class WorldLifecycleAutomationControllerTests
AbortedSelectionFrames: 0,
SelectionPartCount: 7,
SelectionDigest: new RenderSceneHash128(15, 16)),
RenderSceneShadow =
RenderSceneShadowComparisonSnapshot.Disabled with
{
Enabled = true,
ComparisonCount = 19,
SuccessfulComparisonCount = 19,
ComparedOracleFrameSequence = 19,
MatchedProjectionCount = 73,
},
TrackedGpuBytes = 1234,
Residency = new ResidencySnapshot(
[
@ -214,6 +223,20 @@ public sealed class WorldLifecycleAutomationControllerTests
0xFEDCBA9876543210uL,
renderSceneOracle.GetProperty("digest").GetProperty("high")
.GetUInt64());
JsonElement renderSceneShadow = json.RootElement
.GetProperty("resources")
.GetProperty("renderSceneShadow");
Assert.True(renderSceneShadow.GetProperty("enabled").GetBoolean());
Assert.Equal(
19,
renderSceneShadow.GetProperty("comparisonCount").GetInt64());
Assert.Equal(
73,
renderSceneShadow.GetProperty("matchedProjectionCount")
.GetInt32());
Assert.Equal(
0,
renderSceneShadow.GetProperty("mismatchCount").GetInt64());
JsonElement residency = json.RootElement
.GetProperty("resources")
.GetProperty("residency");
@ -353,6 +376,7 @@ public sealed class WorldLifecycleAutomationControllerTests
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled,
RenderSceneShadowComparisonSnapshot.Disabled,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,

View file

@ -269,6 +269,7 @@ public sealed class LiveSessionHostTests
InboundEventFifo = noop,
LiveLiveness = noop,
LiveRuntime = noop,
RenderSceneProjection = noop,
SessionIdentity = noop,
RemoteTeleport = noop,
NetworkEffects = noop,

View file

@ -298,6 +298,7 @@ public sealed class LiveSessionResetPlanTests
LiveRuntime = Stage(
"live runtime",
() => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)),
RenderSceneProjection = Stage("render scene projection"),
SessionIdentity = Stage("session identity", () =>
{
LiveSessionEntityRuntimeReset.RequireConvergence(live);
@ -378,6 +379,7 @@ public sealed class LiveSessionResetPlanTests
"inbound event fifo",
"live liveness",
"live runtime",
"render scene projection",
"session identity",
"remote teleport",
"network effects",

View file

@ -0,0 +1,224 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderSceneShadowRuntimeTests
{
private const uint Landblock = 0xA9B4FFFF;
private const uint Cell = 0xA9B40170;
[Fact]
public void ExactCurrentProjection_MatchesAndAcknowledgesDirtyWorkset()
{
WorldEntity entity = Entity(17, serverGuid: 0, parentCell: null);
CurrentRenderSceneOracle oracle = Capture(
entity,
visibleCells: []);
using var shadow = new RenderSceneShadowRuntime(Generation(1));
Register(shadow, entity, RenderProjectionClass.OutdoorStatic);
Assert.Equal(1, shadow.DrainUpdateBoundary().Registered);
var controller = new RenderSceneShadowComparisonController(
shadow,
oracle);
RenderSceneShadowComparisonSnapshot snapshot =
controller.CaptureCheckpointSnapshot();
Assert.True(snapshot.Enabled);
Assert.Equal(1uL, snapshot.ComparisonCount);
Assert.Equal(1uL, snapshot.SuccessfulComparisonCount);
Assert.Equal(0, snapshot.MismatchCount);
Assert.Equal(1, snapshot.MatchedProjectionCount);
Assert.Equal(0, snapshot.PendingDeltaCount);
Assert.Equal(0, snapshot.SceneIndexCounts.Dirty);
Assert.Equal(1, snapshot.SceneCounts.Total);
Assert.True(snapshot.Memory.TotalEstimatedBytes > 0);
}
[Fact]
public void FirstExactMismatch_IsNamedAndLoggedOnlyOnce()
{
WorldEntity entity = Entity(18, serverGuid: 0, parentCell: null);
CurrentRenderSceneOracle oracle = Capture(entity, []);
using var shadow = new RenderSceneShadowRuntime(Generation(1));
RenderProjectionRecord record = Project(
entity,
RenderProjectionClass.OutdoorStatic) with
{
Source = Project(
entity,
RenderProjectionClass.OutdoorStatic).Source with
{
TransformFingerprint = new RenderSceneHash128(91, 92),
},
};
shadow.Journal.Register(in record);
shadow.DrainUpdateBoundary();
var logs = new List<string>();
var controller = new RenderSceneShadowComparisonController(
shadow,
oracle,
logs.Add);
controller.CompareNow();
controller.CompareNow();
RenderSceneShadowComparisonSnapshot snapshot = controller.Snapshot;
Assert.Equal(2uL, snapshot.ComparisonCount);
Assert.Equal(0uL, snapshot.SuccessfulComparisonCount);
Assert.Equal(2, snapshot.MismatchCount);
Assert.Contains("sourceChannel=static", snapshot.FirstMismatch);
Assert.Contains("field=transform", snapshot.FirstMismatch);
Assert.Single(logs);
}
[Fact]
public void NonFloodedIndoorStatic_IsAValidRetainedSceneExtra()
{
WorldEntity entity = Entity(19, serverGuid: 0, parentCell: Cell);
CurrentRenderSceneOracle oracle = Capture(entity, []);
Assert.Empty(oracle.Projections);
using var shadow = new RenderSceneShadowRuntime(Generation(1));
Register(shadow, entity, RenderProjectionClass.IndoorCellStatic);
shadow.DrainUpdateBoundary();
var controller = new RenderSceneShadowComparisonController(
shadow,
oracle);
controller.CompareNow();
Assert.Equal(1uL, controller.Snapshot.SuccessfulComparisonCount);
Assert.Equal(0, controller.Snapshot.MismatchCount);
}
[Fact]
public void DrawnOutdoorExtra_IsReportedAsStaleProjection()
{
WorldEntity expected = Entity(20, serverGuid: 0, parentCell: null);
WorldEntity extra = Entity(21, serverGuid: 0, parentCell: null);
CurrentRenderSceneOracle oracle = Capture(expected, []);
using var shadow = new RenderSceneShadowRuntime(Generation(1));
Register(shadow, expected, RenderProjectionClass.OutdoorStatic);
Register(shadow, extra, RenderProjectionClass.OutdoorStatic);
shadow.DrainUpdateBoundary();
var controller = new RenderSceneShadowComparisonController(
shadow,
oracle);
controller.CompareNow();
Assert.Equal(1, controller.Snapshot.MismatchCount);
Assert.Contains("expected=absent actual=drawn", controller.Snapshot.FirstMismatch);
}
[Fact]
public void RejectedDelta_IsAComparisonFailureEvenWhenSceneIsEmpty()
{
CurrentRenderSceneOracle oracle = CaptureNoEntities();
using var shadow = new RenderSceneShadowRuntime(Generation(1));
WorldEntity missing = Entity(22, serverGuid: 0, parentCell: null);
RenderProjectionRecord record = Project(
missing,
RenderProjectionClass.OutdoorStatic);
shadow.Journal.Update(
RenderProjectionDeltaKind.UpdateTransform,
in record);
RenderDeltaApplyResult apply = shadow.DrainUpdateBoundary();
Assert.Equal(1, apply.RejectedMissing);
var controller = new RenderSceneShadowComparisonController(
shadow,
oracle);
controller.CompareNow();
Assert.Equal(1, controller.Snapshot.MismatchCount);
Assert.Contains("rejectedDeltas=1", controller.Snapshot.FirstMismatch);
}
private static void Register(
RenderSceneShadowRuntime shadow,
WorldEntity entity,
RenderProjectionClass projectionClass)
{
RenderProjectionRecord record = Project(entity, projectionClass);
shadow.Journal.Register(in record);
}
private static RenderProjectionRecord Project(
WorldEntity entity,
RenderProjectionClass projectionClass) =>
RenderProjectionRecordFactory.ProjectEntity(
StaticRenderProjectionJournal.StaticEntityId(
Landblock,
entity.Id),
projectionClass,
RenderOwnerIncarnation.FromRaw(entity.Id),
Landblock,
entity.ParentCellId ?? Landblock,
entity,
spatiallyVisible: true);
private static CurrentRenderSceneOracle Capture(
WorldEntity entity,
HashSet<uint> visibleCells)
{
var oracle = new CurrentRenderSceneOracle();
var result = new InteriorEntityPartition.Result();
InteriorEntityPartition.Partition(
result,
visibleCells,
[
(
Landblock,
Vector3.Zero,
Vector3.One,
(IReadOnlyList<WorldEntity>)[entity],
(IReadOnlyDictionary<uint, WorldEntity>?)null),
],
oracle);
return oracle;
}
private static CurrentRenderSceneOracle CaptureNoEntities()
{
var oracle = new CurrentRenderSceneOracle();
var result = new InteriorEntityPartition.Result();
InteriorEntityPartition.Partition(
result,
[],
Array.Empty<(
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>(),
oracle);
return oracle;
}
private static RenderSceneGeneration Generation(ulong value) =>
RenderSceneGeneration.FromRaw(value);
private static WorldEntity Entity(
uint id,
uint serverGuid,
uint? parentCell) =>
new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(1, 2, 3),
Rotation = Quaternion.Identity,
MeshRefs =
[
new MeshRef(
0x01000001u,
Matrix4x4.CreateTranslation(0.25f, 0.5f, 0.75f)),
],
ParentCellId = parentCell,
};
}