From ea0da8c8ae3ed8f749cd32aecfb04693f3ca9d09 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 21:45:16 +0200 Subject: [PATCH] refactor(streaming): compose landblock presentation transaction Own render, physics, DAT-static presentation, and retained static-resource rebinds behind retryable receipts so a failed publication resumes its exact unfinished suffix without replaying retail create-time defaults. Co-authored-by: Codex --- src/AcDream.App/Rendering/GameWindow.cs | 48 ++- .../RetailStaticAnimatingObjectScheduler.cs | 65 ++- .../Rendering/Vfx/EntityScriptActivator.cs | 128 +++++- src/AcDream.App/Streaming/GpuWorldState.cs | 47 ++- .../Streaming/LandblockPhysicsPublisher.cs | 58 ++- .../LandblockPresentationPipeline.cs | 104 ++++- .../Streaming/LandblockRenderPublisher.cs | 140 +++++-- .../LandblockStaticPresentationPublisher.cs | 346 ++++++++++++++++ .../Streaming/StreamingController.cs | 20 +- ...tailStaticAnimatingObjectSchedulerTests.cs | 36 ++ .../Vfx/EntityScriptActivatorTests.cs | 107 ++++- .../Streaming/GpuWorldStateActivatorTests.cs | 42 +- ...dblockConcretePresentationPipelineTests.cs | 380 ++++++++++++++++++ .../LandblockPhysicsPublisherTests.cs | 3 +- ...ndblockStaticPresentationPublisherTests.cs | 309 ++++++++++++++ 15 files changed, 1744 insertions(+), 89 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 66e9e207..c4f3c771 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -153,6 +153,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; + private AcDream.App.Streaming.LandblockStaticPresentationPublisher? + _landblockStaticPresentationPublisher; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; @@ -2194,7 +2196,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _translucencyFades.ClearEntity(ownerId); }, (entity, info) => _staticAnimationScheduler.Register(entity, info), - ownerId => _staticAnimationScheduler.Unregister(ownerId)); + ownerId => _staticAnimationScheduler.Unregister(ownerId), + (entity, info) => _staticAnimationScheduler.Rebind(entity, info)); _entityScriptActivator = entityScriptActivator; // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock @@ -2429,6 +2432,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext new AcDream.App.Streaming.LandblockPhysicsPublisher( _physicsEngine, _heightTable!); + _landblockStaticPresentationPublisher = + new AcDream.App.Streaming.LandblockStaticPresentationPublisher( + _lightingSink!, + _translucencyFades, + _worldGameState, + _worldEvents); _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( @@ -2520,6 +2529,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); _streamer.Start(); + var landblockPresentationPipeline = + new AcDream.App.Streaming.LandblockPresentationPipeline( + _landblockRenderPublisher!, + _landblockPhysicsPublisher!, + _landblockStaticPresentationPublisher!, + _worldState, + onLandblockLoaded: loadedLandblockId => + _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), + ensureEnvCellMeshes: + _landblockRenderPublisher!.PrepareAfterRenderPins, + retirementCoordinator: _landblockRetirements); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( new AcDream.App.Streaming.LandblockBuildRequest( @@ -2542,7 +2562,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext onLandblockLoaded: loadedLandblockId => _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, - retirementCoordinator: _landblockRetirements); + retirementCoordinator: _landblockRetirements, + presentationPipeline: landblockPresentationPipeline); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( @@ -3793,19 +3814,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext ticket.RunForEachEntity( AcDream.App.Streaming.LandblockRetirementStage.EntityLighting, entity => !staticOnly || entity.ServerGuid == 0, - entity => _lightingSink?.UnregisterOwner(entity.Id)); + entity => + { + if (entity.ServerGuid == 0) + _landblockStaticPresentationPublisher!.RemoveLighting(entity); + else + _lightingSink?.UnregisterOwner(entity.Id); + }); ticket.RunForEachEntity( AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency, entity => !staticOnly || entity.ServerGuid == 0, - entity => _translucencyFades.ClearEntity(entity.Id)); + entity => + { + if (entity.ServerGuid == 0) + _landblockStaticPresentationPublisher!.RemoveTranslucency(entity); + else + _translucencyFades.ClearEntity(entity.Id); + }); ticket.RunForEachEntity( AcDream.App.Streaming.LandblockRetirementStage.PluginProjection, static entity => entity.ServerGuid == 0, - entity => - { - _worldGameState.RemoveById(entity.Id); - _worldEvents.ForgetEntity(entity.Id); - }); + entity => _landblockStaticPresentationPublisher! + .RemovePluginProjection(entity)); if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) { diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 3c4ca4ea..ab813a51 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -78,7 +78,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram internal int Count => _owners.Count; - public void Register(WorldEntity entity, ScriptActivationInfo info) + public bool Register(WorldEntity entity, ScriptActivationInfo info) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(info); @@ -86,13 +86,13 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram || info.Setup is not { } setup || info.DefaultAnimationId == 0) { - return; + return false; } if (_owners.TryGetValue(entity.Id, out Owner? retained)) { if (ReferenceEquals(retained.Entity, entity)) - return; + return true; throw new InvalidOperationException( $"DAT-static animation owner 0x{entity.Id:X8} is already registered."); } @@ -110,7 +110,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram new MotionTable(), _animationLoader); if (!sequencer.HasCurrentNode) - return; + return false; } int partCount = setup.Parts.Count; @@ -131,6 +131,63 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram SurfaceOverrides = surfaces, PartAvailable = available, }); + return true; + } + + /// + /// Rebinds the retained retail static-animation workset member to a fresh + /// landblock snapshot without reconstructing its PartArray sequencer or + /// restarting Setup.DefaultAnimation. + /// + public void Rebind(WorldEntity entity, ScriptActivationInfo info) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(info); + if (!_owners.TryGetValue(entity.Id, out Owner? owner)) + { + throw new InvalidOperationException( + $"DAT-static animation owner 0x{entity.Id:X8} is not registered."); + } + if (ReferenceEquals(owner.Entity, entity)) + return; + if (entity.ServerGuid != 0 + || !info.UsesStaticAnimationWorkset + || info.Setup is null + || info.DefaultAnimationId == 0) + { + throw new InvalidOperationException( + $"DAT-static animation owner 0x{entity.Id:X8} cannot be rebound to incompatible Setup data."); + } + + int partCount = owner.Setup.Parts.Count; + uint[] gfxIds = BuildPartGfxIds(owner.Setup, entity); + bool[] available = new bool[partCount]; + if (info.PartAvailability is { } supplied) + { + for (int i = 0; i < partCount && i < supplied.Count; i++) + available[i] = supplied[i]; + } + + // Compose publishes the owner's retained lists directly. Detach the + // displaced immutable snapshot before those lists continue advancing + // under the replacement entity. + WorldEntity displaced = owner.Entity; + if (ReferenceEquals(displaced.MeshRefs, owner.MeshRefs)) + displaced.MeshRefs = owner.MeshRefs.ToArray(); + if (ReferenceEquals(displaced.IndexedPartTransforms, owner.PartPoses) + || ReferenceEquals(displaced.IndexedPartAvailable, owner.PartAvailable)) + { + displaced.SetIndexedPartPoses( + owner.PartPoses.ToArray(), + owner.PartAvailable.ToArray()); + } + + owner.Entity = entity; + owner.PartGfxIds = gfxIds; + owner.SurfaceOverrides = MatchSurfaceOverrides(entity, gfxIds, available); + owner.PartAvailable = available; + owner.HasPreparedLivePartFrames = false; + owner.PreparedLivePartFrames.Clear(); } /// diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index f314c9fd..d998be06 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -40,6 +40,12 @@ public sealed class EntityScriptActivator public bool ScriptsStopped; public bool EmittersStopped; public bool PoseRemoved; + public WorldEntity? PendingReplacement; + public ScriptActivationInfo? PendingReplacementInfo; + public bool ReplacementPosePublished; + public bool ReplacementEffectOwnerUpdated; + public bool ReplacementAnimationOwnerUpdated; + public bool ReplacementAnchorUpdated; } private readonly PhysicsScriptRunner _scriptRunner; @@ -48,8 +54,9 @@ public sealed class EntityScriptActivator private readonly Func _resolver; private readonly Action? _registerDatStaticEffectOwner; private readonly Action? _unregisterDatStaticEffectOwner; - private readonly Action? _registerDatStaticAnimationOwner; + private readonly Func? _registerDatStaticAnimationOwner; private readonly Action? _unregisterDatStaticAnimationOwner; + private readonly Action? _rebindDatStaticAnimationOwner; private readonly Dictionary _staticOwners = new(); public EntityScriptActivator( @@ -59,8 +66,9 @@ public sealed class EntityScriptActivator Func resolver, Action? registerDatStaticEffectOwner = null, Action? unregisterDatStaticEffectOwner = null, - Action? registerDatStaticAnimationOwner = null, - Action? unregisterDatStaticAnimationOwner = null) + Func? registerDatStaticAnimationOwner = null, + Action? unregisterDatStaticAnimationOwner = null, + Action? rebindDatStaticAnimationOwner = null) { _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner)); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); @@ -70,6 +78,116 @@ public sealed class EntityScriptActivator _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; _registerDatStaticAnimationOwner = registerDatStaticAnimationOwner; _unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner; + _rebindDatStaticAnimationOwner = rebindDatStaticAnimationOwner; + } + + /// + /// Rebinds a retained DAT-static logical owner to the fresh immutable + /// snapshot produced by a same-landblock + /// rehydrate. This is not a retail create edge: default Setup scripts and + /// existing emitters keep their logical lifetime and are never replayed. + /// + public void OnRebindStatic(WorldEntity previous, WorldEntity replacement) + { + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(replacement); + if (previous.ServerGuid != 0 + || replacement.ServerGuid != 0 + || previous.Id == 0 + || previous.Id != replacement.Id) + { + throw new ArgumentException( + "DAT-static rebind requires the same nonzero local ID.", + nameof(replacement)); + } + + uint key = previous.Id; + if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state)) + return; + if (ReferenceEquals(state.Entity, replacement)) + return; + if (!ReferenceEquals(state.Entity, previous)) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} does not match the retained incarnation."); + } + if (state.ReleaseStarted) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} cannot rebind while teardown is pending."); + } + + if (state.PendingReplacement is null) + { + ScriptActivationInfo replacementInfo = _resolver(replacement) + ?? throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} lost its Setup activation data during rebind."); + ValidateLogicalRebind(state, replacement, replacementInfo); + state.PendingReplacement = replacement; + state.PendingReplacementInfo = replacementInfo; + } + else if (!ReferenceEquals(state.PendingReplacement, replacement)) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} already has another rebind pending."); + } + + ScriptActivationInfo info = state.PendingReplacementInfo!; + if (state.PosePublished && !state.ReplacementPosePublished) + { + _poses.Publish(replacement, info.PartTransforms, info.PartAvailability); + state.ReplacementPosePublished = true; + } + if (state.EffectOwnerRegistered && !state.ReplacementEffectOwnerUpdated) + { + _registerDatStaticEffectOwner!(key, replacement, info.EffectProfile!); + state.ReplacementEffectOwnerUpdated = true; + } + if (state.AnimationOwnerRegistered && !state.ReplacementAnimationOwnerUpdated) + { + (_rebindDatStaticAnimationOwner + ?? throw new InvalidOperationException( + "DAT-static animation ownership has no retained-owner rebind callback."))( + replacement, + info); + state.ReplacementAnimationOwnerUpdated = true; + } + if (!state.ReplacementAnchorUpdated) + { + _scriptRunner.SetOwnerAnchor(key, replacement.Position); + state.ReplacementAnchorUpdated = true; + } + + state.Entity = replacement; + state.Info = info; + state.PendingReplacement = null; + state.PendingReplacementInfo = null; + state.ReplacementPosePublished = false; + state.ReplacementEffectOwnerUpdated = false; + state.ReplacementAnimationOwnerUpdated = false; + state.ReplacementAnchorUpdated = false; + } + + private static void ValidateLogicalRebind( + StaticOwnerState state, + WorldEntity replacement, + ScriptActivationInfo replacementInfo) + { + ScriptActivationInfo retained = state.Info; + bool retainedAnimation = retained.UsesStaticAnimationWorkset + && retained.DefaultAnimationId != 0; + bool replacementAnimation = replacementInfo.UsesStaticAnimationWorkset + && replacementInfo.DefaultAnimationId != 0; + if (state.Entity.SourceGfxObjOrSetupId != replacement.SourceGfxObjOrSetupId + || retained.ScriptId != replacementInfo.ScriptId + || retainedAnimation != replacementAnimation + || (retainedAnimation + && retained.DefaultAnimationId != replacementInfo.DefaultAnimationId) + || (retained.EffectProfile is null) != (replacementInfo.EffectProfile is null)) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{state.Entity.Id:X8} changed Setup lifetime during a retained rebind."); + } } public void OnCreate(WorldEntity entity) @@ -156,8 +274,8 @@ public sealed class EntityScriptActivator && info.DefaultAnimationId != 0 && _registerDatStaticAnimationOwner is not null) { - _registerDatStaticAnimationOwner(entity, info); - state.AnimationOwnerRegistered = true; + state.AnimationOwnerRegistered = + _registerDatStaticAnimationOwner(entity, info); } if (!state.ScriptStarted && info.ScriptId != 0) diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 354e8392..3a96f5d5 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -670,6 +670,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery RequiresActivation: false); } + // Reconcile the only fallible logical-owner edge before consuming any + // pending live/render/tier maps. A retry then sees the exact pending + // suffix even when a resolver or owner callback fails partway through. + landblock = NormalizeLoadedLandblock(landblock); + if (_loaded.TryGetValue(landblock.LandblockId, out LoadedLandblock? displaced)) + ReconcileRetainedStaticEntityScripts(displaced, landblock); + // If pending live entities have been waiting for this landblock, // merge them into the render-thread-owned mutable entity bucket before // storing. Subsequent live spawns append to that bucket in O(1). @@ -681,11 +688,6 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery landblock = NormalizeLoadedLandblock(landblock, merged); _pendingByLandblock.Remove(landblock.LandblockId); } - else - { - landblock = NormalizeLoadedLandblock(landblock); - } - HashSet? mergedRenderIds = additionalRenderIds is null ? null : new HashSet(additionalRenderIds); @@ -703,7 +705,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery tier = LandblockStreamTier.Near; } - if (_loaded.Remove(landblock.LandblockId, out LoadedLandblock? displaced)) + if (_loaded.Remove(landblock.LandblockId, out displaced)) { RemoveLoadedLandblockFromFlatView(displaced); _animatedIndexByLandblock.Remove(landblock.LandblockId); @@ -717,6 +719,39 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery (IEnumerable?)mergedRenderIds ?? Array.Empty()); } + /// + /// A same-landblock rehydrate is an acdream streaming replacement, not a + /// retail logical create/delete edge. Retained static IDs rebind their + /// Setup-owned resources without replaying defaults; omitted IDs perform + /// the same exact teardown used by ordinary retirement. + /// + private void ReconcileRetainedStaticEntityScripts( + LoadedLandblock displaced, + LoadedLandblock replacement) + { + if (_entityScriptActivator is null) + return; + + var replacementsById = new Dictionary(); + for (int i = 0; i < replacement.Entities.Count; i++) + { + WorldEntity entity = replacement.Entities[i]; + if (entity.ServerGuid == 0) + replacementsById.Add(entity.Id, entity); + } + + for (int i = 0; i < displaced.Entities.Count; i++) + { + WorldEntity prior = displaced.Entities[i]; + if (prior.ServerGuid != 0) + continue; + if (replacementsById.TryGetValue(prior.Id, out WorldEntity? retained)) + _entityScriptActivator.OnRebindStatic(prior, retained); + else + _entityScriptActivator.OnRemove(prior); + } + } + internal void ActivateLandblockPresentation( GpuLandblockSpatialPublication publication) { diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 02908273..c13e21ea 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -24,6 +24,7 @@ public sealed class LandblockPhysicsPublication internal object Owner { get; } internal LandblockBuild Build { get; } + internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } public uint LandblockId => Build.Landblock.LandblockId; @@ -127,6 +128,20 @@ public sealed class LandblockPhysicsPublisher /// public LandblockPhysicsPublication BeginPublication( LandblockRenderPublication renderPublication) + { + LandblockPhysicsPublication publication = PreparePublication( + renderPublication); + BeginPublication(publication); + return publication; + } + + /// + /// Captures the exact render receipt before physics mutation begins. The + /// coordinator retains this receipt and can retry the idempotent exact + /// replacement when a cache/backend operation fails. + /// + public LandblockPhysicsPublication PreparePublication( + LandblockRenderPublication renderPublication) { ArgumentNullException.ThrowIfNull(renderPublication); LandblockBuild build = renderPublication.Build; @@ -134,7 +149,25 @@ public sealed class LandblockPhysicsPublisher if (!IsFinite(origin)) throw new ArgumentOutOfRangeException(nameof(renderPublication)); + return new LandblockPhysicsPublication( + _receiptOwner, + build, + origin); + } + + /// + /// Advances the exact terrain/cell/building physics replacement from a + /// retained receipt. + /// + public void BeginPublication(LandblockPhysicsPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return; + long started = Stopwatch.GetTimestamp(); + LandblockBuild build = publication.Build; + Vector3 origin = publication.Origin; LoadedLandblock landblock = build.Landblock; PhysicsDatBundle datBundle = landblock.PhysicsDats ?? PhysicsDatBundle.Empty; @@ -286,12 +319,9 @@ public sealed class LandblockPhysicsPublisher _cellSurfaceCount += cellSurfaces.Count; _portalPlaneCount += portalPlanes.Count; _buildingCount += buildingCount; + publication.BeginCommitted = true; _beginCount++; _basePublishTicks += Stopwatch.GetTimestamp() - started; - return new LandblockPhysicsPublication( - _receiptOwner, - build, - origin); } /// @@ -304,11 +334,10 @@ public sealed class LandblockPhysicsPublisher LandblockPhysicsPublication publication, Action? beforeStaticCollision = null) { - ArgumentNullException.ThrowIfNull(publication); - if (!ReferenceEquals(publication.Owner, _receiptOwner)) - throw new ArgumentException( - "The physics publication receipt belongs to another publisher.", - nameof(publication)); + ValidateReceipt(publication); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Physics publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) return; @@ -651,4 +680,15 @@ public sealed class LandblockPhysicsPublisher float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z); + + private void ValidateReceipt(LandblockPhysicsPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + { + throw new ArgumentException( + "The physics publication receipt belongs to another publisher.", + nameof(publication)); + } + } } diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 5f815cb2..bc7585e4 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -41,6 +41,9 @@ public sealed class LandblockPresentationPipeline public required uint LandblockId; public LandblockStreamTier Tier; public bool PresentationCommitted; + public LandblockRenderPublication? RenderPublication; + public LandblockPhysicsPublication? PhysicsPublication; + public LandblockStaticPresentationPublication? StaticPublication; public bool SpatialCommitted; public GpuLandblockSpatialPublication? SpatialPublication; public bool SpatialPresentationCommitted; @@ -48,7 +51,11 @@ public sealed class LandblockPresentationPipeline public bool LiveRecoveryCommitted; } - private readonly Action _publishBeforeSpatialCommit; + private readonly Action? + _publishBeforeSpatialCommit; + private readonly LandblockRenderPublisher? _renderPublisher; + private readonly LandblockPhysicsPublisher? _physicsPublisher; + private readonly LandblockStaticPresentationPublisher? _staticPublisher; private readonly GpuWorldState _state; private readonly Action? _onLandblockLoaded; private readonly Action? _ensureEnvCellMeshes; @@ -79,6 +86,37 @@ public sealed class LandblockPresentationPipeline demoteNearLayer); } + /// + /// Production constructor. The pipeline retains each concrete owner + /// receipt before mutation and resumes only the unfinished exact stage + /// after a failure. + /// + public LandblockPresentationPipeline( + LandblockRenderPublisher renderPublisher, + LandblockPhysicsPublisher physicsPublisher, + LandblockStaticPresentationPublisher staticPublisher, + GpuWorldState state, + Action? onLandblockLoaded = null, + Action? ensureEnvCellMeshes = null, + LandblockRetirementCoordinator? retirementCoordinator = null) + { + _renderPublisher = renderPublisher + ?? throw new ArgumentNullException(nameof(renderPublisher)); + _physicsPublisher = physicsPublisher + ?? throw new ArgumentNullException(nameof(physicsPublisher)); + _staticPublisher = staticPublisher + ?? throw new ArgumentNullException(nameof(staticPublisher)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + _onLandblockLoaded = onLandblockLoaded; + _ensureEnvCellMeshes = ensureEnvCellMeshes; + // Until Slice 5F internalizes concrete retirement ownership, a + // concrete publication pipeline must share the production coordinator. + // A legacy no-op retirement suffix would detach spatial state while + // leaking the concrete render/physics/static owners. + _retirements = retirementCoordinator + ?? throw new ArgumentNullException(nameof(retirementCoordinator)); + } + public int PendingRetirementCount => _retirements.PendingCount; public int PendingPublicationCount => _publications.Count; @@ -219,22 +257,60 @@ public sealed class LandblockPresentationPipeline { if (!transaction.PresentationCommitted) { - try + if (_renderPublisher is not null + && _physicsPublisher is not null + && _staticPublisher is not null) { - _publishBeforeSpatialCommit(transaction.Build, transaction.MeshData); + transaction.RenderPublication ??= + _renderPublisher.PreparePublication( + transaction.Build, + transaction.MeshData); + transaction.PhysicsPublication ??= + _physicsPublisher.PreparePublication( + transaction.RenderPublication); + transaction.StaticPublication ??= + _staticPublisher.PreparePublication( + transaction.PhysicsPublication); + + // Prepare every immutable receipt before the first external + // mutation. Cross-owner identity/data errors therefore block + // the publication without leaving a committed prefix. + _renderPublisher.BeginPublication(transaction.RenderPublication); + _physicsPublisher.BeginPublication(transaction.PhysicsPublication); + + // Retail establishes the building/visible-cell suffix before + // each static object publishes lights, collision, and scripts. + _renderPublisher.CompletePublication( + transaction.RenderPublication); + + _staticPublisher.BeginPublication(transaction.StaticPublication); + _physicsPublisher.CompletePublication( + transaction.PhysicsPublication, + entity => _staticPublisher.PrepareEntityBeforeCollision( + transaction.StaticPublication, + entity)); + _staticPublisher.CompletePublication( + transaction.StaticPublication); } - catch (Exception error) + else { - // The legacy GameWindow callback still spans several concrete - // owners and cannot report a committed prefix. Until Slice-5 - // checkpoints C-E replace it with individually ledgered - // publishers, its production wrapper conservatively reports a - // committed mutation. Test/compatibility callbacks retain the - // established strong-exception contract unless they explicitly - // report otherwise. - if (error is StreamingMutationException { MutationCommitted: true }) - _publications.Remove(result); - throw; + try + { + (_publishBeforeSpatialCommit + ?? throw new InvalidOperationException( + "The presentation pipeline has no publication owners."))( + transaction.Build, + transaction.MeshData); + } + catch (Exception error) + { + // Compatibility callbacks cannot report an exact retained + // receipt. Preserve the established fail-fast rule only + // for callbacks that explicitly report committed mutation. + if (error is StreamingMutationException { MutationCommitted: true }) + _publications.Remove(result); + throw; + } } transaction.PresentationCommitted = true; } diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs index eb41ae7d..2e240ba3 100644 --- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -20,17 +20,32 @@ public sealed class LandblockRenderPublication internal LandblockRenderPublication( object owner, LandblockBuild build, + LandblockMeshData meshData, Vector3 origin, - Dictionary visibilityCells) + Dictionary visibilityCells, + Vector3 aabbMin, + Vector3 aabbMax) { Owner = owner; Build = build; + MeshData = meshData; Origin = origin; _visibilityCells = new ReadOnlyDictionary(visibilityCells); + AabbMin = aabbMin; + AabbMax = aabbMax; } internal object Owner { get; } internal LandblockBuild Build { get; } + internal LandblockMeshData MeshData { get; } + internal Vector3 AabbMin { get; } + internal Vector3 AabbMax { get; } + internal bool TerrainCommitted { get; set; } + internal bool VisibilityCommitted { get; set; } + internal bool AabbCommitted { get; set; } + internal bool BeginCommitted { get; set; } + internal bool BuildingRegistryCommitted { get; set; } + internal bool EnvCellsCommitted { get; set; } internal bool CompletionCommitted { get; set; } public uint LandblockId => Build.Landblock.LandblockId; @@ -137,6 +152,22 @@ public sealed class LandblockRenderPublisher public LandblockRenderPublication BeginPublication( LandblockBuild build, LandblockMeshData meshData) + { + LandblockRenderPublication publication = PreparePublication( + build, + meshData); + BeginPublication(publication); + return publication; + } + + /// + /// Validates and captures the immutable render transaction before its + /// first external mutation. A coordinator retains this receipt when an + /// exact-replacement callback fails, then resumes the same publication. + /// + public LandblockRenderPublication PreparePublication( + LandblockBuild build, + LandblockMeshData meshData) { ArgumentNullException.ThrowIfNull(build); ArgumentNullException.ThrowIfNull(meshData); @@ -145,7 +176,6 @@ public sealed class LandblockRenderPublisher "Render publication requires the build's captured origin.", nameof(build)); - long beginStarted = Stopwatch.GetTimestamp(); uint landblockId = build.Landblock.LandblockId; if (build.EnvCells is { } candidateEnvCells && candidateEnvCells.LandblockId != landblockId) @@ -157,28 +187,64 @@ public sealed class LandblockRenderPublisher Vector3 origin = ComputeOrigin(landblockId, build.Origin); - long terrainStarted = Stopwatch.GetTimestamp(); - _publishTerrain(landblockId, meshData, origin); - _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; - - var committedCells = new Dictionary(); + var visibilityCells = new Dictionary(); if (build.EnvCells is { } envCells) { - _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); foreach (LoadedCell cell in envCells.VisibilityCells) - committedCells[cell.CellId] = cell; + visibilityCells[cell.CellId] = cell; } - (Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin); - _worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax); - - _beginCount++; - _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; return new LandblockRenderPublication( _receiptOwner, build, + meshData, origin, - committedCells); + visibilityCells, + aabbMin, + aabbMax); + } + + /// + /// Advances the render prefix from a retained receipt. Every operation is + /// exact replacement, so a callback failure can safely retry this stage. + /// + public void BeginPublication(LandblockRenderPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return; + + long beginStarted = Stopwatch.GetTimestamp(); + LandblockBuild build = publication.Build; + uint landblockId = publication.LandblockId; + + if (!publication.TerrainCommitted) + { + long terrainStarted = Stopwatch.GetTimestamp(); + _publishTerrain(landblockId, publication.MeshData, publication.Origin); + _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; + publication.TerrainCommitted = true; + } + + if (!publication.VisibilityCommitted) + { + if (build.EnvCells is { } envCells) + _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); + publication.VisibilityCommitted = true; + } + + if (!publication.AabbCommitted) + { + _worldState.SetLandblockAabb( + landblockId, + publication.AabbMin, + publication.AabbMax); + publication.AabbCommitted = true; + } + + publication.BeginCommitted = true; + _beginCount++; + _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; } /// @@ -188,28 +254,35 @@ public sealed class LandblockRenderPublisher /// public void CompletePublication(LandblockRenderPublication publication) { - ArgumentNullException.ThrowIfNull(publication); - if (!ReferenceEquals(publication.Owner, _receiptOwner)) - throw new ArgumentException( - "The render publication receipt belongs to another publisher.", - nameof(publication)); + ValidateReceipt(publication); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Render publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) return; long started = Stopwatch.GetTimestamp(); LandblockBuild build = publication.Build; uint landblockId = publication.LandblockId; - if (build.Landblock.PhysicsDats?.Info is { } info) + if (!publication.BuildingRegistryCommitted) { - uint registryKey = landblockId & 0xFFFF0000u; - _buildingRegistries[registryKey] = BuildingLoader.Build( - info, - landblockId, - publication.VisibilityCells); + if (build.Landblock.PhysicsDats?.Info is { } info) + { + uint registryKey = landblockId & 0xFFFF0000u; + _buildingRegistries[registryKey] = BuildingLoader.Build( + info, + landblockId, + publication.VisibilityCells); + } + publication.BuildingRegistryCommitted = true; } - if (build.EnvCells is { } envCells) - _commitEnvCells?.Invoke(envCells); + if (!publication.EnvCellsCommitted) + { + if (build.EnvCells is { } envCells) + _commitEnvCells?.Invoke(envCells); + publication.EnvCellsCommitted = true; + } publication.CompletionCommitted = true; _completeCount++; @@ -287,4 +360,15 @@ public sealed class LandblockRenderPublisher new Vector3(origin.X, origin.Y, zMin), new Vector3(origin.X + 192f, origin.Y + 192f, zMax)); } + + private void ValidateReceipt(LandblockRenderPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + { + throw new ArgumentException( + "The render publication receipt belongs to another publisher.", + nameof(publication)); + } + } } diff --git a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs new file mode 100644 index 00000000..345369ab --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs @@ -0,0 +1,346 @@ +using AcDream.Core.Lighting; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Streaming; + +/// +/// Retained receipt for the DAT-static presentation stages of one landblock +/// publication. The physics publisher calls the light/translucency stage at +/// the existing per-object point immediately before ordinary collision. +/// +public sealed class LandblockStaticPresentationPublication +{ + private readonly IReadOnlyDictionary _entities; + private readonly IReadOnlyDictionary _snapshots; + + internal LandblockStaticPresentationPublication( + object owner, + LandblockPhysicsPublication physicsPublication, + Dictionary entities, + Dictionary snapshots, + HashSet previouslyActiveIds) + { + Owner = owner; + PhysicsPublication = physicsPublication; + _entities = entities; + _snapshots = snapshots; + PreviouslyActiveIds = previouslyActiveIds; + } + + internal object Owner { get; } + internal LandblockPhysicsPublication PhysicsPublication { get; } + internal HashSet PreviouslyActiveIds { get; } + internal int PriorCleanupCursor { get; set; } + internal int PluginCursor { get; set; } + internal bool BeginCommitted { get; set; } + internal bool CompletionCommitted { get; set; } + + public uint LandblockId => PhysicsPublication.LandblockId; + public IReadOnlyDictionary Entities => _entities; + public IReadOnlyDictionary Snapshots => _snapshots; +} + +public readonly record struct LandblockStaticPresentationDiagnostics( + long BeginCount, + long CompleteCount, + long LightReplacementCount, + long PluginSpawnCount, + long PluginRefreshCount, + long PluginRemovalCount, + int ActiveLandblockCount, + int ActiveEntityCount); + +/// +/// Update-thread owner of DAT-static lights, translucency lifetime, and plugin +/// projection. Setup defaults remain activated exactly once at the existing +/// post-spatial render-pin boundary owned by . +/// +/// +/// Retail constructs each static CPhysicsObj from Setup defaults before +/// CObjCell::init_objects (0x0052B420) inserts/refloods it. The light +/// state follows CPhysicsObj::set_lights (0x0050FCF0); translucency +/// starts from the new object's Setup/material state before later FPHooks. +/// Plugin projection is an acdream adaptation and therefore emits at most one +/// logical spawn per retained static ID, while reapply only refreshes current +/// state. +/// +public sealed class LandblockStaticPresentationPublisher +{ + private readonly object _receiptOwner = new(); + private readonly LightingHookSink _lighting; + private readonly TranslucencyFadeManager _translucency; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly Dictionary> + _activeByLandblock = new(); + private readonly Dictionary _landblockByEntityId = new(); + + private long _beginCount; + private long _completeCount; + private long _lightReplacementCount; + private long _pluginSpawnCount; + private long _pluginRefreshCount; + private long _pluginRemovalCount; + + public LandblockStaticPresentationPublisher( + LightingHookSink lighting, + TranslucencyFadeManager translucency, + WorldGameState worldState, + WorldEvents worldEvents) + { + _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); + _translucency = translucency + ?? throw new ArgumentNullException(nameof(translucency)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + } + + public LandblockStaticPresentationDiagnostics Diagnostics => new( + _beginCount, + _completeCount, + _lightReplacementCount, + _pluginSpawnCount, + _pluginRefreshCount, + _pluginRemovalCount, + _activeByLandblock.Count, + _landblockByEntityId.Count); + + public LandblockStaticPresentationPublication PreparePublication( + LandblockPhysicsPublication physicsPublication) + { + ArgumentNullException.ThrowIfNull(physicsPublication); + + uint canonical = Canonicalize(physicsPublication.LandblockId); + _activeByLandblock.TryGetValue( + canonical, + out Dictionary? active); + var entities = new Dictionary(); + var snapshots = new Dictionary(); + foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities) + { + if (entity.ServerGuid != 0) + continue; + if (!entities.TryAdd(entity.Id, entity)) + { + throw new InvalidOperationException( + $"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " + + $"0x{entity.Id:X8}."); + } + if (_landblockByEntityId.TryGetValue(entity.Id, out uint owner) + && owner != canonical) + { + throw new InvalidOperationException( + $"DAT-static ID 0x{entity.Id:X8} is already owned by " + + $"landblock 0x{owner:X8}."); + } + if (active is not null + && active.TryGetValue(entity.Id, out WorldEntitySnapshot retained) + && retained.SourceId != entity.SourceGfxObjOrSetupId) + { + throw new InvalidOperationException( + $"Retained DAT-static ID 0x{entity.Id:X8} changed source " + + $"from 0x{retained.SourceId:X8} to " + + $"0x{entity.SourceGfxObjOrSetupId:X8}."); + } + + snapshots.Add(entity.Id, Snapshot(entity)); + } + + HashSet previous = active is not null + ? active.Keys.ToHashSet() + : new HashSet(); + return new LandblockStaticPresentationPublication( + _receiptOwner, + physicsPublication, + entities, + snapshots, + previous); + } + + /// + /// Retires omitted prior projections and detaches prior concrete lights + /// before replacement collision. Retained IDs keep their SetLight latch + /// and FPHook translucency state because rehydrate is not a logical create. + /// + public void BeginPublication(LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return; + + uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray(); + while (publication.PriorCleanupCursor < priorIds.Length) + { + uint id = priorIds[publication.PriorCleanupCursor]; + bool retained = publication.Entities.ContainsKey(id); + _lighting.UnregisterOwner(id, forgetState: !retained); + if (!retained) + { + _translucency.ClearEntity(id); + _worldState.RemoveById(id); + _worldEvents.ForgetEntity(id); + _landblockByEntityId.Remove(id); + _pluginRemovalCount++; + } + publication.PriorCleanupCursor++; + } + + publication.BeginCommitted = true; + _beginCount++; + } + + /// + /// Exact replacement called by + /// immediately before registering this object's ordinary collision. + /// + public void PrepareEntityBeforeCollision( + LandblockStaticPresentationPublication publication, + WorldEntity entity) + { + ValidateReceipt(publication); + ArgumentNullException.ThrowIfNull(entity); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Static presentation cannot prepare an entity before begin commits."); + if (entity.ServerGuid != 0) + return; + if (!publication.Entities.TryGetValue(entity.Id, out WorldEntity? expected) + || !ReferenceEquals(expected, entity)) + { + throw new InvalidOperationException( + $"DAT-static entity 0x{entity.Id:X8} does not belong to this publication."); + } + + bool retained = publication.PreviouslyActiveIds.Contains(entity.Id); + _lighting.UnregisterOwner(entity.Id, forgetState: !retained); + if (!retained) + _translucency.ClearEntity(entity.Id); + + PhysicsDatBundle datBundle = publication.PhysicsPublication.Build.Landblock.PhysicsDats + ?? PhysicsDatBundle.Empty; + uint sourceId = entity.SourceGfxObjOrSetupId; + if ((sourceId & 0xFF000000u) == 0x02000000u + && datBundle.Setups.TryGetValue(sourceId, out var setup)) + { + IReadOnlyList lights = LightInfoLoader.Load( + setup, + ownerId: entity.Id, + entityPosition: entity.Position, + entityRotation: entity.Rotation, + cellId: entity.ParentCellId ?? 0u); + for (int i = 0; i < lights.Count; i++) + _lighting.RegisterOwnedLight(lights[i]); + } + _lightReplacementCount++; + } + + /// + /// Publishes the exact current plugin snapshot after collision/reflood. + /// Existing IDs refresh replay state without emitting another logical + /// spawn; new IDs emit once. + /// + public void CompletePublication( + LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (!publication.BeginCommitted + || !publication.PhysicsPublication.CompletionCommitted) + { + throw new InvalidOperationException( + "Static presentation requires completed physics publication."); + } + if (publication.CompletionCommitted) + return; + + KeyValuePair[] snapshots = publication.Snapshots + .OrderBy(pair => pair.Key) + .ToArray(); + while (publication.PluginCursor < snapshots.Length) + { + (uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor]; + _worldState.Add(snapshot); + if (publication.PreviouslyActiveIds.Contains(id)) + { + _worldEvents.UpsertCurrent(snapshot); + _pluginRefreshCount++; + } + else + { + _worldEvents.FireEntitySpawned(snapshot); + _pluginSpawnCount++; + } + _landblockByEntityId[id] = Canonicalize(publication.LandblockId); + publication.PluginCursor++; + } + + uint canonical = Canonicalize(publication.LandblockId); + if (publication.Snapshots.Count == 0) + { + _activeByLandblock.Remove(canonical); + } + else + { + _activeByLandblock[canonical] = publication.Snapshots + .ToDictionary(pair => pair.Key, pair => pair.Value); + } + publication.CompletionCommitted = true; + _completeCount++; + } + + public void RemoveLighting(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) + _lighting.UnregisterOwner(entity.Id); + } + + public void RemoveTranslucency(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) + _translucency.ClearEntity(entity.Id); + } + + public void RemovePluginProjection(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid != 0) + return; + + _worldState.RemoveById(entity.Id); + _worldEvents.ForgetEntity(entity.Id); + if (_landblockByEntityId.Remove(entity.Id, out uint landblockId) + && _activeByLandblock.TryGetValue( + landblockId, + out Dictionary? active)) + { + active.Remove(entity.Id); + if (active.Count == 0) + _activeByLandblock.Remove(landblockId); + } + _pluginRemovalCount++; + } + + private void ValidateReceipt(LandblockStaticPresentationPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + { + throw new ArgumentException( + "The static presentation receipt belongs to another publisher.", + nameof(publication)); + } + } + + private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new( + Id: entity.Id, + SourceId: entity.SourceGfxObjOrSetupId, + Position: entity.Position, + Rotation: entity.Rotation); + + private static uint Canonicalize(uint landblockId) => + (landblockId & 0xFFFF0000u) | 0xFFFFu; +} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 9805d96f..7edfbf09 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -174,21 +174,23 @@ public sealed class StreamingController Action? clearPendingLoads = null, Action? onLandblockLoaded = null, Action? ensureEnvCellMeshes = null, - LandblockRetirementCoordinator? retirementCoordinator = null) + LandblockRetirementCoordinator? retirementCoordinator = null, + LandblockPresentationPipeline? presentationPipeline = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _clearPendingLoads = clearPendingLoads; _state = state; - _presentation = new LandblockPresentationPipeline( - applyTerrain, - state, - onLandblockLoaded, - ensureEnvCellMeshes, - retirementCoordinator, - removeTerrain, - demoteNearLayer); + _presentation = presentationPipeline + ?? new LandblockPresentationPipeline( + applyTerrain, + state, + onLandblockLoaded, + ensureEnvCellMeshes, + retirementCoordinator, + removeTerrain, + demoteNearLayer); NearRadius = nearRadius; FarRadius = farRadius; } diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 7d3954ea..df37c5e7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -64,6 +64,42 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.Equal(2, posePublishes); } + [Fact] + public void Rebind_RetainsSequencerAndPublishesIntoReplacementEntity() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }); + Setup setup = MakeSetup(); + WorldEntity first = MakeEntity(); + ScriptActivationInfo info = new( + ScriptId: 0, + PartTransforms: first.IndexedPartTransforms, + PartAvailability: first.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true); + scheduler.Register(first, info); + scheduler.Tick(1f / 60f); + float phaseBeforeRebind = first.MeshRefs[0].PartTransform.Translation.X; + + WorldEntity replacement = MakeEntity(); + replacement.Position = new Vector3(4f, 5f, 6f); + scheduler.Rebind(replacement, info with + { + PartTransforms = replacement.IndexedPartTransforms, + PartAvailability = replacement.IndexedPartAvailable, + }); + scheduler.Tick(1f / 60f); + + Assert.Equal(1, scheduler.Count); + Assert.True( + replacement.MeshRefs[0].PartTransform.Translation.X > phaseBeforeRebind); + } + [Fact] public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index 0965dd1b..65c76a20 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -282,6 +282,7 @@ public sealed class EntityScriptActivatorTests registerCount++; registeredEntity = entity; Assert.Same(info, actual); + return true; }, unregisterDatStaticAnimationOwner: ownerId => { @@ -313,7 +314,11 @@ public sealed class EntityScriptActivatorTests ScriptId: 0, PartTransforms: Array.Empty(), DefaultAnimationId: 0x03000001u), - registerDatStaticAnimationOwner: (_, _) => registerCount++); + registerDatStaticAnimationOwner: (_, _) => + { + registerCount++; + return true; + }); activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero)); @@ -335,7 +340,11 @@ public sealed class EntityScriptActivatorTests PartTransforms: Array.Empty(), DefaultAnimationId: 0x03000001u, UsesStaticAnimationWorkset: true), - registerDatStaticAnimationOwner: (_, _) => registerCount++, + registerDatStaticAnimationOwner: (_, _) => + { + registerCount++; + return true; + }, unregisterDatStaticAnimationOwner: _ => unregisterCount++); WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero); @@ -378,6 +387,7 @@ public sealed class EntityScriptActivatorTests animationAttempts++; if (animationAttempts == 1) throw new InvalidOperationException("animation acquire fault"); + return true; }); Assert.Throws(() => activator.OnCreate(entity)); @@ -424,7 +434,7 @@ public sealed class EntityScriptActivatorTests if (effectReleaseAttempts == 1) throw new InvalidOperationException("effect release fault"); }, - registerDatStaticAnimationOwner: (_, _) => { }, + registerDatStaticAnimationOwner: (_, _) => true, unregisterDatStaticAnimationOwner: _ => { animationReleaseAttempts++; @@ -465,6 +475,97 @@ public sealed class EntityScriptActivatorTests Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first)); } + [Fact] + public void DatStaticRebind_RetriesOnlyUnfinishedOwnersAndDoesNotReplayDefault() + { + var p = BuildPipeline( + (0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var setup = new DatReaderWriter.DBObjs.Setup(); + ScriptActivationInfo info = new( + ScriptId: 0xAAu, + PartTransforms: Array.Empty(), + EffectProfile: EntityEffectProfile.CreateDatStatic(setup), + Setup: setup, + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + int effectUpdates = 0; + int animationRegistrations = 0; + int animationRebindAttempts = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticEffectOwner: (_, _, _) => effectUpdates++, + registerDatStaticAnimationOwner: (_, _) => + { + animationRegistrations++; + return true; + }, + rebindDatStaticAnimationOwner: (_, _) => + { + animationRebindAttempts++; + if (animationRebindAttempts == 1) + throw new InvalidOperationException("animation rebind fault"); + }); + WorldEntity first = MakeDatStatic(0x80A9B400u, Vector3.One); + WorldEntity replacement = MakeDatStatic( + first.Id, + new Vector3(7f, 8f, 9f)); + activator.OnCreate(first); + + Assert.Throws(() => + activator.OnRebindStatic(first, replacement)); + activator.OnRebindStatic(first, replacement); + activator.OnCreate(replacement); + + Assert.Equal(1, p.Runner.ActiveOwnerCount); + Assert.Equal(2, effectUpdates); + Assert.Equal(1, animationRegistrations); + Assert.Equal(2, animationRebindAttempts); + Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root)); + Assert.Equal(replacement.Position, root.Translation); + p.Runner.Tick(1.1); + Assert.Single(p.Recording.Calls); + Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos); + } + + [Fact] + public void DatStaticRebind_MissingDefaultAnimationNeverClaimsWorksetOwner() + { + var p = BuildPipeline(); + var setup = new DatReaderWriter.DBObjs.Setup(); + ScriptActivationInfo info = new( + ScriptId: 0, + PartTransforms: Array.Empty(), + Setup: setup, + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + int registerAttempts = 0; + int rebindAttempts = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticAnimationOwner: (_, _) => + { + registerAttempts++; + return false; + }, + rebindDatStaticAnimationOwner: (_, _) => rebindAttempts++); + WorldEntity first = MakeDatStatic(0x80A9B400u, Vector3.Zero); + WorldEntity replacement = MakeDatStatic(first.Id, Vector3.One); + + activator.OnCreate(first); + activator.OnRebindStatic(first, replacement); + activator.OnCreate(replacement); + + Assert.Equal(2, registerAttempts); + Assert.Equal(0, rebindAttempts); + Assert.True(p.Poses.TryGetRootPose(first.Id, out _)); + } + [Fact] public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid() { diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs index a210a5f6..a265ed2a 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs @@ -35,6 +35,7 @@ public sealed class GpuWorldStateActivatorTests GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, + EntityEffectPoseRegistry Poses, RecordingSink Recording); private static Pipeline BuildPipeline(uint scriptId) @@ -57,7 +58,7 @@ public sealed class GpuWorldStateActivatorTests _ => new ScriptActivationInfo(scriptId, Array.Empty())); var state = new GpuWorldState(entityScriptActivator: activator); - return new Pipeline(state, runner, sink, recording); + return new Pipeline(state, runner, sink, poses, recording); } private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities) @@ -135,6 +136,45 @@ public sealed class GpuWorldStateActivatorTests Assert.Equal(0, p.Runner.ActiveScriptCount); } + [Fact] + public void SameIdLandblockRehydrate_RebindsOwnerWithoutReplayingDefaultScript() + { + var p = BuildPipeline(scriptId: 0xAAu); + WorldEntity first = DatHydrated( + id: 0x40A9B401u, + pos: new Vector3(1f, 2f, 3f)); + WorldEntity replacement = DatHydrated( + id: first.Id, + pos: new Vector3(8f, 9f, 10f)); + + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, first)); + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, replacement)); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root)); + Assert.Equal(replacement.Position, root.Translation); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos); + } + + [Fact] + public void SameLandblockRehydrate_OmittedStaticRetiresScriptAndPose() + { + var p = BuildPipeline(scriptId: 0xAAu); + WorldEntity entity = DatHydrated(0x40A9B401u, Vector3.Zero); + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, entity)); + Assert.Equal(1, p.Runner.ActiveScriptCount); + Assert.True(p.Poses.TryGetRootPose(entity.Id, out _)); + + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + Assert.False(p.Poses.TryGetRootPose(entity.Id, out _)); + } + [Fact] public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs new file mode 100644 index 00000000..e4d84e69 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -0,0 +1,380 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Terrain; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockConcretePresentationPipelineTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + + [Fact] + public void Loaded_ConcreteOwnersPreserveRetailPrefixAndSpatialOrder() + { + var calls = new List(); + ConcreteFixture fixture = Fixture( + calls, + commitEnvCells: _ => calls.Add("envcell")); + fixture.Events.EntitySpawned += _ => + { + Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount); + Assert.False(fixture.State.IsLoaded(LandblockId)); + calls.Add("plugin"); + }; + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + onLandblockLoaded: _ => calls.Add("live-recovery"), + retirementCoordinator: fixture.Retirements); + LandblockBuild build = Build(Entity(0x80A9B401u)); + + pipeline.PublishLoaded(Result(build)); + + Assert.Equal( + ["terrain", "envcell", "plugin", "pin", "live-recovery"], + calls); + Assert.True(fixture.State.IsNearTier(LandblockId)); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount); + } + + [Fact] + public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes() + { + var calls = new List(); + bool failEnvOnce = true; + ConcreteFixture fixture = Fixture( + calls, + commitEnvCells: _ => + { + calls.Add("envcell"); + if (failEnvOnce) + { + failEnvOnce = false; + throw new InvalidOperationException("injected envcell failure"); + } + }); + fixture.Events.EntitySpawned += _ => calls.Add("plugin"); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + LandblockBuild build = Build(Entity(0x80A9B401u)); + LandblockStreamResult.Loaded result = Result(build); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.True(pipeline.HasPendingPublication(result)); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(0, fixture.Physics.Diagnostics.CompleteCount); + Assert.Equal(0, fixture.Static.Diagnostics.BeginCount); + + pipeline.ResumePublication(result); + + Assert.Equal(1, calls.Count(call => call == "terrain")); + Assert.Equal(2, calls.Count(call => call == "envcell")); + Assert.Equal(1, calls.Count(call => call == "plugin")); + Assert.Equal(1, calls.Count(call => call == "pin")); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount); + Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount); + Assert.False(pipeline.HasPendingPublication(result)); + Assert.True(fixture.State.IsLoaded(LandblockId)); + } + + [Fact] + public void CrossOwnerValidationFailure_BlocksBeforeAnyPresentationMutation() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity first = Entity(0x80A9B401u); + LandblockBuild build = Build(first, Entity(first.Id)); + LandblockStreamResult.Loaded result = Result(build); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + + Assert.Empty(calls); + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(0, fixture.Engine.LandblockCount); + Assert.Equal(0, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(0, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(0, fixture.Static.Diagnostics.BeginCount); + Assert.True(pipeline.HasPendingPublication(result)); + Assert.Equal(1, pipeline.PendingPublicationCount); + } + + [Fact] + public void RetainedIdSourceChange_BlocksBeforeReplacementMutation() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity first = Entity(0x80A9B401u); + pipeline.PublishLoaded(Result(Build(first))); + long renderBegins = fixture.Render.Diagnostics.BeginCount; + long physicsBegins = fixture.Physics.Diagnostics.BeginCount; + long staticBegins = fixture.Static.Diagnostics.BeginCount; + calls.Clear(); + LandblockStreamResult.Loaded changed = Result(Build( + Entity(first.Id, sourceId: 0x01000002u))); + + Assert.Throws(() => + pipeline.PublishLoaded(changed)); + + Assert.Empty(calls); + Assert.Equal(renderBegins, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(physicsBegins, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(staticBegins, fixture.Static.Diagnostics.BeginCount); + Assert.True(pipeline.HasPendingPublication(changed)); + } + + [Fact] + public void ConcreteConstructor_RequiresSharedRetirementCoordinator() + { + ConcreteFixture fixture = Fixture(new List()); + + Assert.Throws(() => + new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: null)); + } + + [Fact] + public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay() + { + ConcreteFixture fixture = Fixture(new List()); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity first = Entity(0x80A9B401u); + WorldEntity replacement = Entity(first.Id); + + pipeline.PublishLoaded(Result(Build(first))); + Assert.Equal(1, fixture.Runner.ActiveOwnerCount); + + pipeline.PublishLoaded(Result(Build(replacement))); + + Assert.Equal(1, fixture.Runner.ActiveOwnerCount); + Assert.True(fixture.Poses.TryGetRootPose(first.Id, out _)); + } + + [Fact] + public void OmittedStaticReapply_RealActivatorBalancesScriptAndPoseOwners() + { + ConcreteFixture fixture = Fixture(new List()); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity entity = Entity(0x80A9B401u); + pipeline.PublishLoaded(Result(Build(entity))); + Assert.Equal(1, fixture.Runner.ActiveOwnerCount); + Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out _)); + + pipeline.PublishLoaded(Result(Build())); + + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _)); + } + + private static ConcreteFixture Fixture( + List calls, + Action? commitEnvCells = null) + { + var poses = new EntityEffectPoseRegistry(); + var particleSink = new ParticleHookSink( + new ParticleSystem(new EmitterDescRegistry()), + poses); + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 10.0, + Hook = new CreateParticleHook { EmitterInfoId = 100u }, + }); + var runner = new PhysicsScriptRunner( + id => id == 0xAAu ? script : null, + new NullHookSink()); + var activator = new EntityScriptActivator( + runner, + particleSink, + poses, + _ => new ScriptActivationInfo(0xAAu, Array.Empty())); + var state = new GpuWorldState( + new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)), + entityScriptActivator: activator); + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var render = new LandblockRenderPublisher( + (_, _, _) => calls.Add("terrain"), + static _ => { }, + new CellVisibility(), + state, + commitEnvCells: commitEnvCells); + var physics = new LandblockPhysicsPublisher(engine, new float[256]); + var lighting = new LightingHookSink( + new LightManager(), + new NullPoseSource()); + var events = new WorldEvents(); + var retirements = new LandblockRetirementCoordinator( + state, + static _ => { }, + static _ => LandblockRetirementStage.None); + return new ConcreteFixture( + state, + engine, + render, + physics, + new LandblockStaticPresentationPublisher( + lighting, + new TranslucencyFadeManager(), + new WorldGameState(), + events), + events, + retirements, + runner, + poses); + } + + private static LandblockStreamResult.Loaded Result(LandblockBuild build) => + new( + LandblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + + private static LandblockBuild Build(params WorldEntity[] entities) + { + uint cellId = (LandblockId & 0xFFFF0000u) | 0x0100u; + var shell = new EnvCellShellPlacement( + cellId, + 0x2_0000_0001UL, + 0x0D000001u, + 1, + ImmutableArray.Empty, + Vector3.Zero, + Quaternion.Identity, + Matrix4x4.Identity, + new WbBoundingBox(Vector3.Zero, Vector3.One), + new WbBoundingBox(Vector3.Zero, Vector3.One)); + var envCells = new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + [shell]); + return new LandblockBuild( + new LoadedLandblock( + LandblockId, + new LandBlock + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }, + entities, + PhysicsDatBundle.Empty), + envCells, + new LandblockBuildOrigin(0xA9, 0xB4)); + } + + private static WorldEntity Entity( + uint id, + uint sourceId = 0x01000001u) => new() + { + Id = id, + SourceGfxObjOrSetupId = sourceId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); + + private sealed record ConcreteFixture( + GpuWorldState State, + PhysicsEngine Engine, + LandblockRenderPublisher Render, + LandblockPhysicsPublisher Physics, + LandblockStaticPresentationPublisher Static, + WorldEvents Events, + LandblockRetirementCoordinator Retirements, + PhysicsScriptRunner Runner, + EntityEffectPoseRegistry Poses); + + private sealed class NullHookSink : IAnimationHookSink + { + public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook) + { + } + } + + private sealed class RecordingMeshAdapter(List calls) : IWbMeshAdapter + { + public void IncrementRefCount(ulong id) + { + } + + public void PinPreparedRenderData(ulong id) => calls.Add("pin"); + + public void DecrementRefCount(ulong id) + { + } + } + + private sealed class NullPoseSource : IEntityEffectPoseSource + { + public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) + { + rootWorld = default; + return false; + } + + public bool TryGetPartPose( + uint localEntityId, + int partIndex, + out Matrix4x4 partLocal) + { + partLocal = default; + return false; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index b82db487..6e7b5101 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -486,7 +486,8 @@ public sealed class LandblockPhysicsPublisherTests MethodInfo begin = Assert.Single( type.GetMethods(), - method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication)); + method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication) + && method.ReturnType == typeof(LandblockPhysicsPublication)); ParameterInfo parameter = Assert.Single(begin.GetParameters()); Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType); Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor => diff --git a/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs new file mode 100644 index 00000000..bc9e1abe --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs @@ -0,0 +1,309 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Terrain; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockStaticPresentationPublisherTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + private const uint AdjacentLandblockId = 0xAAB4FFFFu; + private const uint SetupId = 0x02000042u; + private static readonly float[] HeightTable = new float[256]; + + [Fact] + public void Reapply_ReplacesLightsAndRefreshesPluginWithoutSpawnReplay() + { + var fixture = Fixture(); + int spawnCount = 0; + fixture.Events.EntitySpawned += _ => spawnCount++; + WorldEntity first = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f)); + WorldEntity moved = Entity(first.Id, new Vector3(36f, 12f, 0f)); + + Publish(fixture, Build(LandblockId, [first], BundleWithLight())); + Assert.Equal(1, fixture.Lights.RegisteredCount); + Assert.Equal(1, spawnCount); + Assert.Equal(first.Position, Assert.Single(fixture.World.Entities).Position); + + fixture.Lighting.SetOwnerLighting(first.Id, enabled: false); + fixture.Translucency.StartPartFade(first.Id, 0u, 0f, 0.6f, 0f); + fixture.Translucency.StartPartFade(first.Id, 1u, 0f, 1f, 2f); + fixture.Translucency.AdvanceAll(0.5f); + Assert.True(fixture.Translucency.TryGetCurrentValue( + first.Id, + 1u, + out float activeFadeBefore)); + Publish(fixture, Build(LandblockId, [moved], BundleWithLight())); + + Assert.Equal(1, fixture.Lights.RegisteredCount); + Assert.Equal(1, spawnCount); + Assert.Equal(moved.Position, Assert.Single(fixture.World.Entities).Position); + Assert.False(Assert.Single(fixture.Lighting.GetOwnedLights(first.Id)!).IsLit); + Assert.Equal(1, fixture.Lighting.RetainedOwnerStateCount); + Assert.True(fixture.Translucency.TryGetCurrentValue( + first.Id, + 0u, + out float settled)); + Assert.Equal(0.6f, settled); + Assert.True(fixture.Translucency.TryGetCurrentValue( + first.Id, + 1u, + out float activeFadeAfter)); + Assert.Equal(activeFadeBefore, activeFadeAfter); + Assert.Equal(1, fixture.Publisher.Diagnostics.PluginSpawnCount); + Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRefreshCount); + Assert.Equal(2, fixture.Publisher.Diagnostics.LightReplacementCount); + } + + [Fact] + public void Reapply_OmittedStaticRemovesLightTranslucencyAndPluginProjection() + { + var fixture = Fixture(); + WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f)); + Publish(fixture, Build(LandblockId, [entity], BundleWithLight())); + fixture.Lighting.SetOwnerLighting(entity.Id, enabled: false); + fixture.Translucency.StartPartFade(entity.Id, 0u, 0f, 1f, 2f); + + Publish( + fixture, + Build(LandblockId, Array.Empty(), PhysicsDatBundle.Empty)); + + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount); + Assert.False(fixture.Translucency.TryGetCurrentValue(entity.Id, 0u, out _)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRemovalCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveLandblockCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount); + } + + [Fact] + public void RetirementThenReload_EmitsNewLogicalPluginSpawn() + { + var fixture = Fixture(); + int spawnCount = 0; + fixture.Events.EntitySpawned += _ => spawnCount++; + WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f)); + Publish(fixture, Build(LandblockId, [entity], BundleWithLight())); + + fixture.Publisher.RemoveLighting(entity); + fixture.Publisher.RemoveTranslucency(entity); + fixture.Publisher.RemovePluginProjection(entity); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount); + + Publish(fixture, Build(LandblockId, [entity], BundleWithLight())); + + Assert.Equal(2, spawnCount); + Assert.Equal(1, fixture.Lights.RegisteredCount); + Assert.Equal(1, fixture.Publisher.Diagnostics.ActiveEntityCount); + } + + [Fact] + public void Prepare_RejectsDuplicateAndCrossLandblockStaticIdsBeforeMutation() + { + var fixture = Fixture(); + WorldEntity first = Entity(0x80A9B401u, Vector3.Zero); + LandblockPhysicsPublication duplicatePhysics = PhysicsPrefix( + fixture, + Build(LandblockId, [first, Entity(first.Id, Vector3.One)])); + + Assert.Throws(() => + fixture.Publisher.PreparePublication(duplicatePhysics)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(0, fixture.Lights.RegisteredCount); + + Publish(fixture, Build(LandblockId, [first])); + LandblockPhysicsPublication adjacentPhysics = PhysicsPrefix( + fixture, + Build(AdjacentLandblockId, [Entity(first.Id, new Vector3(192f, 0f, 0f))])); + Assert.Throws(() => + fixture.Publisher.PreparePublication(adjacentPhysics)); + Assert.Single(fixture.World.Entities); + } + + [Fact] + public void LiveProjectionInBuild_IsIgnoredByDatStaticPresentation() + { + var fixture = Fixture(); + WorldEntity live = Entity(7u, Vector3.Zero, serverGuid: 0x80000007u); + + Publish(fixture, Build(LandblockId, [live], BundleWithLight())); + + Assert.Empty(fixture.World.Entities); + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount); + } + + [Fact] + public void ForeignReceipt_IsRejectedWithoutChangingOwnerState() + { + var first = Fixture(); + var second = Fixture(); + LandblockPhysicsPublication physics = PhysicsPrefix( + first, + Build(LandblockId, [Entity(1u, Vector3.Zero)])); + LandblockStaticPresentationPublication receipt = + first.Publisher.PreparePublication(physics); + + Assert.Throws(() => + second.Publisher.BeginPublication(receipt)); + Assert.Equal(0, second.Publisher.Diagnostics.BeginCount); + } + + private static void Publish(FixtureState fixture, LandblockBuild build) + { + LandblockPhysicsPublication physics = PhysicsPrefix(fixture, build); + fixture.Render.CompletePublication(fixture.LastRender!); + LandblockStaticPresentationPublication presentation = + fixture.Publisher.PreparePublication(physics); + fixture.Publisher.BeginPublication(presentation); + fixture.Physics.CompletePublication( + physics, + entity => fixture.Publisher.PrepareEntityBeforeCollision( + presentation, + entity)); + fixture.Publisher.CompletePublication(presentation); + } + + private static LandblockPhysicsPublication PhysicsPrefix( + FixtureState fixture, + LandblockBuild build) + { + LandblockRenderPublication render = fixture.Render.BeginPublication( + build, + EmptyMesh()); + fixture.LastRender = render; + LandblockPhysicsPublication physics = + fixture.Physics.PreparePublication(render); + fixture.Physics.BeginPublication(physics); + return physics; + } + + private static FixtureState Fixture() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var gpuState = new GpuWorldState(); + var render = new LandblockRenderPublisher( + static (_, _, _) => { }, + static _ => { }, + new CellVisibility(), + gpuState); + var physics = new LandblockPhysicsPublisher(engine, HeightTable); + var lights = new LightManager(); + var lighting = new LightingHookSink(lights, new NullPoseSource()); + var translucency = new TranslucencyFadeManager(); + var world = new WorldGameState(); + var events = new WorldEvents(); + return new FixtureState( + render, + physics, + new LandblockStaticPresentationPublisher( + lighting, + translucency, + world, + events), + lights, + lighting, + translucency, + world, + events); + } + + private static PhysicsDatBundle BundleWithLight() + { + var setup = new Setup(); + setup.Lights[0] = new LightInfo + { + ViewSpaceLocation = new Frame { Orientation = Quaternion.Identity }, + Color = new ColorARGB + { + Red = 255, + Green = 255, + Blue = 255, + Alpha = 255, + }, + Intensity = 1f, + Falloff = 8f, + }; + return new PhysicsDatBundle( + new LandBlockInfo(), + new Dictionary(), + new Dictionary(), + new Dictionary { [SetupId] = setup }, + new Dictionary()); + } + + private static LandblockBuild Build( + uint landblockId, + IReadOnlyList entities, + PhysicsDatBundle? bundle = null) => new( + new LoadedLandblock( + landblockId, + new LandBlock + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }, + entities, + bundle ?? PhysicsDatBundle.Empty), + Origin: new LandblockBuildOrigin(0xA9, 0xB4)); + + private static WorldEntity Entity( + uint id, + Vector3 position, + uint serverGuid = 0u) => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = SetupId, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); + + private sealed record FixtureState( + LandblockRenderPublisher Render, + LandblockPhysicsPublisher Physics, + LandblockStaticPresentationPublisher Publisher, + LightManager Lights, + LightingHookSink Lighting, + TranslucencyFadeManager Translucency, + WorldGameState World, + WorldEvents Events) + { + public LandblockRenderPublication? LastRender { get; set; } + } + + private sealed class NullPoseSource : IEntityEffectPoseSource + { + public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) + { + rootWorld = default; + return false; + } + + public bool TryGetPartPose( + uint localEntityId, + int partIndex, + out Matrix4x4 partLocal) + { + partLocal = default; + return false; + } + } +}