From ea7ffbc1861fe6f59628b85e21404329d09216a6 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 22:07:36 +0200 Subject: [PATCH] refactor(streaming): own presentation retirement in pipeline Move full and near-tier presentation retirement out of GameWindow and into a concrete pipeline-owned stage mapper. Harden the retry ledger against reentrancy and committed visibility observer failures, enforce a single state/resource ownership graph, and split concrete versus legacy controller construction so callbacks cannot be silently ignored. Co-authored-by: Codex --- src/AcDream.App/Rendering/GameWindow.cs | 88 +----- .../LandblockPresentationPipeline.cs | 39 ++- .../LandblockPresentationRetirementOwner.cs | 119 ++++++++ .../Streaming/LandblockRenderPublisher.cs | 3 + .../LandblockRetirementCoordinator.cs | 157 ++++++++-- .../LandblockStaticPresentationPublisher.cs | 6 + .../Streaming/StreamingController.cs | 52 +++- ...dblockConcretePresentationPipelineTests.cs | 267 ++++++++++++++++-- .../LandblockPresentationPipelineTests.cs | 57 ++++ .../LandblockRetirementCoordinatorTests.cs | 212 +++++++++++++- 10 files changed, 860 insertions(+), 140 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c4f3c771..2ef8d4c9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -150,7 +150,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); - private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; private AcDream.App.Streaming.LandblockStaticPresentationPublisher? @@ -2208,11 +2207,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext wbSpawnAdapter, onLandblockUnloaded: _classificationCache.InvalidateLandblock, entityScriptActivator: entityScriptActivator); - _landblockRetirements = - new AcDream.App.Streaming.LandblockRetirementCoordinator( - _worldState, - AdvanceLandblockPresentationRetirement); - _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, new AcDream.App.World.CompositeLiveEntityResourceLifecycle( @@ -2529,17 +2523,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); _streamer.Start(); + var landblockRetirementOwner = + new AcDream.App.Streaming.LandblockPresentationRetirementOwner( + _landblockRenderPublisher!, + _landblockPhysicsPublisher!, + _landblockStaticPresentationPublisher!, + _lightingSink!, + _translucencyFades); var landblockPresentationPipeline = new AcDream.App.Streaming.LandblockPresentationPipeline( _landblockRenderPublisher!, _landblockPhysicsPublisher!, _landblockStaticPresentationPublisher!, _worldState, + landblockRetirementOwner, onLandblockLoaded: loadedLandblockId => _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), ensureEnvCellMeshes: - _landblockRenderPublisher!.PrepareAfterRenderPins, - retirementCoordinator: _landblockRetirements); + _landblockRenderPublisher!.PrepareAfterRenderPins); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( new AcDream.App.Streaming.LandblockBuildRequest( @@ -2551,18 +2552,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveCenterY))), enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, - applyTerrain: ApplyLoadedTerrain, state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, clearPendingLoads: _streamer.ClearPendingLoads, - // #138: restore retained server objects when a landblock reloads + // Retained-object recovery runs after each // (dungeon-exit expand or Far→Near promote). ACE won't re-send the - // objects it thinks we still know, so we re-project them ourselves. - onLandblockLoaded: loadedLandblockId => - _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), - ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, - retirementCoordinator: _landblockRetirements, + // objects it still considers known. presentationPipeline: landblockPresentationPipeline); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -3805,65 +3801,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - private void AdvanceLandblockPresentationRetirement( - AcDream.App.Streaming.LandblockRetirementTicket ticket) - { - bool staticOnly = - ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.NearLayer; - - ticket.RunForEachEntity( - AcDream.App.Streaming.LandblockRetirementStage.EntityLighting, - entity => !staticOnly || entity.ServerGuid == 0, - 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 => - { - 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 => _landblockStaticPresentationPublisher! - .RemovePluginProjection(entity)); - - if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) - { - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.Terrain, - () => _landblockRenderPublisher?.RemoveTerrain(ticket.LandblockId)); - } - - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.Physics, - () => - { - if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) - _landblockPhysicsPublisher!.RemoveLandblock(ticket.LandblockId); - else - _landblockPhysicsPublisher!.DemoteToTerrain(ticket.LandblockId); - }); - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.CellVisibility, - () => _landblockRenderPublisher?.RemoveCellVisibility(ticket.LandblockId)); - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry, - () => _landblockRenderPublisher?.RemoveBuildingRegistry(ticket.LandblockId)); - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells, - () => _landblockRenderPublisher?.RemoveEnvironmentCells(ticket.LandblockId)); - } - /// /// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick /// whenever a new landblock's terrain + entities are ready for GPU upload. @@ -5961,7 +5898,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext LiveEntities: _liveEntities?.Count ?? 0, MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0, PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0, - PendingLandblockRetirements: _landblockRetirements?.PendingCount ?? 0, + PendingLandblockRetirements: + _streamingController?.PendingRetirementCount ?? 0, ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0, Particles: _particleSystem?.ActiveParticleCount ?? 0, ParticleBindings: _particleSink?.ActiveBindingCount ?? 0, diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index bc7585e4..640ebb6a 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -79,6 +79,13 @@ public sealed class LandblockPresentationPipeline _state = state; _onLandblockLoaded = onLandblockLoaded; _ensureEnvCellMeshes = ensureEnvCellMeshes; + if (retirementCoordinator is not null + && !retirementCoordinator.MatchesState(_state)) + { + throw new ArgumentException( + "The retirement coordinator must own the pipeline's world state.", + nameof(retirementCoordinator)); + } _retirements = retirementCoordinator ?? LandblockRetirementCoordinator.CreateLegacy( state, @@ -96,9 +103,9 @@ public sealed class LandblockPresentationPipeline LandblockPhysicsPublisher physicsPublisher, LandblockStaticPresentationPublisher staticPublisher, GpuWorldState state, + LandblockPresentationRetirementOwner retirementOwner, Action? onLandblockLoaded = null, - Action? ensureEnvCellMeshes = null, - LandblockRetirementCoordinator? retirementCoordinator = null) + Action? ensureEnvCellMeshes = null) { _renderPublisher = renderPublisher ?? throw new ArgumentNullException(nameof(renderPublisher)); @@ -107,19 +114,35 @@ public sealed class LandblockPresentationPipeline _staticPublisher = staticPublisher ?? throw new ArgumentNullException(nameof(staticPublisher)); _state = state ?? throw new ArgumentNullException(nameof(state)); + if (!_renderPublisher.MatchesState(_state)) + { + throw new ArgumentException( + "The render publisher must own the pipeline's world state.", + nameof(state)); + } + ArgumentNullException.ThrowIfNull(retirementOwner); + if (!retirementOwner.Matches( + _renderPublisher, + _physicsPublisher, + _staticPublisher)) + { + throw new ArgumentException( + "The retirement owner must reference the pipeline's concrete publishers.", + nameof(retirementOwner)); + } _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)); + _retirements = new LandblockRetirementCoordinator( + state, + retirementOwner.Advance); } public int PendingRetirementCount => _retirements.PendingCount; public int PendingPublicationCount => _publications.Count; + internal bool MatchesState(GpuWorldState state) => + ReferenceEquals(_state, state); + public bool IsRetirementPending(uint landblockId) => _retirements.IsPending(landblockId); diff --git a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs new file mode 100644 index 00000000..9c409891 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs @@ -0,0 +1,119 @@ +using AcDream.Core.Lighting; +using AcDream.Core.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Composes the presentation-owner half of one detach-first landblock +/// retirement. remains the exact +/// retry ledger; this class only maps each stage to its concrete owner. +/// +/// +/// Retail separates CLandBlock::release_all (0x0052FCF0), +/// CLandBlock::destroy_static_objects (0x0052FA50), and +/// CLandBlock::Destroy (0x0052FAA0). Acdream's asynchronous equivalent +/// detaches spatial reachability first, then advances these concrete owners +/// from the retained ticket without replaying successful stages or entities. +/// +public sealed class LandblockPresentationRetirementOwner +{ + private readonly LandblockRenderPublisher _render; + private readonly LandblockPhysicsPublisher _physics; + private readonly LandblockStaticPresentationPublisher _staticPresentation; + private readonly LightingHookSink _lighting; + private readonly TranslucencyFadeManager _translucency; + + public LandblockPresentationRetirementOwner( + LandblockRenderPublisher render, + LandblockPhysicsPublisher physics, + LandblockStaticPresentationPublisher staticPresentation, + LightingHookSink lighting, + TranslucencyFadeManager translucency) + { + _render = render ?? throw new ArgumentNullException(nameof(render)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _staticPresentation = staticPresentation + ?? throw new ArgumentNullException(nameof(staticPresentation)); + _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); + _translucency = translucency + ?? throw new ArgumentNullException(nameof(translucency)); + if (!_staticPresentation.MatchesResources(_lighting, _translucency)) + { + throw new ArgumentException( + "The retirement resources must be those owned by the static " + + "presentation publisher.", + nameof(staticPresentation)); + } + } + + internal bool Matches( + LandblockRenderPublisher render, + LandblockPhysicsPublisher physics, + LandblockStaticPresentationPublisher staticPresentation) => + ReferenceEquals(_render, render) + && ReferenceEquals(_physics, physics) + && ReferenceEquals(_staticPresentation, staticPresentation) + && _staticPresentation.MatchesResources(_lighting, _translucency); + + public void Advance(LandblockRetirementTicket ticket) + { + ArgumentNullException.ThrowIfNull(ticket); + bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer; + + ticket.RunForEachEntity( + LandblockRetirementStage.EntityLighting, + entity => !staticOnly || entity.ServerGuid == 0, + RemoveLighting); + ticket.RunForEachEntity( + LandblockRetirementStage.EntityTranslucency, + entity => !staticOnly || entity.ServerGuid == 0, + RemoveTranslucency); + ticket.RunForEachEntity( + LandblockRetirementStage.PluginProjection, + static entity => entity.ServerGuid == 0, + _staticPresentation.RemovePluginProjection); + + if (ticket.Kind == LandblockRetirementKind.Full) + { + ticket.RunOnce( + LandblockRetirementStage.Terrain, + () => _render.RemoveTerrain(ticket.LandblockId)); + } + + ticket.RunOnce( + LandblockRetirementStage.Physics, + () => + { + if (ticket.Kind == LandblockRetirementKind.Full) + _physics.RemoveLandblock(ticket.LandblockId); + else + _physics.DemoteToTerrain(ticket.LandblockId); + }); + ticket.RunOnce( + LandblockRetirementStage.CellVisibility, + () => _render.RemoveCellVisibility(ticket.LandblockId)); + ticket.RunOnce( + LandblockRetirementStage.BuildingRegistry, + () => _render.RemoveBuildingRegistry(ticket.LandblockId)); + ticket.RunOnce( + LandblockRetirementStage.EnvironmentCells, + () => _render.RemoveEnvironmentCells(ticket.LandblockId)); + } + + private void RemoveLighting(WorldEntity entity) + { + if (entity.ServerGuid == 0) + _staticPresentation.RemoveLighting(entity); + else + _lighting.UnregisterOwner(entity.Id); + } + + private void RemoveTranslucency(WorldEntity entity) + { + if (entity.ServerGuid == 0) + _staticPresentation.RemoveTranslucency(entity); + else + _translucency.ClearEntity(entity.Id); + } +} diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs index 2e240ba3..3b768918 100644 --- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -145,6 +145,9 @@ public sealed class LandblockRenderPublisher _buildingRegistryRemovalCount, _envCellRemovalCount); + internal bool MatchesState(GpuWorldState state) => + ReferenceEquals(_worldState, state); + /// /// Publishes the prefix that retail establishes before physics/static /// activation: terrain, complete visibility cells, and spatial bounds. diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs index c1f98912..03fea9d6 100644 --- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -136,14 +136,23 @@ public sealed class LandblockRetirementTicket } /// -/// Game-window-lifetime owner for landblock retirement. World-state detachment +/// Pipeline-lifetime owner for landblock retirement. World-state detachment /// commits first; renderer/cache/script cleanup advances afterward from a -/// per-owner ledger. The coordinator is intentionally shared by replacement -/// instances so a quality-setting rebuild -/// cannot abandon an unfinished retirement. +/// per-owner ledger. Reentrant begin/advance requests are serialized so an +/// owner callback cannot recursively replay its active stage or mutate the +/// pending-ticket map while it is being enumerated. /// public sealed class LandblockRetirementCoordinator { + private enum RequestKind : byte + { + BeginFull, + BeginNearLayer, + Advance, + } + + private readonly record struct Request(RequestKind Kind, uint LandblockId); + private const LandblockRetirementStage CoreStages = LandblockRetirementStage.MeshReferences | LandblockRetirementStage.StaticScripts @@ -165,6 +174,9 @@ public sealed class LandblockRetirementCoordinator _requiredPresentationStages; private readonly Dictionary> _pending = new(); private readonly List _completedIds = new(); + private readonly Queue _requests = new(); + private readonly HashSet _seenDuringDrain = new(); + private bool _drainingRequests; public LandblockRetirementCoordinator( GpuWorldState state, @@ -186,13 +198,19 @@ public sealed class LandblockRetirementCoordinator public bool IsPending(uint landblockId) => _pending.ContainsKey(Canonicalize(landblockId)); - public void BeginFull(uint landblockId) => - Begin(landblockId, LandblockRetirementKind.Full); + internal bool MatchesState(GpuWorldState state) => + ReferenceEquals(_state, state); - public void BeginNearLayer(uint landblockId) => - Begin(landblockId, LandblockRetirementKind.NearLayer); + public void BeginFull(uint landblockId) => EnqueueRequest( + new Request(RequestKind.BeginFull, Canonicalize(landblockId))); - public void Advance() + public void BeginNearLayer(uint landblockId) => EnqueueRequest( + new Request(RequestKind.BeginNearLayer, Canonicalize(landblockId))); + + public void Advance() => EnqueueRequest( + new Request(RequestKind.Advance, 0u)); + + private void AdvanceCore() { if (_pending.Count == 0) return; @@ -250,7 +268,7 @@ public sealed class LandblockRetirementCoordinator return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required); } - private void Begin(uint landblockId, LandblockRetirementKind kind) + private void BeginCore(uint landblockId, LandblockRetirementKind kind) { uint canonical = Canonicalize(landblockId); if (_pending.TryGetValue(canonical, out List? existing)) @@ -259,20 +277,52 @@ public sealed class LandblockRetirementCoordinator { if (existing[i].Kind == kind) { - AdvanceTicket(existing[i]); + LandblockRetirementTicket existingTicket = existing[i]; + AdvanceTicket(existingTicket); + if (existingTicket.IsComplete) + { + existing.RemoveAt(i); + PendingCount--; + if (existing.Count == 0) + _pending.Remove(canonical); + } return; } } } - GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full - ? _state.DetachLandblock(canonical) - : _state.DetachNearLayer(canonical); - if (stateRetirement is null) - return; - LandblockRetirementStage required = CoreStages | _requiredPresentationStages(kind); + GpuLandblockRetirement? stateRetirement = null; + Exception? detachmentObserverFailure = null; + GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch(); + try + { + // The detach methods create a nested batch. Holding this outer + // transaction lets us retain their returned teardown context + // before visibility observers run at the outer commit boundary. + stateRetirement = kind == LandblockRetirementKind.Full + ? _state.DetachLandblock(canonical) + : _state.DetachNearLayer(canonical); + } + finally + { + try + { + mutation.Dispose(); + } + catch (Exception error) + { + detachmentObserverFailure = error; + } + } + if (stateRetirement is null) + { + if (detachmentObserverFailure is not null) + throw detachmentObserverFailure; + return; + } + var ticket = new LandblockRetirementTicket(stateRetirement, required); if (existing is null) { @@ -290,6 +340,13 @@ public sealed class LandblockRetirementCoordinator if (existing.Count == 0) _pending.Remove(canonical); } + // Visibility is a delivered-by-attempt broadcast: every subscriber is + // invoked and failures are aggregated, but failed subscribers are not + // retained as replayable work. Complete the presentation teardown + // from the preserved detach receipt, then surface that committed-edge + // failure to the caller without inventing a one-frame pending fence. + if (detachmentObserverFailure is not null) + throw detachmentObserverFailure; } private void AdvanceTicket(LandblockRetirementTicket ticket) @@ -325,4 +382,70 @@ public sealed class LandblockRetirementCoordinator private static uint Canonicalize(uint id) => (id & 0xFFFF0000u) | 0xFFFFu; + + private void EnqueueRequest(Request request) + { + if (_drainingRequests) + { + if (_seenDuringDrain.Add(request)) + _requests.Enqueue(request); + return; + } + + _drainingRequests = true; + _seenDuringDrain.Clear(); + _requests.Clear(); + _seenDuringDrain.Add(request); + _requests.Enqueue(request); + List? failures = null; + try + { + while (_requests.TryDequeue(out Request pending)) + { + try + { + switch (pending.Kind) + { + case RequestKind.BeginFull: + BeginCore( + pending.LandblockId, + LandblockRetirementKind.Full); + break; + case RequestKind.BeginNearLayer: + BeginCore( + pending.LandblockId, + LandblockRetirementKind.NearLayer); + break; + case RequestKind.Advance: + AdvanceCore(); + break; + default: + throw new InvalidOperationException( + $"Unknown retirement request {pending.Kind}."); + } + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + } + finally + { + _requests.Clear(); + _seenDuringDrain.Clear(); + _drainingRequests = false; + } + + if (failures is { Count: 1 }) + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(failures[0]) + .Throw(); + if (failures is { Count: > 1 }) + { + throw new AggregateException( + "One or more serialized landblock retirement requests failed.", + failures); + } + } } diff --git a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs index 345369ab..5d4081bb 100644 --- a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs @@ -108,6 +108,12 @@ public sealed class LandblockStaticPresentationPublisher _activeByLandblock.Count, _landblockByEntityId.Count); + internal bool MatchesResources( + LightingHookSink lighting, + TranslucencyFadeManager translucency) => + ReferenceEquals(_lighting, lighting) + && ReferenceEquals(_translucency, translucency); + public LandblockStaticPresentationPublication PreparePublication( LandblockPhysicsPublication physicsPublication) { diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 7edfbf09..0d76a543 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -115,6 +115,7 @@ public sealed class StreamingController // deferred-LOAD backlog. A non-zero value during/after a teleport is the // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. public int DeferredApplyBacklog => _deferredApply.Count; + public int PendingRetirementCount => _presentation.PendingRetirementCount; /// /// True once every in-bounds landblock in the requested Chebyshev ring has @@ -174,8 +175,40 @@ public sealed class StreamingController Action? clearPendingLoads = null, Action? onLandblockLoaded = null, Action? ensureEnvCellMeshes = null, - LandblockRetirementCoordinator? retirementCoordinator = null, - LandblockPresentationPipeline? presentationPipeline = null) + LandblockRetirementCoordinator? retirementCoordinator = null) + : this( + enqueueLoad, + enqueueUnload, + drainCompletions, + state, + nearRadius, + farRadius, + new LandblockPresentationPipeline( + applyTerrain, + state, + onLandblockLoaded, + ensureEnvCellMeshes, + retirementCoordinator, + removeTerrain, + demoteNearLayer), + clearPendingLoads) + { + } + + /// + /// Concrete presentation path. Its pipeline is the sole presentation and + /// retirement owner, so no legacy publication delegate can be supplied or + /// silently ignored. + /// + public StreamingController( + Action enqueueLoad, + Action enqueueUnload, + Func> drainCompletions, + GpuWorldState state, + int nearRadius, + int farRadius, + LandblockPresentationPipeline presentationPipeline, + Action? clearPendingLoads = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; @@ -183,14 +216,13 @@ public sealed class StreamingController _clearPendingLoads = clearPendingLoads; _state = state; _presentation = presentationPipeline - ?? new LandblockPresentationPipeline( - applyTerrain, - state, - onLandblockLoaded, - ensureEnvCellMeshes, - retirementCoordinator, - removeTerrain, - demoteNearLayer); + ?? throw new ArgumentNullException(nameof(presentationPipeline)); + if (!_presentation.MatchesState(_state)) + { + throw new ArgumentException( + "The presentation pipeline must own the controller's world state.", + nameof(presentationPipeline)); + } NearRadius = nearRadius; FarRadius = farRadius; } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index e4d84e69..fa7f87a2 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -40,8 +40,9 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, + fixture.RetirementOwner, onLandblockLoaded: _ => calls.Add("live-recovery"), - retirementCoordinator: fixture.Retirements); + ensureEnvCellMeshes: null); LandblockBuild build = Build(Entity(0x80A9B401u)); pipeline.PublishLoaded(Result(build)); @@ -77,7 +78,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); LandblockBuild build = Build(Entity(0x80A9B401u)); LandblockStreamResult.Loaded result = Result(build); @@ -113,7 +114,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity first = Entity(0x80A9B401u); LandblockBuild build = Build(first, Entity(first.Id)); LandblockStreamResult.Loaded result = Result(build); @@ -140,7 +141,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity first = Entity(0x80A9B401u); pipeline.PublishLoaded(Result(Build(first))); long renderBegins = fixture.Render.Diagnostics.BeginCount; @@ -161,7 +162,7 @@ public sealed class LandblockConcretePresentationPipelineTests } [Fact] - public void ConcreteConstructor_RequiresSharedRetirementCoordinator() + public void ConcreteConstructor_RequiresMatchingRetirementOwner() { ConcreteFixture fixture = Fixture(new List()); @@ -171,7 +172,51 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: null)); + retirementOwner: null!)); + + var foreignRender = new LandblockRenderPublisher( + static (_, _, _) => { }, + static _ => { }, + new CellVisibility(), + fixture.State); + var foreignOwner = new LandblockPresentationRetirementOwner( + foreignRender, + fixture.Physics, + fixture.Static, + fixture.Lighting, + fixture.Translucency); + Assert.Throws(() => + new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + foreignOwner)); + + Assert.Throws(() => + new LandblockPresentationRetirementOwner( + fixture.Render, + fixture.Physics, + fixture.Static, + new LightingHookSink( + new LightManager(), + new NullPoseSource()), + fixture.Translucency)); + Assert.Throws(() => + new LandblockPresentationRetirementOwner( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.Lighting, + new TranslucencyFadeManager())); + + Assert.Throws(() => + new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + new GpuWorldState(), + fixture.RetirementOwner)); } [Fact] @@ -183,7 +228,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity first = Entity(0x80A9B401u); WorldEntity replacement = Entity(first.Id); @@ -205,7 +250,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity entity = Entity(0x80A9B401u); pipeline.PublishLoaded(Result(Build(entity))); Assert.Equal(1, fixture.Runner.ActiveOwnerCount); @@ -217,9 +262,158 @@ public sealed class LandblockConcretePresentationPipelineTests Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _)); } + [Fact] + public void FullRetirement_DetachesFirstAndBalancesEveryConcreteOwner() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = Pipeline(fixture); + WorldEntity staticEntity = Entity(0x80A9B401u); + WorldEntity liveEntity = Entity( + 7u, + serverGuid: 0x80000007u); + pipeline.PublishLoaded(Result(Build(staticEntity, liveEntity))); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = staticEntity.Id, + Kind = LightKind.Point, + }); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = liveEntity.Id, + Kind = LightKind.Point, + }); + fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f); + fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f); + calls.Clear(); + + pipeline.BeginFullRetirement(LandblockId); + + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(0, pipeline.PendingRetirementCount); + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _)); + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.False(fixture.Translucency.TryGetCurrentValue( + staticEntity.Id, + 0u, + out _)); + Assert.False(fixture.Translucency.TryGetCurrentValue( + liveEntity.Id, + 0u, + out _)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(0, fixture.Physics.Diagnostics.DemotionCount); + Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.CellVisibilityRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.BuildingRegistryRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + Assert.Contains("unpin", calls); + Assert.Contains("terrain-remove", calls); + Assert.Contains("envcell-remove", calls); + } + + [Fact] + public void NearRetirement_KeepsTerrainAndLivePresentationButRetiresNearOwners() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = Pipeline(fixture); + WorldEntity staticEntity = Entity(0x80A9B401u); + WorldEntity liveEntity = Entity( + 7u, + serverGuid: 0x80000007u); + pipeline.PublishLoaded(Result(Build(staticEntity, liveEntity))); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = staticEntity.Id, + Kind = LightKind.Point, + }); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = liveEntity.Id, + Kind = LightKind.Point, + }); + fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f); + fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f); + calls.Clear(); + + pipeline.BeginNearLayerRetirement(LandblockId); + + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.False(fixture.State.IsNearTier(LandblockId)); + Assert.Single(fixture.State.Entities); + Assert.Same(liveEntity, fixture.State.Entities[0]); + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _)); + Assert.Single(fixture.Lighting.GetOwnedLights(liveEntity.Id)!); + Assert.Null(fixture.Lighting.GetOwnedLights(staticEntity.Id)); + Assert.False(fixture.Translucency.TryGetCurrentValue( + staticEntity.Id, + 0u, + out _)); + Assert.True(fixture.Translucency.TryGetCurrentValue( + liveEntity.Id, + 0u, + out _)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(0, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(1, fixture.Physics.Diagnostics.DemotionCount); + Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.CellVisibilityRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.BuildingRegistryRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + Assert.DoesNotContain("terrain-remove", calls); + } + + [Fact] + public void FullRetirementFailure_RetriesOnlyUnfinishedConcreteStage() + { + int terrainAttempts = 0; + var calls = new List(); + ConcreteFixture fixture = Fixture( + calls, + removeTerrain: _ => + { + terrainAttempts++; + if (terrainAttempts == 1) + throw new InvalidOperationException("injected terrain removal failure"); + }); + var pipeline = Pipeline(fixture); + pipeline.PublishLoaded(Result(Build(Entity(0x80A9B401u)))); + + pipeline.BeginFullRetirement(LandblockId); + + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(1, pipeline.PendingRetirementCount); + Assert.Equal(1, terrainAttempts); + Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + + pipeline.AdvanceRetirements(); + + Assert.Equal(0, pipeline.PendingRetirementCount); + Assert.Equal(2, terrainAttempts); + Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + } + + private static LandblockPresentationPipeline Pipeline( + ConcreteFixture fixture) => new( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + fixture.RetirementOwner); + private static ConcreteFixture Fixture( List calls, - Action? commitEnvCells = null) + Action? commitEnvCells = null, + Action? removeTerrain = null, + Action? removeEnvCells = null) { var poses = new EntityEffectPoseRegistry(); var particleSink = new ParticleHookSink( @@ -246,33 +440,44 @@ public sealed class LandblockConcretePresentationPipelineTests var engine = new PhysicsEngine { DataCache = cache }; var render = new LandblockRenderPublisher( (_, _, _) => calls.Add("terrain"), - static _ => { }, + removeTerrain ?? (_ => calls.Add("terrain-remove")), new CellVisibility(), state, - commitEnvCells: commitEnvCells); + commitEnvCells: commitEnvCells, + removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove"))); var physics = new LandblockPhysicsPublisher(engine, new float[256]); + var lights = new LightManager(); var lighting = new LightingHookSink( - new LightManager(), + lights, new NullPoseSource()); + var translucency = new TranslucencyFadeManager(); + var world = new WorldGameState(); var events = new WorldEvents(); - var retirements = new LandblockRetirementCoordinator( - state, - static _ => { }, - static _ => LandblockRetirementStage.None); + var staticPresentation = new LandblockStaticPresentationPublisher( + lighting, + translucency, + world, + events); + var retirementOwner = new LandblockPresentationRetirementOwner( + render, + physics, + staticPresentation, + lighting, + translucency); return new ConcreteFixture( state, engine, render, physics, - new LandblockStaticPresentationPublisher( - lighting, - new TranslucencyFadeManager(), - new WorldGameState(), - events), + staticPresentation, events, - retirements, + retirementOwner, runner, - poses); + poses, + lights, + lighting, + translucency, + world); } private static LandblockStreamResult.Loaded Result(LandblockBuild build) => @@ -316,9 +521,11 @@ public sealed class LandblockConcretePresentationPipelineTests private static WorldEntity Entity( uint id, - uint sourceId = 0x01000001u) => new() + uint sourceId = 0x01000001u, + uint serverGuid = 0u) => new() { Id = id, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = sourceId, Position = Vector3.Zero, Rotation = Quaternion.Identity, @@ -336,9 +543,13 @@ public sealed class LandblockConcretePresentationPipelineTests LandblockPhysicsPublisher Physics, LandblockStaticPresentationPublisher Static, WorldEvents Events, - LandblockRetirementCoordinator Retirements, + LandblockPresentationRetirementOwner RetirementOwner, PhysicsScriptRunner Runner, - EntityEffectPoseRegistry Poses); + EntityEffectPoseRegistry Poses, + LightManager Lights, + LightingHookSink Lighting, + TranslucencyFadeManager Translucency, + WorldGameState World); private sealed class NullHookSink : IAnimationHookSink { @@ -355,9 +566,7 @@ public sealed class LandblockConcretePresentationPipelineTests public void PinPreparedRenderData(ulong id) => calls.Add("pin"); - public void DecrementRefCount(ulong id) - { - } + public void DecrementRefCount(ulong id) => calls.Add("unpin"); } private sealed class NullPoseSource : IEntityEffectPoseSource diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index 0357c1b2..df731675 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -12,6 +12,63 @@ namespace AcDream.App.Tests.Streaming; public sealed class LandblockPresentationPipelineTests { + [Fact] + public void ControllerRejectsPresentationPipelineFromForeignWorldState() + { + var controllerState = new GpuWorldState(); + var foreignState = new GpuWorldState(); + var foreignPipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + foreignState); + + Assert.Throws(() => new StreamingController( + enqueueLoad: static (_, _, _) => { }, + enqueueUnload: static (_, _) => { }, + drainCompletions: static _ => Array.Empty(), + controllerState, + nearRadius: 0, + farRadius: 0, + presentationPipeline: foreignPipeline)); + } + + [Fact] + public void LegacyPipelineRejectsRetirementCoordinatorFromForeignWorldState() + { + var pipelineState = new GpuWorldState(); + var foreignCoordinator = new LandblockRetirementCoordinator( + new GpuWorldState(), + static _ => { }); + + Assert.Throws(() => + new LandblockPresentationPipeline( + static (_, _) => { }, + pipelineState, + retirementCoordinator: foreignCoordinator)); + } + + [Fact] + public void ControllerConcreteConstructorHasNoLegacyPresentationParameters() + { + string[] legacyNames = + [ + "applyTerrain", + "removeTerrain", + "demoteNearLayer", + "onLandblockLoaded", + "ensureEnvCellMeshes", + "retirementCoordinator", + ]; + + System.Reflection.ConstructorInfo concrete = Assert.Single( + typeof(StreamingController).GetConstructors(), + constructor => constructor.GetParameters().Any( + parameter => parameter.Name == "presentationPipeline")); + string[] names = concrete.GetParameters() + .Select(parameter => parameter.Name!) + .ToArray(); + Assert.DoesNotContain(names, legacyNames.Contains); + } + [Fact] public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index a1325e27..65c1a335 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -137,6 +137,203 @@ public sealed class LandblockRetirementCoordinatorTests Assert.False(coordinator.IsPending(landblockId)); } + [Fact] + public void ReentrantSameIdBegin_DoesNotReplaySuccessfulStage() + { + const uint landblockId = 0x4040FFFFu; + var state = StateWith(landblockId, Entity(1)); + int lightingCalls = 0; + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + lighting: _ => + { + lightingCalls++; + coordinator!.BeginFull(landblockId); + })); + + coordinator.BeginFull(landblockId); + + Assert.Equal(1, lightingCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void ReentrantAdvance_RetriesFailedStageWithoutRecursion() + { + const uint landblockId = 0x4141FFFFu; + var state = StateWith(landblockId, Entity(1)); + int lightingCalls = 0; + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + lighting: _ => + { + lightingCalls++; + coordinator!.Advance(); + if (lightingCalls == 1) + throw new InvalidOperationException("injected first failure"); + })); + + coordinator.BeginFull(landblockId); + + Assert.Equal(2, lightingCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void ReentrantDifferentIdBegin_IsDeferredUntilActiveAdvanceCompletes() + { + const uint firstId = 0x4242FFFFu; + const uint secondId = 0x4343FFFFu; + var state = StateWith(firstId); + state.AddLandblock(new LoadedLandblock( + secondId, + new LandBlock(), + Array.Empty())); + int firstAttempts = 0; + int secondAttempts = 0; + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => + { + if (ticket.LandblockId == firstId) + { + firstAttempts++; + if (firstAttempts == 1) + throw new InvalidOperationException("leave first pending"); + coordinator!.BeginFull(secondId); + } + else + { + secondAttempts++; + } + })); + coordinator.BeginFull(firstId); + Assert.Equal(1, coordinator.PendingCount); + + coordinator.Advance(); + + Assert.Equal(2, firstAttempts); + Assert.Equal(1, secondAttempts); + Assert.False(state.IsLoaded(secondId)); + Assert.Equal(0, coordinator.PendingCount); + } + + [Fact] + public void RepeatedBegin_RemovesTicketWhenRetryCompletes() + { + const uint landblockId = 0x4444FFFFu; + var state = StateWith(landblockId); + int terrainAttempts = 0; + int requiredStageCalls = 0; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => + { + terrainAttempts++; + if (terrainAttempts == 1) + throw new InvalidOperationException("injected first failure"); + }), + _ => + { + requiredStageCalls++; + return LandblockRetirementCoordinator.ProductionPresentationStages; + }); + + coordinator.BeginFull(landblockId); + Assert.Equal(1, coordinator.PendingCount); + + coordinator.BeginFull(landblockId); + + Assert.Equal(2, terrainAttempts); + Assert.Equal(1, requiredStageCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void VisibilityObserverFailure_CompletesReceiptThenRethrowsCommittedEdge() + { + const uint landblockId = 0x4545FFFFu; + WorldEntity live = Entity(1, serverGuid: 0x70000001u); + var state = StateWith(landblockId, live); + state.LiveProjectionVisibilityChanged += (_, _) => + throw new InvalidOperationException("injected visibility failure"); + int terrainCalls = 0; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => terrainCalls++)); + + Assert.Throws(() => + coordinator.BeginFull(landblockId)); + + Assert.False(state.IsLoaded(landblockId)); + Assert.Equal(1, terrainCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void RequiredStageFailure_HappensBeforeSpatialDetachment() + { + const uint landblockId = 0x4646FFFFu; + var state = StateWith(landblockId); + var coordinator = new LandblockRetirementCoordinator( + state, + static _ => { }, + static _ => throw new InvalidOperationException( + "injected required-stage failure")); + + Assert.Throws(() => + coordinator.BeginFull(landblockId)); + + Assert.True(state.IsLoaded(landblockId)); + Assert.Equal(0, coordinator.PendingCount); + } + + [Fact] + public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow() + { + const uint firstId = 0x4747FFFFu; + const uint secondId = 0x4848FFFFu; + WorldEntity live = Entity(1, serverGuid: 0x70000002u); + var state = StateWith(firstId, live); + state.AddLandblock(new LoadedLandblock( + secondId, + new LandBlock(), + Array.Empty())); + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation(ticket)); + state.LiveProjectionVisibilityChanged += (_, _) => + { + coordinator.BeginFull(secondId); + throw new InvalidOperationException("injected visibility failure"); + }; + + Assert.Throws(() => + coordinator.BeginFull(firstId)); + + Assert.False(state.IsLoaded(firstId)); + Assert.False(state.IsLoaded(secondId)); + Assert.Equal(0, coordinator.PendingCount); + } + private static StreamingController Controller( GpuWorldState state, LandblockRetirementCoordinator coordinator, @@ -187,15 +384,28 @@ public sealed class LandblockRetirementCoordinatorTests ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { }); } - private static WorldEntity Entity(uint id) => new() + private static WorldEntity Entity(uint id, uint serverGuid = 0u) => new() { Id = id, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x01000000u + id, Position = Vector3.Zero, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; + private static GpuWorldState StateWith( + uint landblockId, + params WorldEntity[] entities) + { + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + entities)); + return state; + } + private static LandblockMeshData EmptyMesh() => new( Array.Empty(), Array.Empty());