feat(rendering): journal live scene projections

Mirror exact live-root and equipped-child lifetimes behind the non-drawing render-scene boundary. Ready, visibility, final-pose, rebucket, removal, and resource teardown edges retain canonical LiveEntityRuntime identity while active-only synchronization avoids resident-static scans.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 22:05:42 +02:00
parent 5d19c56d15
commit 58e7c2eb99
10 changed files with 949 additions and 148 deletions

View file

@ -59,6 +59,17 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable
() => source.ProjectionPoseReady -= handler));
}
public void BindProjectionRemoved(
EquippedChildRenderController source,
Action<uint> handler)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
source.ProjectionRemoved += handler;
Adopt("equipped-child projection removal", new DelegateBinding(
() => source.ProjectionRemoved -= handler));
}
public void Dispose()
{
if (_deactivationStarted && _bindings.Count == 0)

View file

@ -0,0 +1,266 @@
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
internal interface ILiveRenderProjectionSink
{
bool OnEntityReady(LiveEntityReadyCandidate candidate);
void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible);
void OnProjectionPoseReady(uint serverGuid);
void OnProjectionRemoved(uint localEntityId);
void OnResourceUnregister(WorldEntity entity);
void SynchronizeActiveSources();
}
/// <summary>
/// Change-driven render projection for canonical live records. It retains
/// exact record references and local presentation IDs only; server GUID lookup
/// remains exclusively in <see cref="LiveEntityRuntime"/>.
/// </summary>
internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
{
private const byte LiveProjectionDomain = 3;
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly Dictionary<uint, TrackedProjection> _byLocalId = [];
private readonly List<TrackedProjection> _activeScratch = [];
public LiveRenderProjectionJournal(
LiveEntityRuntime runtime,
RenderProjectionJournal journal)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
}
public int ProjectionCount => _byLocalId.Count;
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{
if (!candidate.IsCurrent(_runtime)
|| candidate.WorldEntity is not { } entity
|| !candidate.Record.ResourcesRegistered)
{
return false;
}
Upsert(candidate.Record, entity, candidate.Record.IsSpatiallyVisible);
return candidate.IsCurrent(_runtime);
}
public void OnProjectionVisibilityChanged(
LiveEntityRecord record,
bool visible)
{
ArgumentNullException.ThrowIfNull(record);
if (!_runtime.IsCurrentRecord(record)
|| record.WorldEntity is not { } entity
|| !_byLocalId.ContainsKey(entity.Id))
{
return;
}
Upsert(record, entity, visible);
}
public void OnProjectionPoseReady(uint serverGuid)
{
if (!_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity
|| !_byLocalId.ContainsKey(entity.Id)
|| record.ProjectionKind is not LiveEntityProjectionKind.Attached)
{
return;
}
Upsert(record, entity, record.IsSpatiallyVisible);
}
public void OnProjectionRemoved(uint localEntityId)
{
if (_byLocalId.TryGetValue(
localEntityId,
out TrackedProjection? tracked)
&& tracked.Projection.ProjectionClass is
RenderProjectionClass.EquippedChild)
{
Remove(tracked);
}
}
public void OnResourceUnregister(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked)
&& ReferenceEquals(tracked.Entity, entity))
{
Remove(tracked);
}
}
/// <summary>
/// Mirrors only registered live/attached sources after simulation,
/// animation, and attachment pose composition have committed. Resident
/// statics are never scanned.
/// </summary>
public void SynchronizeActiveSources()
{
_activeScratch.Clear();
foreach (TrackedProjection tracked in _byLocalId.Values)
{
if (tracked.Record.IsSpatiallyProjected)
_activeScratch.Add(tracked);
}
for (int i = 0; i < _activeScratch.Count; i++)
{
TrackedProjection tracked = _activeScratch[i];
if (!_runtime.IsCurrentRecord(tracked.Record)
|| !ReferenceEquals(tracked.Record.WorldEntity, tracked.Entity)
|| !_byLocalId.TryGetValue(
tracked.Entity.Id,
out TrackedProjection? current)
|| !ReferenceEquals(current, tracked))
{
continue;
}
Upsert(
tracked.Record,
tracked.Entity,
tracked.Record.IsSpatiallyVisible);
}
}
public void ResetTracking()
{
_byLocalId.Clear();
_activeScratch.Clear();
}
internal bool TryGet(
uint localEntityId,
out RenderProjectionRecord record)
{
if (_byLocalId.TryGetValue(
localEntityId,
out TrackedProjection? tracked))
{
record = tracked.Projection;
return true;
}
record = default;
return false;
}
internal static RenderProjectionId ProjectionId(uint localEntityId) =>
RenderProjectionId.FromRaw(
((ulong)LiveProjectionDomain << 56) | localEntityId);
private void Upsert(
LiveEntityRecord record,
WorldEntity entity,
bool spatiallyVisible)
{
RenderProjectionRecord projected = Project(
record,
entity,
spatiallyVisible);
if (!_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked))
{
_journal.Register(in projected);
_byLocalId.Add(
entity.Id,
new TrackedProjection(record, entity, projected));
return;
}
if (!ReferenceEquals(tracked.Record, record)
|| !ReferenceEquals(tracked.Entity, entity)
|| tracked.Projection.OwnerIncarnation
!= projected.OwnerIncarnation
|| tracked.Projection.ProjectionClass
!= projected.ProjectionClass)
{
_journal.Register(in projected);
_byLocalId[entity.Id] =
new TrackedProjection(record, entity, projected);
return;
}
projected = projected with
{
PreviousTransform = new PreviousRenderTransform(
tracked.Projection.Transform.LocalToWorld),
};
_journal.AppendDifference(tracked.Projection, projected);
tracked.Projection = projected;
}
private RenderProjectionRecord Project(
LiveEntityRecord record,
WorldEntity entity,
bool spatiallyVisible)
{
uint fullCellId = record.FullCellId != 0
? record.FullCellId
: entity.ParentCellId ?? 0;
uint ownerLandblockId = record.CanonicalLandblockId != 0
? record.CanonicalLandblockId
: fullCellId == 0
? 0
: Canonicalize(fullCellId);
return RenderProjectionRecordFactory.ProjectEntity(
ProjectionId(entity.Id),
record.ProjectionKind is LiveEntityProjectionKind.Attached
? RenderProjectionClass.EquippedChild
: RenderProjectionClass.LiveDynamicRoot,
RenderOwnerIncarnation.FromRaw(
((ulong)record.Generation << 32) | entity.Id),
ownerLandblockId,
fullCellId,
entity,
spatiallyVisible);
}
private void Remove(TrackedProjection tracked)
{
if (!_byLocalId.Remove(tracked.Entity.Id))
return;
_journal.Unregister(
tracked.Projection.Id,
tracked.Projection.OwnerIncarnation);
}
private static uint Canonicalize(uint cellOrLandblockId) =>
(cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
private sealed class TrackedProjection(
LiveEntityRecord record,
WorldEntity entity,
RenderProjectionRecord projection)
{
public LiveEntityRecord Record { get; } = record;
public WorldEntity Entity { get; } = entity;
public RenderProjectionRecord Projection { get; set; } = projection;
}
}
internal sealed class LiveRenderProjectionResourceLifecycle(
ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle
{
private readonly ILiveRenderProjectionSink _sink =
sink ?? throw new ArgumentNullException(nameof(sink));
public void Register(WorldEntity entity)
{
// The exact EntityReady edge follows all resource registration and is
// the first point at which the projection may enter the journal.
}
public void Unregister(WorldEntity entity) =>
_sink.OnResourceUnregister(entity);
}

View file

@ -43,6 +43,45 @@ internal sealed class RenderProjectionJournal
id,
incarnation));
public void AppendDifference(
in RenderProjectionRecord prior,
in RenderProjectionRecord current)
{
if (prior.Id != current.Id
|| prior.OwnerIncarnation != current.OwnerIncarnation
|| prior.ProjectionClass != current.ProjectionClass)
{
throw new ArgumentException(
"A channel update cannot change projection identity, incarnation, or class.",
nameof(current));
}
if (prior.Transform != current.Transform
|| prior.Bounds != current.Bounds
|| prior.SortKey != current.SortKey
|| prior.Source.TransformFingerprint
!= current.Source.TransformFingerprint)
{
Update(RenderProjectionDeltaKind.UpdateTransform, in current);
}
if (prior.MeshSet != current.MeshSet
|| prior.Material != current.Material
|| prior.DegradeState != current.DegradeState
|| prior.Source.GeometryFingerprint
!= current.Source.GeometryFingerprint
|| prior.Source.AppearanceFingerprint
!= current.Source.AppearanceFingerprint)
{
Update(RenderProjectionDeltaKind.UpdateAppearance, in current);
}
if (prior.Residency != current.Residency)
Update(RenderProjectionDeltaKind.Rebucket, in current);
if (prior.Flags != current.Flags)
Update(RenderProjectionDeltaKind.UpdateFlags, in current);
}
public RenderDeltaApplyResult DrainTo(IRenderScene scene)
{
ArgumentNullException.ThrowIfNull(scene);

View file

@ -0,0 +1,130 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
/// <summary>
/// Pure conversion from an accepted <see cref="WorldEntity"/> presentation
/// snapshot into the packed render-scene record. This method never mutates
/// source AABB state or acquires gameplay identity.
/// </summary>
internal static class RenderProjectionRecordFactory
{
private const float DefaultAabbRadius = 5.0f;
public static RenderProjectionRecord ProjectEntity(
RenderProjectionId id,
RenderProjectionClass projectionClass,
RenderOwnerIncarnation incarnation,
uint ownerLandblockId,
uint fullCellId,
WorldEntity entity,
bool spatiallyVisible)
{
ArgumentNullException.ThrowIfNull(entity);
CurrentRenderProjectionFingerprint fingerprint =
CurrentRenderSceneOracle.CreateProjectionFingerprint(
ownerLandblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (spatiallyVisible
&& entity.IsDrawVisible
&& entity.IsAncestorDrawVisible)
{
flags |= RenderProjectionFlags.Draw;
}
if (!entity.IsDrawVisible)
flags |= RenderProjectionFlags.Hidden;
if (!entity.IsAncestorDrawVisible)
flags |= RenderProjectionFlags.AncestorHidden;
RenderTransform transform = RenderTransform.FromRoot(
entity.Position,
entity.Rotation,
entity.Scale);
(Vector3 minimum, Vector3 maximum) = CalculateBounds(entity);
return new RenderProjectionRecord(
id,
projectionClass,
incarnation,
transform,
new PreviousRenderTransform(transform.LocalToWorld),
new RenderMeshSet(
RenderAssetHandle.FromRaw(
fingerprint.Geometry.Low ^ fingerprint.Geometry.High),
entity.MeshRefs.Count,
0),
new RenderMaterialVariant(
fingerprint.Appearance.Low,
fingerprint.Appearance.High,
1.0f),
new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(fullCellId),
ownerLandblockId,
fullCellId),
new RenderWorldBounds(minimum, maximum),
flags,
default,
id.ToSortKey(),
RenderDirtyMask.All,
new RenderSourceMetadata(
entity.Id,
entity.ServerGuid,
entity.SourceGfxObjOrSetupId,
entity.ParentCellId ?? 0,
entity.EffectCellId ?? 0,
entity.BuildingShellAnchorCellId ?? 0,
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance));
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(
WorldEntity entity)
{
Vector3 position = entity.Position;
if (entity.HasLocalBounds)
{
Vector3 localMin = entity.LocalBoundMin;
Vector3 localMax = entity.LocalBoundMax;
Vector3 minimum = default;
Vector3 maximum = default;
for (int cornerIndex = 0; cornerIndex < 8; cornerIndex++)
{
Vector3 corner = new(
(cornerIndex & 1) == 0 ? localMin.X : localMax.X,
(cornerIndex & 2) == 0 ? localMin.Y : localMax.Y,
(cornerIndex & 4) == 0 ? localMin.Z : localMax.Z);
Vector3 transformed =
Vector3.Transform(corner, entity.Rotation);
if (cornerIndex == 0)
{
minimum = transformed;
maximum = transformed;
}
else
{
minimum = Vector3.Min(minimum, transformed);
maximum = Vector3.Max(maximum, transformed);
}
}
Vector3 margin = new(DefaultAabbRadius);
return (
position + minimum - margin,
position + maximum + margin);
}
float radius = DefaultAabbRadius;
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
radius = MathF.Max(
radius,
DefaultAabbRadius
+ entity.MeshRefs[i].PartTransform.Translation.Length());
}
Vector3 extent = new(radius);
return (position - extent, position + extent);
}
}

View file

@ -1,4 +1,3 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.Core.World;
@ -25,7 +24,6 @@ internal sealed class StaticRenderProjectionJournal :
{
private const byte StaticEntityDomain = 1;
private const byte EnvCellShellDomain = 2;
private const float DefaultAabbRadius = 5.0f;
private readonly RenderProjectionJournal _journal;
private readonly Dictionary<uint, Dictionary<RenderProjectionId, TrackedProjection>>
@ -115,7 +113,7 @@ internal sealed class StaticRenderProjectionJournal :
if (accepted != retained.Record)
{
AppendUpdates(retained.Record, accepted);
_journal.AppendDifference(retained.Record, accepted);
current[candidate.Id] = new TrackedProjection(accepted);
}
continue;
@ -217,61 +215,18 @@ internal sealed class StaticRenderProjectionJournal :
uint landblockId,
WorldEntity entity)
{
CurrentRenderProjectionFingerprint fingerprint =
CurrentRenderSceneOracle.CreateProjectionFingerprint(
landblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (entity.IsDrawVisible && entity.IsAncestorDrawVisible)
flags |= RenderProjectionFlags.Draw;
if (!entity.IsDrawVisible)
flags |= RenderProjectionFlags.Hidden;
if (!entity.IsAncestorDrawVisible)
flags |= RenderProjectionFlags.AncestorHidden;
RenderTransform transform = RenderTransform.FromRoot(
entity.Position,
entity.Rotation,
entity.Scale);
(Vector3 minimum, Vector3 maximum) = CalculateBounds(entity);
RenderProjectionId id = StaticEntityId(landblockId, entity.Id);
uint fullCellId = entity.ParentCellId ?? landblockId;
return new RenderProjectionRecord(
return RenderProjectionRecordFactory.ProjectEntity(
id,
InteriorEntityPartition.IsIndoorCellId(entity.ParentCellId)
? RenderProjectionClass.IndoorCellStatic
: RenderProjectionClass.OutdoorStatic,
default,
transform,
new PreviousRenderTransform(transform.LocalToWorld),
new RenderMeshSet(
RenderAssetHandle.FromRaw(
fingerprint.Geometry.Low ^ fingerprint.Geometry.High),
entity.MeshRefs.Count,
0),
new RenderMaterialVariant(
fingerprint.Appearance.Low,
fingerprint.Appearance.High,
1.0f),
new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(fullCellId),
landblockId,
fullCellId),
new RenderWorldBounds(minimum, maximum),
flags,
default,
id.ToSortKey(),
RenderDirtyMask.All,
new RenderSourceMetadata(
entity.Id,
entity.ServerGuid,
entity.SourceGfxObjOrSetupId,
entity.ParentCellId ?? 0,
entity.EffectCellId ?? 0,
entity.BuildingShellAnchorCellId ?? 0,
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance));
landblockId,
fullCellId,
entity,
spatiallyVisible: true);
}
private static RenderProjectionRecord ProjectEnvCellShell(
@ -332,95 +287,6 @@ internal sealed class StaticRenderProjectionJournal :
RenderSceneHash128.Empty));
}
private void AppendUpdates(
in RenderProjectionRecord prior,
in RenderProjectionRecord current)
{
if (prior.Transform != current.Transform
|| prior.Bounds != current.Bounds
|| prior.SortKey != current.SortKey
|| prior.Source.TransformFingerprint
!= current.Source.TransformFingerprint)
{
_journal.Update(
RenderProjectionDeltaKind.UpdateTransform,
in current);
}
if (prior.MeshSet != current.MeshSet
|| prior.Material != current.Material
|| prior.DegradeState != current.DegradeState
|| prior.Source.GeometryFingerprint != current.Source.GeometryFingerprint
|| prior.Source.AppearanceFingerprint != current.Source.AppearanceFingerprint)
{
_journal.Update(
RenderProjectionDeltaKind.UpdateAppearance,
in current);
}
if (prior.Residency != current.Residency)
{
_journal.Update(
RenderProjectionDeltaKind.Rebucket,
in current);
}
if (prior.Flags != current.Flags)
{
_journal.Update(
RenderProjectionDeltaKind.UpdateFlags,
in current);
}
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(
WorldEntity entity)
{
Vector3 position = entity.Position;
if (entity.HasLocalBounds)
{
Vector3 localMin = entity.LocalBoundMin;
Vector3 localMax = entity.LocalBoundMax;
Vector3 minimum = default;
Vector3 maximum = default;
for (int cornerIndex = 0; cornerIndex < 8; cornerIndex++)
{
Vector3 corner = new(
(cornerIndex & 1) == 0 ? localMin.X : localMax.X,
(cornerIndex & 2) == 0 ? localMin.Y : localMax.Y,
(cornerIndex & 4) == 0 ? localMin.Z : localMax.Z);
Vector3 transformed = Vector3.Transform(corner, entity.Rotation);
if (cornerIndex == 0)
{
minimum = transformed;
maximum = transformed;
}
else
{
minimum = Vector3.Min(minimum, transformed);
maximum = Vector3.Max(maximum, transformed);
}
}
Vector3 margin = new(DefaultAabbRadius);
return (
position + minimum - margin,
position + maximum + margin);
}
float radius = DefaultAabbRadius;
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
radius = MathF.Max(
radius,
DefaultAabbRadius
+ entity.MeshRefs[i].PartTransform.Translation.Length());
}
Vector3 extent = new(radius);
return (position - extent, position + extent);
}
private RenderOwnerIncarnation NextIncarnation()
{
if (_nextIncarnation == ulong.MaxValue)

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.World;
@ -136,6 +137,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
_animatedEntities;
private readonly EquippedChildRenderController _equippedChildren;
private readonly LiveEffectFrameController _effects;
private readonly ILiveRenderProjectionSink? _renderProjections;
public LiveObjectFrameController(
RetailInboundEventDispatcher inboundEvents,
@ -149,7 +151,8 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
LiveEntityAnimationPresenter animationPresenter,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animatedEntities,
EquippedChildRenderController equippedChildren,
LiveEffectFrameController effects)
LiveEffectFrameController effects,
ILiveRenderProjectionSink? renderProjections = null)
{
_inboundEvents = inboundEvents ?? throw new ArgumentNullException(nameof(inboundEvents));
_localPlayerFrame = localPlayerFrame
@ -168,6 +171,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
_equippedChildren = equippedChildren
?? throw new ArgumentNullException(nameof(equippedChildren));
_effects = effects ?? throw new ArgumentNullException(nameof(effects));
_renderProjections = renderProjections;
}
public void Tick(float deltaSeconds) =>
@ -202,6 +206,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
if (_animatedEntities.Count > 0)
_animationPresenter.Present(schedules);
_equippedChildren.Tick();
_renderProjections?.SynchronizeActiveSources();
// Retail CPhysicsObj::animate_static_object @ 0x00513DF0 runs
// Script -> Particle -> process_hooks. acdream intentionally retains
// the TS-51 static-order difference here: ProcessHooks precedes the
@ -221,24 +226,28 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
private readonly EquippedChildRenderController _equippedChildren;
private readonly ParticleHookSink _particleSink;
private readonly LiveEntityLightController _lights;
private readonly ILiveRenderProjectionSink? _renderProjections;
public LiveSpatialPresentationReconciler(
EntityEffectController entityEffects,
EquippedChildRenderController equippedChildren,
ParticleHookSink particleSink,
LiveEntityLightController lights)
LiveEntityLightController lights,
ILiveRenderProjectionSink? renderProjections = null)
{
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_equippedChildren = equippedChildren
?? throw new ArgumentNullException(nameof(equippedChildren));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
_renderProjections = renderProjections;
}
public void Reconcile()
{
_entityEffects.RefreshLiveOwnerPoses();
_equippedChildren.Tick();
_renderProjections?.SynchronizeActiveSources();
_particleSink.RefreshAttachedEmitters();
_lights.Refresh();
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Net;
@ -80,16 +81,19 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
private readonly Func<LiveEntityRecord, bool> _prepareEffects;
private readonly Func<LiveEntityRecord, bool> _presentState;
private readonly Func<LiveEntityRecord, bool> _replayEffects;
private readonly ILiveRenderProjectionSink? _renderProjection;
public LiveEntityReadyPublisher(
LiveEntityRuntime runtime,
EntityEffectController effects,
LiveEntityPresentationController presentation)
LiveEntityPresentationController presentation,
ILiveRenderProjectionSink? renderProjection = null)
: this(
runtime,
record => effects.PrepareLiveEntityOwner(record.ServerGuid),
record => presentation.OnLiveEntityReady(record.ServerGuid),
record => effects.ReplayPendingForLiveEntity(record.ServerGuid))
record => effects.ReplayPendingForLiveEntity(record.ServerGuid),
renderProjection)
{
ArgumentNullException.ThrowIfNull(effects);
ArgumentNullException.ThrowIfNull(presentation);
@ -99,7 +103,8 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
LiveEntityRuntime runtime,
Func<LiveEntityRecord, bool> prepareEffects,
Func<LiveEntityRecord, bool> presentState,
Func<LiveEntityRecord, bool> replayEffects)
Func<LiveEntityRecord, bool> replayEffects,
ILiveRenderProjectionSink? renderProjection = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_prepareEffects = prepareEffects
@ -108,6 +113,7 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
?? throw new ArgumentNullException(nameof(presentState));
_replayEffects = replayEffects
?? throw new ArgumentNullException(nameof(replayEffects));
_renderProjection = renderProjection;
}
public bool Publish(LiveEntityReadyCandidate candidate)
@ -125,7 +131,13 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
return false;
}
return _replayEffects(expectedRecord)
if (!_replayEffects(expectedRecord)
|| !candidate.IsCurrent(_runtime))
{
return false;
}
return (_renderProjection?.OnEntityReady(candidate) ?? true)
&& candidate.IsCurrent(_runtime);
}
}