diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e522bc7..50b6db03 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4608,7 +4608,26 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; if (_frameDiag) _applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - ApplyLoadedTerrainLocked(build, meshData); + try + { + ApplyLoadedTerrainLocked(build, meshData); + } + catch (AcDream.App.Streaming.StreamingMutationException) + { + throw; + } + catch (Exception error) + { + // This legacy body spans multiple concrete owners and cannot yet + // identify an exact failed suffix. Conservatively report that a + // prefix may have committed so the transaction coordinator never + // replays terrain/cells/collision/lights/plugin events. Slice-5 + // checkpoints C-E replace this wrapper with per-owner receipts. + throw new AcDream.App.Streaming.StreamingMutationException( + $"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.", + mutationCommitted: true, + error); + } if (_frameDiag) { _applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 23a05e2f..354e8392 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -8,6 +8,18 @@ using AcDream.Core.World; namespace AcDream.App.Streaming; +/// +/// Exact result of one committed landblock bucket mutation. Fallible mesh-pin +/// and static-script reconciliation consume this receipt separately, so a +/// callback failure cannot force the spatial mutation to replay. +/// +internal sealed record GpuLandblockSpatialPublication( + uint LandblockId, + LoadedLandblock Landblock, + IReadOnlyList AdditionalRenderIds, + IReadOnlyList StaticEntities, + bool RequiresActivation = true); + /// /// Render-thread-owned registry of currently-loaded landblocks and their /// entities. All mutation happens in @@ -619,12 +631,44 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery LandblockStreamTier tier = LandblockStreamTier.Near) { using MutationBatch mutation = BeginMutationBatch(); + GpuLandblockSpatialPublication publication = + CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); + ActivateLandblockPresentation(publication); + } + + /// + /// Commits only canonical buckets, indexes, pending-live merge, tier, and + /// readiness-id ownership. The caller holds one outer + /// through the subsequent + /// call so live visibility + /// observers retain the shipped commit boundary. + /// + internal GpuLandblockSpatialPublication CommitLandblockSpatial( + LoadedLandblock landblock, + IEnumerable? additionalRenderIds, + LandblockStreamTier tier) => + CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); + + private GpuLandblockSpatialPublication CommitLandblockSpatialCore( + LoadedLandblock landblock, + IEnumerable? additionalRenderIds, + LandblockStreamTier tier) + { + ArgumentNullException.ThrowIfNull(landblock); // A stale Far completion must never replace a newer Near entity layer. // StreamingController normally filters it before render-side apply; // keep the state boundary independently monotonic as well. if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId)) - return; + { + LoadedLandblock retained = _loaded[landblock.LandblockId]; + return new GpuLandblockSpatialPublication( + retained.LandblockId, + retained, + Array.Empty(), + Array.Empty(), + RequiresActivation: false); + } // If pending live entities have been waiting for this landblock, // merge them into the render-thread-owned mutable entity bucket before @@ -668,26 +712,53 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _loaded[landblock.LandblockId] = landblock; AddLoadedLandblockToFlatView(landblock); _tierByLandblock[landblock.LandblockId] = tier; - if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded( - _loaded[landblock.LandblockId], - mergedRenderIds); + return CreateSpatialPublication( + _loaded[landblock.LandblockId], + (IEnumerable?)mergedRenderIds ?? Array.Empty()); + } - // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). - // LiveEntityRuntime owns activation for live objects. This static-only - // filter avoids replaying their defaults when a pending projection is - // drained into a newly loaded landblock. - if (_entityScriptActivator is not null) + internal void ActivateLandblockPresentation( + GpuLandblockSpatialPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!publication.RequiresActivation) + return; + if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current) + || !ReferenceEquals(current, publication.Landblock)) { - var loadedEntities = _loaded[landblock.LandblockId].Entities; - for (int i = 0; i < loadedEntities.Count; i++) - { - var e = loadedEntities[i]; - if (e.ServerGuid == 0) - _entityScriptActivator.OnCreate(e); - } + throw new InvalidOperationException( + $"Landblock 0x{publication.LandblockId:X8} changed before presentation activation."); } + _wbSpawnAdapter?.OnLandblockLoaded( + publication.Landblock, + publication.AdditionalRenderIds); + + // C.1.5b: fire DefaultScript for dat-hydrated entities only. + // EntityScriptActivator owns a retryable per-static acquisition ledger, + // so replaying this activation suffix after a later owner failure does + // not replay already-started defaults. + if (_entityScriptActivator is not null) + { + for (int i = 0; i < publication.StaticEntities.Count; i++) + _entityScriptActivator.OnCreate(publication.StaticEntities[i]); + } + } + + private static GpuLandblockSpatialPublication CreateSpatialPublication( + LoadedLandblock landblock, + IEnumerable additionalRenderIds) + { + ulong[] renderIds = additionalRenderIds as ulong[] + ?? additionalRenderIds.ToArray(); + WorldEntity[] staticEntities = landblock.Entities + .Where(static entity => entity.ServerGuid == 0) + .ToArray(); + return new GpuLandblockSpatialPublication( + landblock.LandblockId, + landblock, + renderIds, + staticEntities); } /// @@ -1228,11 +1299,50 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery IEnumerable? additionalRenderIds = null) { using MutationBatch mutation = BeginMutationBatch(); + GpuLandblockSpatialPublication? publication = + CommitEntitiesToExistingLandblockSpatialCore( + landblockId, + entities, + additionalRenderIds, + parkIfMissing: true); + if (publication is null) + return false; + + ActivateLandblockPresentation(publication); + return true; + } + + internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial( + uint landblockId, + IReadOnlyList entities, + IEnumerable? additionalRenderIds) + { + GpuLandblockSpatialPublication? publication = + CommitEntitiesToExistingLandblockSpatialCore( + landblockId, + entities, + additionalRenderIds, + parkIfMissing: false); + return publication + ?? throw new InvalidOperationException( + $"Landblock 0x{landblockId:X8} is not resident for an accepted promotion."); + } + + private GpuLandblockSpatialPublication? CommitEntitiesToExistingLandblockSpatialCore( + uint landblockId, + IReadOnlyList entities, + IEnumerable? additionalRenderIds, + bool parkIfMissing) + { + ArgumentNullException.ThrowIfNull(entities); // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (!_loaded.TryGetValue(canonical, out var lb)) { + if (!parkIfMissing) + return null; + // Park as pending — same pattern as live projections for not-yet-loaded LBs. if (!_pendingByLandblock.TryGetValue(canonical, out var bucket)) { @@ -1263,7 +1373,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery } renderIds.UnionWith(additionalRenderIds); } - return false; + return null; } List resident = MutableEntities(lb); for (int i = 0; i < entities.Count; i++) @@ -1278,20 +1388,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery } _animatedIndexByLandblock.Remove(canonical); _tierByLandblock[canonical] = LandblockStreamTier.Near; - if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds); - - // C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. - // All entities arriving via this path are atlas-tier by construction - // (the promotion path streams in dat-static scenery + EnvCell statics - // + stabs per the method's class doc-comment). - if (_entityScriptActivator is not null) - { - for (int i = 0; i < entities.Count; i++) - _entityScriptActivator.OnCreate(entities[i]); - } - - return true; + ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty(); + WorldEntity[] staticEntities = entities + .Where(static entity => entity.ServerGuid == 0) + .ToArray(); + return new GpuLandblockSpatialPublication( + canonical, + _loaded[canonical], + effectiveRenderIds, + staticEntities); } private void DrainVisibilityTransitions() diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs new file mode 100644 index 00000000..24fb3888 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -0,0 +1,299 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Render-thread transaction coordinator for one accepted landblock streaming +/// result. Streaming policy (desired tier, generation, priority, and apply +/// budget) remains in ; spatial buckets remain +/// in . This owner fixes the presentation ordering +/// independently of those policies so the concrete render, physics, and static +/// publishers can be extracted behind it without returning callbacks to the +/// game window. +/// +/// +/// Retail establishes cells/buildings before static objects in +/// LScape::grab_visible_cells @ 0x00504EC0. Its release path separates +/// CLandBlock::release_all @ 0x0052FCF0 (objects and visible cells) from +/// later destruction in CLandBlock::Destroy @ 0x0052FAA0 (static objects +/// and buildings). Acdream's detach-first retry ledger is the existing +/// asynchronous lifetime adaptation. The injected publication operation +/// currently contains the already-shipped production transaction; later +/// Slice-5 checkpoints replace it with focused publishers. +/// +public sealed class LandblockPresentationPipeline +{ + private enum PublicationKind : byte + { + Loaded, + PromoteExisting, + PromoteSelfContained, + Far, + } + + private sealed class PublicationTransaction + { + public required PublicationKind Kind; + public required LandblockBuild Build; + public required LandblockMeshData MeshData; + public required uint LandblockId; + public LandblockStreamTier Tier; + public bool PresentationCommitted; + public bool SpatialCommitted; + public GpuLandblockSpatialPublication? SpatialPublication; + public bool SpatialPresentationCommitted; + public bool EnvCellReplayCommitted; + public bool LiveRecoveryCommitted; + } + + private readonly Action _publishBeforeSpatialCommit; + private readonly GpuWorldState _state; + private readonly Action? _onLandblockLoaded; + private readonly Action? _ensureEnvCellMeshes; + private readonly LandblockRetirementCoordinator _retirements; + private readonly Dictionary _publications = + new(ReferenceEqualityComparer.Instance); + + public LandblockPresentationPipeline( + Action publishBeforeSpatialCommit, + GpuWorldState state, + Action? onLandblockLoaded = null, + Action? ensureEnvCellMeshes = null, + LandblockRetirementCoordinator? retirementCoordinator = null, + Action? removeTerrain = null, + Action? demoteNearLayer = null) + { + ArgumentNullException.ThrowIfNull(publishBeforeSpatialCommit); + ArgumentNullException.ThrowIfNull(state); + + _publishBeforeSpatialCommit = publishBeforeSpatialCommit; + _state = state; + _onLandblockLoaded = onLandblockLoaded; + _ensureEnvCellMeshes = ensureEnvCellMeshes; + _retirements = retirementCoordinator + ?? LandblockRetirementCoordinator.CreateLegacy( + state, + removeTerrain, + demoteNearLayer); + } + + public int PendingRetirementCount => _retirements.PendingCount; + public int PendingPublicationCount => _publications.Count; + + public bool IsRetirementPending(uint landblockId) => + _retirements.IsPending(landblockId); + + public bool HasPendingPublication(LandblockStreamResult result) + { + ArgumentNullException.ThrowIfNull(result); + return _publications.ContainsKey(result); + } + + public IReadOnlyList GetPendingPublicationResults() => + _publications.Keys.ToArray(); + + public void ResumePublication(LandblockStreamResult result) + { + ArgumentNullException.ThrowIfNull(result); + if (!_publications.TryGetValue(result, out PublicationTransaction? transaction)) + return; + Advance(result, transaction); + } + + public void AdvanceRetirements() => _retirements.Advance(); + + public void BeginFullRetirement(uint landblockId) => + _retirements.BeginFull(landblockId); + + public void BeginNearLayerRetirement(uint landblockId) => + _retirements.BeginNearLayer(landblockId); + + public void PublishLoaded(LandblockStreamResult.Loaded loaded) + { + ArgumentNullException.ThrowIfNull(loaded); + PublicationTransaction transaction = GetOrCreate( + loaded, + PublicationKind.Loaded, + loaded.Build, + loaded.MeshData, + loaded.LandblockId, + loaded.Tier); + Advance(loaded, transaction); + } + + public void PublishPromoted( + LandblockStreamResult.Promoted promoted, + bool mergeIntoExistingLandblock) + { + ArgumentNullException.ThrowIfNull(promoted); + PublicationKind kind = mergeIntoExistingLandblock + ? PublicationKind.PromoteExisting + : PublicationKind.PromoteSelfContained; + PublicationTransaction transaction = GetOrCreate( + promoted, + kind, + promoted.Build, + promoted.MeshData, + promoted.LandblockId, + LandblockStreamTier.Near); + Advance(promoted, transaction); + } + + public void PublishAsFar( + LandblockStreamResult acceptedResult, + LandblockBuild completedBuild, + LandblockMeshData meshData) + { + ArgumentNullException.ThrowIfNull(acceptedResult); + ArgumentNullException.ThrowIfNull(completedBuild); + ArgumentNullException.ThrowIfNull(meshData); + + if (!_publications.TryGetValue(acceptedResult, out PublicationTransaction? transaction)) + { + LoadedLandblock completed = completedBuild.Landblock; + var farLandblock = new LoadedLandblock( + completed.LandblockId, + completed.Heightmap, + Array.Empty(), + PhysicsDatBundle.Empty); + transaction = new PublicationTransaction + { + Kind = PublicationKind.Far, + Build = new LandblockBuild(farLandblock), + MeshData = meshData, + LandblockId = farLandblock.LandblockId, + Tier = LandblockStreamTier.Far, + }; + _publications.Add(acceptedResult, transaction); + } + else if (transaction.Kind != PublicationKind.Far) + { + throw new InvalidOperationException( + $"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending."); + } + + Advance(acceptedResult, transaction); + } + + private PublicationTransaction GetOrCreate( + LandblockStreamResult result, + PublicationKind kind, + LandblockBuild build, + LandblockMeshData meshData, + uint landblockId, + LandblockStreamTier tier) + { + if (_publications.TryGetValue(result, out PublicationTransaction? existing)) + { + if (existing.Kind != kind + && !(existing.Kind is PublicationKind.PromoteExisting + or PublicationKind.PromoteSelfContained + && kind is PublicationKind.PromoteExisting + or PublicationKind.PromoteSelfContained)) + { + throw new InvalidOperationException( + $"Landblock publication 0x{existing.LandblockId:X8} changed kind while pending."); + } + return existing; + } + + var created = new PublicationTransaction + { + Kind = kind, + Build = build, + MeshData = meshData, + LandblockId = landblockId, + Tier = tier, + }; + _publications.Add(result, created); + return created; + } + + private void Advance( + LandblockStreamResult result, + PublicationTransaction transaction) + { + if (!transaction.PresentationCommitted) + { + try + { + _publishBeforeSpatialCommit(transaction.Build, transaction.MeshData); + } + catch (Exception error) + { + // 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; + } + transaction.PresentationCommitted = true; + } + + if (!transaction.SpatialPresentationCommitted) + { + // Keep live-projection visibility publication at the shipped + // boundary: bucket mutation, pin/script reconciliation, then the + // outer spatial observer commit. If pinning throws, the receipt is + // retained and only that suffix retries. + using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch(); + if (!transaction.SpatialCommitted) + { + IEnumerable? renderIds = + transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId); + transaction.SpatialPublication = transaction.Kind switch + { + PublicationKind.Loaded + or PublicationKind.PromoteSelfContained + or PublicationKind.Far => + _state.CommitLandblockSpatial( + transaction.Build.Landblock, + renderIds, + transaction.Tier), + PublicationKind.PromoteExisting => + _state.CommitEntitiesToExistingLandblockSpatial( + transaction.LandblockId, + transaction.Build.Landblock.Entities, + renderIds), + _ => throw new InvalidOperationException( + $"Unknown landblock publication kind {transaction.Kind}."), + }; + transaction.SpatialCommitted = true; + } + + _state.ActivateLandblockPresentation( + transaction.SpatialPublication + ?? throw new InvalidOperationException( + "A committed spatial publication has no activation receipt.")); + transaction.SpatialPresentationCommitted = true; + } + + if (!transaction.EnvCellReplayCommitted) + { + if (transaction.Kind != PublicationKind.Far + && transaction.Build.EnvCells is { Shells.Length: > 0 } envCells) + { + _ensureEnvCellMeshes?.Invoke(envCells); + } + transaction.EnvCellReplayCommitted = true; + } + + if (!transaction.LiveRecoveryCommitted) + { + // Retained live projections can materialize only after the + // canonical bucket exists. Landblock load never reconstructs their + // identity or replays create-time resources. + _onLandblockLoaded?.Invoke(transaction.LandblockId); + transaction.LiveRecoveryCommitted = true; + } + + _publications.Remove(result); + } +} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 69bb8760..9805d96f 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -21,24 +21,9 @@ public sealed class StreamingController private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; - private readonly Action _applyTerrain; private readonly Action? _clearPendingLoads; - private readonly LandblockRetirementCoordinator _retirements; + private readonly LandblockPresentationPipeline _presentation; - /// - /// #138: fired after a landblock's entity layer (re)loads — both the full - /// path (initial / dungeon-exit - /// expand) and the path - /// (Far→Near). GameWindow wires it to re-hydrate server objects whose - /// render entities were dropped by the collapse/demote but whose parsed - /// spawns it still retains. Receives the canonical landblock id. Null in - /// tests that don't exercise re-hydration. - /// - private readonly Action? _onLandblockLoaded; - // Invoked only after LandblockSpawnAdapter has pinned every synthetic - // EnvCell geometry id. This re-arms the schema-aware preparation path if - // an unowned cached mesh was evicted between worker schedule and publish. - private readonly Action? _ensureEnvCellMeshes; private readonly GpuWorldState _state; private StreamingRegion? _region; private RadiiReconfiguration? _pendingRadiiReconfiguration; @@ -194,16 +179,16 @@ public sealed class StreamingController _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; - _applyTerrain = applyTerrain; _clearPendingLoads = clearPendingLoads; - _onLandblockLoaded = onLandblockLoaded; - _ensureEnvCellMeshes = ensureEnvCellMeshes; _state = state; - _retirements = retirementCoordinator - ?? LandblockRetirementCoordinator.CreateLegacy( - state, - removeTerrain, - demoteNearLayer); + _presentation = new LandblockPresentationPipeline( + applyTerrain, + state, + onLandblockLoaded, + ensureEnvCellMeshes, + retirementCoordinator, + removeTerrain, + demoteNearLayer); NearRadius = nearRadius; FarRadius = farRadius; } @@ -249,6 +234,12 @@ public sealed class StreamingController if (nearRadius == NearRadius && farRadius == FarRadius) return; + // A staged old-window publication must either finish or fail before + // the desired-tier snapshot below is computed. Otherwise a landblock + // that becomes spatially resident during convergence is absent from + // the reconfiguration mutation ledger. + ConvergePendingPublications(); + if (_collapsed || _region is null) { NearRadius = nearRadius; @@ -348,14 +339,44 @@ public sealed class StreamingController private void DemoteLandblock(uint id) { uint canonical = (id & 0xFFFF0000u) | 0xFFFFu; - _retirements.BeginNearLayer(canonical); + _presentation.BeginNearLayerRetirement(canonical); } private void AdvanceGeneration() { + ConvergePendingPublications(); _generation = unchecked(_generation + 1); } + private void ConvergePendingPublications() + { + IReadOnlyList pending = + _presentation.GetPendingPublicationResults(); + if (pending.Count == 0) + return; + + try + { + for (int i = 0; i < pending.Count; i++) + _presentation.ResumePublication(pending[i]); + } + finally + { + // A completed staged result can still be present in the deferred + // FIFO that originally retained it. Remove only those exact + // objects; unrelated accepted completions keep their order. + _deferredApply.RemoveAll(result => + { + for (int i = 0; i < pending.Count; i++) + { + if (ReferenceEquals(result, pending[i])) + return !_presentation.HasPendingPublication(result); + } + return false; + }); + } + } + /// /// Advance one frame. / /// are landblock coordinates (0..255) of the current viewer — the camera @@ -382,7 +403,11 @@ public sealed class StreamingController // Presentation-owner failures never roll back spatial state. Resume // unfinished owner ledgers before accepting replacement publications. - _retirements.Advance(); + _presentation.AdvanceRetirements(); + // Complete an admitted publication before this frame can mutate its + // desired tier or spatial residence. A later recenter may then demote + // or retire the fully known owner set through the normal ledger. + ConvergePendingPublications(); uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); @@ -616,11 +641,12 @@ public sealed class StreamingController /// private void EnterDungeonCollapse(int cx, int cy, uint centerId) { - if (!_collapsed || _collapsedCenter != centerId) + bool logTransition = !_collapsed || _collapsedCenter != centerId; + if (logTransition) Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}"); + AdvanceGeneration(); _collapsed = true; _collapsedCenter = centerId; - AdvanceGeneration(); _clearPendingLoads?.Invoke(); _deferredApply.Clear(); _region = null; @@ -685,8 +711,8 @@ public sealed class StreamingController Console.WriteLine( $"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " + $"(was collapsed on 0x{_collapsedCenter:X8})"); - _collapsed = false; AdvanceGeneration(); + _collapsed = false; var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); foreach (var id in _state.LoadedLandblockIds) @@ -715,19 +741,20 @@ public sealed class StreamingController /// public void ForceReloadWindow() { - _collapsed = false; AdvanceGeneration(); + _collapsed = false; _clearPendingLoads?.Invoke(); _deferredApply.Clear(); // Commit the old region boundary before any presentation owner is - // advanced. New same-id publications are fenced by _retirements. + // advanced. New same-id publications are fenced by the presentation + // pipeline's retryable retirement ledger. _region = null; // Snapshot — RemoveLandblock mutates the loaded set we're iterating. var ids = new List(_state.LoadedLandblockIds); ForceReloadCount++; // [FRAME-DIAG] churn counter LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume foreach (var id in ids) - _retirements.BeginFull(id); + _presentation.BeginFullRetirement(id); } /// @@ -757,18 +784,41 @@ public sealed class StreamingController { var chunk = _drainCompletions(MaxCompletionsPerFrame); if (chunk.Count == 0) break; - foreach (var result in chunk) + for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++) { + LandblockStreamResult result = chunk[chunkIndex]; // Reject obsolete hard-window work before it can occupy the // metered deferred queue. ApplyResult repeats this invariant // for direct callers and future drain paths. - if (IsStaleGeneration(result)) + if (IsStaleGeneration(result) + && !_presentation.HasPendingPublication(result)) continue; if (IsPublicationBlockedByRetirement(result)) _deferredApply.Add(result); else if (result is LandblockStreamResult.Unloaded || IsWithinPriorityRing(ResultLandblockId(result))) - ApplyResult(result); // free (unload) or behind-the-fade near ring + { + try + { + ApplyResult(result); // free (unload) or behind-the-fade near ring + } + catch + { + // The presentation pipeline retains its exact committed + // stage after the coarse presentation callback commits. + // Preserve it and every untouched completion from this + // already-drained chunk in original FIFO order. A + // presentation-stage failure is deliberately fail-fast + // until the focused publishers replace that callback; + // its result is not retryable, but the untouched suffix + // must still survive. + if (_presentation.HasPendingPublication(result)) + _deferredApply.Add(result); + for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++) + _deferredApply.Add(chunk[suffix]); + throw; + } + } else _deferredApply.Add(result); // a GPU-upload load — meter it in step 2 } @@ -798,9 +848,24 @@ public sealed class StreamingController // Advance read only after the apply commits. If it throws, the // finally block removes prior committed entries but retains this // exact result and the untouched tail for retry. - ApplyResult(result); - read++; - applied++; + try + { + ApplyResult(result); + read++; + applied++; + } + catch + { + // A suffix-stage failure keeps its pipeline receipt and + // must remain at this exact FIFO position. The still- + // monolithic presentation callback deliberately removes + // its receipt on failure because its internal committed + // prefix is unknown; consume only that failed result so a + // later frame cannot replay it automatically. + if (!_presentation.HasPendingPublication(result)) + read++; + throw; + } } } finally @@ -821,6 +886,13 @@ public sealed class StreamingController /// private void ApplyResult(LandblockStreamResult result) { + if (_presentation.HasPendingPublication(result)) + { + _presentation.ResumePublication(result); + ReconcileCompletedPendingPublication(result.LandblockId); + return; + } + if (IsStaleGeneration(result)) { // A worker can finish one old load/unload after a hard recenter. @@ -853,7 +925,9 @@ public sealed class StreamingController bool isNearCompletion = result is LandblockStreamResult.Promoted or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near }; - if (isNearCompletion && _state.IsNearTier(result.LandblockId)) + if (isNearCompletion + && _state.IsNearTier(result.LandblockId) + && !_presentation.HasPendingPublication(result)) { // Normal recenter can enqueue a second high-priority Near job // while an earlier one is already in flight. The streamer @@ -878,10 +952,10 @@ public sealed class StreamingController switch (result) { case LandblockStreamResult.Loaded loaded: - ApplyAsFar(loaded.Build, loaded.MeshData); + _presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData); break; case LandblockStreamResult.Promoted promoted: - ApplyAsFar(promoted.Build, promoted.MeshData); + _presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData); break; } } @@ -900,16 +974,7 @@ public sealed class StreamingController // layer that now owns the landblock. break; } - _applyTerrain(loaded.Build, loaded.MeshData); - _state.AddLandblock( - loaded.Landblock, - loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId), - loaded.Tier); - EnsureEnvCellMeshesAfterPin(loaded.Build); - // #138: after the landblock is in _loaded (so live projection - // hot-paths), restore any retained server objects ACE won't - // re-send. Fired AFTER AddLandblock, never before. - _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); + _presentation.PublishLoaded(loaded); break; case LandblockStreamResult.Promoted promoted: // PromoteToNear carries a complete build and mesh because the @@ -917,28 +982,12 @@ public sealed class StreamingController // that Far job never started, publish this as the real Near // landblock; if the base is already resident, merge only the // Near layer so existing live projections retain identity. - _applyTerrain(promoted.Build, promoted.MeshData); - var promotedRenderIds = - promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId); - if (_state.IsLoaded(promoted.LandblockId)) - { - _state.AddEntitiesToExistingLandblock( - promoted.LandblockId, - promoted.Entities, - promotedRenderIds); - } - else - { - _state.AddLandblock( - promoted.Landblock, - promotedRenderIds, - LandblockStreamTier.Near); - } - EnsureEnvCellMeshesAfterPin(promoted.Build); - _onLandblockLoaded?.Invoke(promoted.LandblockId); + _presentation.PublishPromoted( + promoted, + mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId)); break; case LandblockStreamResult.Unloaded unloaded: - _retirements.BeginFull(unloaded.LandblockId); + _presentation.BeginFullRetirement(unloaded.LandblockId); break; case LandblockStreamResult.Failed failed: Console.WriteLine( @@ -951,25 +1000,21 @@ public sealed class StreamingController } } - private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData) + private void ReconcileCompletedPendingPublication(uint landblockId) { - var completed = completedBuild.Landblock; - var farLandblock = new LoadedLandblock( - completed.LandblockId, - completed.Heightmap, - Array.Empty(), - PhysicsDatBundle.Empty); - var farBuild = new LandblockBuild(farLandblock); + if (_region is null + || !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier)) + { + if (_state.IsLoaded(landblockId)) + _presentation.BeginFullRetirement(landblockId); + return; + } - _applyTerrain(farBuild, meshData); - _state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far); - _onLandblockLoaded?.Invoke(farLandblock.LandblockId); - } - - private void EnsureEnvCellMeshesAfterPin(LandblockBuild build) - { - if (build.EnvCells is { Shells.Length: > 0 } envCells) - _ensureEnvCellMeshes?.Invoke(envCells); + if (desiredTier == LandblockStreamTier.Far + && _state.IsNearTier(landblockId)) + { + _presentation.BeginNearLayerRetirement(landblockId); + } } private bool IsStaleGeneration(LandblockStreamResult result) => @@ -978,7 +1023,7 @@ public sealed class StreamingController private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) => result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted - && _retirements.IsPending(result.LandblockId); + && _presentation.IsRetirementPending(result.LandblockId); /// /// Returns the landblock id associated with . diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs new file mode 100644 index 00000000..fc89089f --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -0,0 +1,711 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockPresentationPipelineTests +{ + [Fact] + public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder() + { + const uint landblockId = 0x2020FFFFu; + var calls = new List(); + var meshes = new RecordingMeshAdapter(calls); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + LandblockBuild build = BuildWithShell(landblockId); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (accepted, _) => + { + Assert.Same(build, accepted); + Assert.False(state.IsLoaded(landblockId)); + calls.Add("presentation"); + }, + state, + onLandblockLoaded: id => + { + Assert.Equal(landblockId, id); + Assert.True(state.IsLoaded(id)); + calls.Add("live-recovery"); + }, + ensureEnvCellMeshes: envCells => + { + Assert.Same(build.EnvCells, envCells); + Assert.True(state.IsLoaded(landblockId)); + calls.Add("mesh-replay-after-pin"); + }); + + pipeline.PublishLoaded(new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh())); + + Assert.Equal( + ["presentation", "pin", "mesh-replay-after-pin", "live-recovery"], + calls); + Assert.True(state.IsNearTier(landblockId)); + } + + [Fact] + public void Promoted_ExistingFar_MergesNearLayerBeforeReplayAndRecovery() + { + const uint landblockId = 0x3030FFFFu; + var calls = new List(); + var meshes = new RecordingMeshAdapter(calls); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + state.AddLandblock( + new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty(), + PhysicsDatBundle.Empty), + tier: LandblockStreamTier.Far); + LandblockBuild build = BuildWithShell(landblockId, Entity(7)); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"), + state, + onLandblockLoaded: _ => + { + Assert.True(state.IsNearTier(landblockId)); + Assert.Contains(state.Entities, entity => entity.Id == 7); + calls.Add("live-recovery"); + }, + ensureEnvCellMeshes: _ => + { + Assert.True(state.IsNearTier(landblockId)); + Assert.Contains(state.Entities, entity => entity.Id == 7); + calls.Add("mesh-replay-after-pin"); + }); + + pipeline.PublishPromoted(new LandblockStreamResult.Promoted( + landblockId, + build, + EmptyMesh()), + mergeIntoExistingLandblock: true); + + Assert.Equal( + ["presentation", "pin", "mesh-replay-after-pin", "live-recovery"], + calls); + } + + [Fact] + public void PublishAsFar_StripsNearPayloadAndSkipsEnvCellReplay() + { + const uint landblockId = 0x4040FFFFu; + var state = new GpuWorldState(); + LandblockBuild source = BuildWithShell(landblockId, Entity(9)); + LandblockBuild? published = null; + int replayCount = 0; + int recoveryCount = 0; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (build, _) => published = build, + state, + onLandblockLoaded: _ => recoveryCount++, + ensureEnvCellMeshes: _ => replayCount++); + + var accepted = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + source, + EmptyMesh()); + pipeline.PublishAsFar(accepted, source, accepted.MeshData); + + Assert.NotNull(published); + Assert.Empty(published.Landblock.Entities); + Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats); + Assert.Null(published.EnvCells); + Assert.True(state.IsLoaded(landblockId)); + Assert.False(state.IsNearTier(landblockId)); + Assert.Equal(0, replayCount); + Assert.Equal(1, recoveryCount); + } + + [Fact] + public void EnvCellReplayFailure_ResumesSuffixWithoutReplayingCommittedPrefix() + { + const uint landblockId = 0x5151FFFFu; + var calls = new List(); + var state = new GpuWorldState(new LandblockSpawnAdapter( + new RecordingMeshAdapter(calls))); + bool failReplayOnce = true; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"), + state, + onLandblockLoaded: _ => calls.Add("live-recovery"), + ensureEnvCellMeshes: _ => + { + calls.Add("mesh-replay"); + if (failReplayOnce) + { + failReplayOnce = false; + throw new InvalidOperationException("injected replay failure"); + } + }); + LandblockBuild build = BuildWithShell(landblockId); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + Assert.True(state.IsNearTier(landblockId)); + Assert.True(pipeline.HasPendingPublication(result)); + + pipeline.PublishLoaded(result); + + Assert.Equal( + ["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"], + calls); + Assert.False(pipeline.HasPendingPublication(result)); + } + + [Fact] + public void PromotionReplayFailure_RetainsOriginalSelfContainedModeAcrossRetry() + { + const uint landblockId = 0x5252FFFFu; + var calls = new List(); + var state = new GpuWorldState(new LandblockSpawnAdapter( + new RecordingMeshAdapter(calls))); + bool failReplayOnce = true; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"), + state, + onLandblockLoaded: _ => calls.Add("live-recovery"), + ensureEnvCellMeshes: _ => + { + calls.Add("mesh-replay"); + if (failReplayOnce) + { + failReplayOnce = false; + throw new InvalidOperationException("injected replay failure"); + } + }); + LandblockBuild build = BuildWithShell(landblockId, Entity(17)); + var result = new LandblockStreamResult.Promoted( + landblockId, + build, + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishPromoted( + result, + mergeIntoExistingLandblock: false)); + Assert.True(state.IsNearTier(landblockId)); + + // Current residency changed after the first attempt. The transaction + // must retain the original self-contained publication command rather + // than switching policy mid-retry. + pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true); + + Assert.Equal( + ["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"], + calls); + Assert.Single(state.Entities, entity => entity.Id == 17); + } + + [Fact] + public void SpatialPinFailure_ControllerRetriesExactCompletionWithoutReplayingPresentation() + { + const uint landblockId = 0x6060FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + LandblockBuild build = BuildWithShell(landblockId); + outbox.Enqueue(new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh())); + int presentationCalls = 0; + int replayCalls = 0; + int liveRecoveryCalls = 0; + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => presentationCalls++, + state, + nearRadius: 0, + farRadius: 0, + onLandblockLoaded: _ => liveRecoveryCalls++, + ensureEnvCellMeshes: _ => replayCalls++); + + Assert.Throws(() => controller.Tick(0x60, 0x60)); + Assert.True(state.IsNearTier(landblockId)); + Assert.Equal(1, presentationCalls); + Assert.Equal(0, replayCalls); + Assert.Equal(0, liveRecoveryCalls); + + controller.Tick(0x60, 0x60); + + Assert.Equal(1, presentationCalls); + Assert.Equal(2, meshes.PinAttempts); + Assert.Equal(1, replayCalls); + Assert.Equal(1, liveRecoveryCalls); + } + + [Fact] + public void PromotionPinFailure_RetriesActivationWithoutAppendingEntitiesAgain() + { + const uint landblockId = 0x6161FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + state.AddLandblock( + new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty()), + tier: LandblockStreamTier.Far); + int presentationCalls = 0; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => presentationCalls++, + state); + WorldEntity promotedEntity = Entity(31); + LandblockBuild build = BuildWithShell(landblockId, promotedEntity); + var result = new LandblockStreamResult.Promoted( + landblockId, + build, + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishPromoted( + result, + mergeIntoExistingLandblock: true)); + Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity)); + + pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true); + + Assert.Equal(1, presentationCalls); + Assert.Equal(2, meshes.PinAttempts); + Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity)); + Assert.False(pipeline.HasPendingPublication(result)); + } + + [Fact] + public void LoadedPinFailure_RetryPreservesPendingLiveProjectionMergedAtSpatialCommit() + { + const uint landblockId = 0x6262FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var pendingLive = new WorldEntity + { + Id = 77, + ServerGuid = 0x80000077u, + SourceGfxObjOrSetupId = 0x01000077u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000077u, Matrix4x4.Identity)], + }; + state.PlaceLiveEntityProjection(0x62620100u, pendingLive); + LandblockBuild build = BuildWithShell(landblockId, Entity(32)); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + var pipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + state); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive)); + Assert.Equal(0, state.PendingLiveEntityCount); + + pipeline.PublishLoaded(result); + + Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive)); + Assert.Single(state.Entities, entity => ReferenceEquals(entity, pendingLive)); + Assert.Equal(0, state.PendingLiveEntityCount); + Assert.Equal(2, meshes.PinAttempts); + } + + [Fact] + public void PriorityFailure_PreservesFailingResultAndUntouchedDrainedSuffix() + { + const uint priorityId = 0x7070FFFFu; + const uint laterId = 0x7071FFFFu; + const uint unloadId = 0x1010FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + state.AddLandblock(new LoadedLandblock( + unloadId, + new LandBlock(), + Array.Empty())); + var outbox = new Queue( + [ + new LandblockStreamResult.Loaded( + priorityId, + LandblockStreamTier.Near, + BuildWithShell(priorityId), + EmptyMesh()), + new LandblockStreamResult.Loaded( + laterId, + LandblockStreamTier.Near, + BuildWithShell(laterId), + EmptyMesh()), + new LandblockStreamResult.Unloaded(unloadId), + ]); + var calls = new List(); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (build, _) => calls.Add($"publish:{build.LandblockId:X8}"), + state, + nearRadius: 0, + farRadius: 2, + removeTerrain: id => calls.Add($"unload:{id:X8}")); + controller.PriorityLandblockId = priorityId; + + Assert.Throws(() => controller.Tick(0x70, 0x70)); + Assert.Empty(outbox); + Assert.False(state.IsLoaded(laterId)); + Assert.True(state.IsLoaded(unloadId)); + + controller.PriorityLandblockId = 0; + controller.Tick(0x70, 0x70); + + Assert.True(state.IsLoaded(priorityId)); + Assert.True(state.IsLoaded(laterId)); + Assert.False(state.IsLoaded(unloadId)); + Assert.Equal( + [ + $"publish:{priorityId:X8}", + $"publish:{laterId:X8}", + $"unload:{unloadId:X8}", + ], + calls); + } + + [Fact] + public void HardRecenter_ConvergesPendingSpatialPublicationBeforeRetiringIt() + { + const uint landblockId = 0x8080FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + BuildWithShell(landblockId), + EmptyMesh()); + outbox.Enqueue(result); + int presentationCalls = 0; + int replayCalls = 0; + int recoveryCalls = 0; + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => presentationCalls++, + state, + nearRadius: 0, + farRadius: 0, + onLandblockLoaded: _ => recoveryCalls++, + ensureEnvCellMeshes: _ => replayCalls++); + + Assert.Throws(() => controller.Tick(0x80, 0x80)); + Assert.True(state.IsNearTier(landblockId)); + + controller.ForceReloadWindow(); + + Assert.False(state.IsLoaded(landblockId)); + Assert.Equal(1, presentationCalls); + Assert.Equal(1, replayCalls); + Assert.Equal(1, recoveryCalls); + Assert.Equal(2, meshes.PinAttempts); + } + + [Fact] + public void DesiredNearToFarChurn_ResumesOriginalPublicationThenDemotes() + { + const uint landblockId = 0x9090FFFFu; + var calls = new List(); + var state = new GpuWorldState(new LandblockSpawnAdapter( + new RecordingMeshAdapter(calls))); + var outbox = new Queue(); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + BuildWithShell(landblockId, Entity(23)), + EmptyMesh()); + outbox.Enqueue(result); + bool failReplayOnce = true; + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => calls.Add("presentation"), + state, + nearRadius: 0, + farRadius: 2, + demoteNearLayer: _ => calls.Add("demote"), + ensureEnvCellMeshes: _ => + { + calls.Add("mesh-replay"); + if (failReplayOnce) + { + failReplayOnce = false; + throw new InvalidOperationException("injected replay failure"); + } + }); + + Assert.Throws(() => controller.Tick(0x90, 0x90)); + Assert.True(state.IsNearTier(landblockId)); + + controller.Tick(0x90, 0x93); + + Assert.True(state.IsLoaded(landblockId)); + Assert.False(state.IsNearTier(landblockId)); + Assert.DoesNotContain(state.Entities, entity => entity.Id == 23); + Assert.Equal(1, calls.Count(call => call == "presentation")); + Assert.Equal(2, calls.Count(call => call == "mesh-replay")); + Assert.Contains("demote", calls); + } + + [Fact] + public void CoarsePresentationFailure_IsFailFastAndNotRetainedForAutomaticReplay() + { + const uint landblockId = 0xA0A0FFFFu; + var state = new GpuWorldState(); + int presentationCalls = 0; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => + { + presentationCalls++; + throw new StreamingMutationException( + "injected committed coarse failure", + mutationCommitted: true); + }, + state); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + BuildWithShell(landblockId), + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + + Assert.Equal(1, presentationCalls); + Assert.False(pipeline.HasPendingPublication(result)); + Assert.Equal(0, pipeline.PendingPublicationCount); + Assert.False(state.IsLoaded(landblockId)); + } + + [Fact] + public void DeferredCommittedPresentationFailure_DropsOnlyFailedResultAndPreservesTail() + { + const uint failedId = 0xB0B0FFFFu; + const uint tailId = 0xB0B1FFFFu; + var outbox = new Queue( + [ + new LandblockStreamResult.Loaded( + failedId, + LandblockStreamTier.Near, + new LandblockBuild(new LoadedLandblock( + failedId, + new LandBlock(), + Array.Empty())), + EmptyMesh()), + new LandblockStreamResult.Loaded( + tailId, + LandblockStreamTier.Near, + new LandblockBuild(new LoadedLandblock( + tailId, + new LandBlock(), + Array.Empty())), + EmptyMesh()), + ]); + int failedCalls = 0; + int tailCalls = 0; + var state = new GpuWorldState(); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (build, _) => + { + if (build.LandblockId == failedId) + { + failedCalls++; + throw new StreamingMutationException( + "injected committed presentation failure", + mutationCommitted: true); + } + tailCalls++; + }, + state, + nearRadius: 0, + farRadius: 2); + + Assert.Throws(() => controller.Tick(0xB0, 0xB0)); + Assert.Equal(1, failedCalls); + Assert.Equal(0, tailCalls); + Assert.Equal(1, controller.DeferredApplyBacklog); + + controller.Tick(0xB0, 0xB0); + + Assert.Equal(1, failedCalls); + Assert.Equal(1, tailCalls); + Assert.False(state.IsLoaded(failedId)); + Assert.True(state.IsLoaded(tailId)); + Assert.Equal(0, controller.DeferredApplyBacklog); + } + + [Fact] + public void RetirementFacade_DetachesFirstAndReportsPendingLedger() + { + const uint landblockId = 0x5050FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + bool failOnce = true; + var retirements = new LandblockRetirementCoordinator( + state, + ticket => + { + ticket.RunOnce( + LandblockRetirementStage.EntityLighting, + () => + { + if (failOnce) + { + failOnce = false; + throw new InvalidOperationException("injected failure"); + } + }); + }, + _ => LandblockRetirementStage.EntityLighting); + var pipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + state, + retirementCoordinator: retirements); + + pipeline.BeginFullRetirement(landblockId); + + Assert.False(state.IsLoaded(landblockId)); + Assert.True(pipeline.IsRetirementPending(landblockId)); + Assert.Equal(1, pipeline.PendingRetirementCount); + + pipeline.AdvanceRetirements(); + + Assert.False(pipeline.IsRetirementPending(landblockId)); + Assert.Equal(0, pipeline.PendingRetirementCount); + } + + [Fact] + public void Pipeline_DoesNotOwnStreamingPolicyOrLiveIdentityMaps() + { + string[] forbiddenFragments = + [ + "generation", + "desiredtier", + "streamingregion", + "serverguid", + "liveentity", + ]; + + string[] fields = typeof(LandblockPresentationPipeline) + .GetFields(System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic) + .Select(field => $"{field.Name}:{field.FieldType.FullName}") + .ToArray(); + + foreach (string field in fields) + foreach (string forbidden in forbiddenFragments) + Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase); + } + + private static LandblockBuild BuildWithShell( + uint landblockId, + 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(), + entities, + PhysicsDatBundle.Empty), + envCells); + } + + private static WorldEntity Entity(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000000u + id, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); + + private static IReadOnlyList Drain( + Queue queue, + int max) + { + var drained = new List(max); + while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result)) + drained.Add(result); + return drained; + } + + 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 FailFirstPreparedPinAdapter : IWbMeshAdapter + { + public int PinAttempts { get; private set; } + + public void IncrementRefCount(ulong id) + { + } + + public void PinPreparedRenderData(ulong id) + { + PinAttempts++; + if (PinAttempts == 1) + throw new InvalidOperationException("injected pin failure"); + } + + public void DecrementRefCount(ulong id) + { + } + } +}