refactor(streaming): own landblock publication transactions
This commit is contained in:
parent
4001251064
commit
4f965d0699
5 changed files with 1301 additions and 122 deletions
|
|
@ -4608,7 +4608,26 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||||
if (_frameDiag)
|
if (_frameDiag)
|
||||||
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||||
|
try
|
||||||
|
{
|
||||||
ApplyLoadedTerrainLocked(build, meshData);
|
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)
|
if (_frameDiag)
|
||||||
{
|
{
|
||||||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,18 @@ using AcDream.Core.World;
|
||||||
|
|
||||||
namespace AcDream.App.Streaming;
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record GpuLandblockSpatialPublication(
|
||||||
|
uint LandblockId,
|
||||||
|
LoadedLandblock Landblock,
|
||||||
|
IReadOnlyList<ulong> AdditionalRenderIds,
|
||||||
|
IReadOnlyList<WorldEntity> StaticEntities,
|
||||||
|
bool RequiresActivation = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Render-thread-owned registry of currently-loaded landblocks and their
|
/// Render-thread-owned registry of currently-loaded landblocks and their
|
||||||
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
|
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
|
||||||
|
|
@ -619,12 +631,44 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
LandblockStreamTier tier = LandblockStreamTier.Near)
|
LandblockStreamTier tier = LandblockStreamTier.Near)
|
||||||
{
|
{
|
||||||
using MutationBatch mutation = BeginMutationBatch();
|
using MutationBatch mutation = BeginMutationBatch();
|
||||||
|
GpuLandblockSpatialPublication publication =
|
||||||
|
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
|
||||||
|
ActivateLandblockPresentation(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commits only canonical buckets, indexes, pending-live merge, tier, and
|
||||||
|
/// readiness-id ownership. The caller holds one outer
|
||||||
|
/// <see cref="MutationBatch"/> through the subsequent
|
||||||
|
/// <see cref="ActivateLandblockPresentation"/> call so live visibility
|
||||||
|
/// observers retain the shipped commit boundary.
|
||||||
|
/// </summary>
|
||||||
|
internal GpuLandblockSpatialPublication CommitLandblockSpatial(
|
||||||
|
LoadedLandblock landblock,
|
||||||
|
IEnumerable<ulong>? additionalRenderIds,
|
||||||
|
LandblockStreamTier tier) =>
|
||||||
|
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
|
||||||
|
|
||||||
|
private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
|
||||||
|
LoadedLandblock landblock,
|
||||||
|
IEnumerable<ulong>? additionalRenderIds,
|
||||||
|
LandblockStreamTier tier)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(landblock);
|
||||||
|
|
||||||
// A stale Far completion must never replace a newer Near entity layer.
|
// A stale Far completion must never replace a newer Near entity layer.
|
||||||
// StreamingController normally filters it before render-side apply;
|
// StreamingController normally filters it before render-side apply;
|
||||||
// keep the state boundary independently monotonic as well.
|
// keep the state boundary independently monotonic as well.
|
||||||
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
|
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
|
||||||
return;
|
{
|
||||||
|
LoadedLandblock retained = _loaded[landblock.LandblockId];
|
||||||
|
return new GpuLandblockSpatialPublication(
|
||||||
|
retained.LandblockId,
|
||||||
|
retained,
|
||||||
|
Array.Empty<ulong>(),
|
||||||
|
Array.Empty<WorldEntity>(),
|
||||||
|
RequiresActivation: false);
|
||||||
|
}
|
||||||
|
|
||||||
// If pending live entities have been waiting for this landblock,
|
// If pending live entities have been waiting for this landblock,
|
||||||
// merge them into the render-thread-owned mutable entity bucket before
|
// merge them into the render-thread-owned mutable entity bucket before
|
||||||
|
|
@ -668,26 +712,53 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
_loaded[landblock.LandblockId] = landblock;
|
_loaded[landblock.LandblockId] = landblock;
|
||||||
AddLoadedLandblockToFlatView(landblock);
|
AddLoadedLandblockToFlatView(landblock);
|
||||||
_tierByLandblock[landblock.LandblockId] = tier;
|
_tierByLandblock[landblock.LandblockId] = tier;
|
||||||
if (_wbSpawnAdapter is not null)
|
return CreateSpatialPublication(
|
||||||
_wbSpawnAdapter.OnLandblockLoaded(
|
|
||||||
_loaded[landblock.LandblockId],
|
_loaded[landblock.LandblockId],
|
||||||
mergedRenderIds);
|
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>());
|
||||||
|
}
|
||||||
|
|
||||||
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
|
internal void ActivateLandblockPresentation(
|
||||||
// LiveEntityRuntime owns activation for live objects. This static-only
|
GpuLandblockSpatialPublication publication)
|
||||||
// filter avoids replaying their defaults when a pending projection is
|
{
|
||||||
// drained into a newly loaded landblock.
|
ArgumentNullException.ThrowIfNull(publication);
|
||||||
|
if (!publication.RequiresActivation)
|
||||||
|
return;
|
||||||
|
if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current)
|
||||||
|
|| !ReferenceEquals(current, publication.Landblock))
|
||||||
|
{
|
||||||
|
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)
|
if (_entityScriptActivator is not null)
|
||||||
{
|
{
|
||||||
var loadedEntities = _loaded[landblock.LandblockId].Entities;
|
for (int i = 0; i < publication.StaticEntities.Count; i++)
|
||||||
for (int i = 0; i < loadedEntities.Count; i++)
|
_entityScriptActivator.OnCreate(publication.StaticEntities[i]);
|
||||||
{
|
|
||||||
var e = loadedEntities[i];
|
|
||||||
if (e.ServerGuid == 0)
|
|
||||||
_entityScriptActivator.OnCreate(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GpuLandblockSpatialPublication CreateSpatialPublication(
|
||||||
|
LoadedLandblock landblock,
|
||||||
|
IEnumerable<ulong> 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1228,11 +1299,50 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
IEnumerable<ulong>? additionalRenderIds = null)
|
IEnumerable<ulong>? additionalRenderIds = null)
|
||||||
{
|
{
|
||||||
using MutationBatch mutation = BeginMutationBatch();
|
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<WorldEntity> entities,
|
||||||
|
IEnumerable<ulong>? 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<WorldEntity> entities,
|
||||||
|
IEnumerable<ulong>? additionalRenderIds,
|
||||||
|
bool parkIfMissing)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entities);
|
||||||
|
|
||||||
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
|
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
|
||||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||||
if (!_loaded.TryGetValue(canonical, out var lb))
|
if (!_loaded.TryGetValue(canonical, out var lb))
|
||||||
{
|
{
|
||||||
|
if (!parkIfMissing)
|
||||||
|
return null;
|
||||||
|
|
||||||
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
|
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
|
||||||
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
|
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
|
||||||
{
|
{
|
||||||
|
|
@ -1263,7 +1373,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
}
|
}
|
||||||
renderIds.UnionWith(additionalRenderIds);
|
renderIds.UnionWith(additionalRenderIds);
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
List<WorldEntity> resident = MutableEntities(lb);
|
List<WorldEntity> resident = MutableEntities(lb);
|
||||||
for (int i = 0; i < entities.Count; i++)
|
for (int i = 0; i < entities.Count; i++)
|
||||||
|
|
@ -1278,20 +1388,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
}
|
}
|
||||||
_animatedIndexByLandblock.Remove(canonical);
|
_animatedIndexByLandblock.Remove(canonical);
|
||||||
_tierByLandblock[canonical] = LandblockStreamTier.Near;
|
_tierByLandblock[canonical] = LandblockStreamTier.Near;
|
||||||
if (_wbSpawnAdapter is not null)
|
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty<ulong>();
|
||||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
|
WorldEntity[] staticEntities = entities
|
||||||
|
.Where(static entity => entity.ServerGuid == 0)
|
||||||
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
|
.ToArray();
|
||||||
// All entities arriving via this path are atlas-tier by construction
|
return new GpuLandblockSpatialPublication(
|
||||||
// (the promotion path streams in dat-static scenery + EnvCell statics
|
canonical,
|
||||||
// + stabs per the method's class doc-comment).
|
_loaded[canonical],
|
||||||
if (_entityScriptActivator is not null)
|
effectiveRenderIds,
|
||||||
{
|
staticEntities);
|
||||||
for (int i = 0; i < entities.Count; i++)
|
|
||||||
_entityScriptActivator.OnCreate(entities[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrainVisibilityTransitions()
|
private void DrainVisibilityTransitions()
|
||||||
|
|
|
||||||
299
src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
Normal file
299
src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.Core.Terrain;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render-thread transaction coordinator for one accepted landblock streaming
|
||||||
|
/// result. Streaming policy (desired tier, generation, priority, and apply
|
||||||
|
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
|
||||||
|
/// in <see cref="GpuWorldState"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail establishes cells/buildings before static objects in
|
||||||
|
/// <c>LScape::grab_visible_cells @ 0x00504EC0</c>. Its release path separates
|
||||||
|
/// <c>CLandBlock::release_all @ 0x0052FCF0</c> (objects and visible cells) from
|
||||||
|
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (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.
|
||||||
|
/// </remarks>
|
||||||
|
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<LandblockBuild, LandblockMeshData> _publishBeforeSpatialCommit;
|
||||||
|
private readonly GpuWorldState _state;
|
||||||
|
private readonly Action<uint>? _onLandblockLoaded;
|
||||||
|
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
|
||||||
|
private readonly LandblockRetirementCoordinator _retirements;
|
||||||
|
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
|
||||||
|
new(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
public LandblockPresentationPipeline(
|
||||||
|
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
|
||||||
|
GpuWorldState state,
|
||||||
|
Action<uint>? onLandblockLoaded = null,
|
||||||
|
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
|
||||||
|
LandblockRetirementCoordinator? retirementCoordinator = null,
|
||||||
|
Action<uint>? removeTerrain = null,
|
||||||
|
Action<uint>? 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<LandblockStreamResult> 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<WorldEntity>(),
|
||||||
|
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<ulong>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,24 +21,9 @@ public sealed class StreamingController
|
||||||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||||
private readonly Action<uint, ulong> _enqueueUnload;
|
private readonly Action<uint, ulong> _enqueueUnload;
|
||||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||||
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
|
|
||||||
private readonly Action? _clearPendingLoads;
|
private readonly Action? _clearPendingLoads;
|
||||||
private readonly LandblockRetirementCoordinator _retirements;
|
private readonly LandblockPresentationPipeline _presentation;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// #138: fired after a landblock's entity layer (re)loads — both the full
|
|
||||||
/// <see cref="LandblockStreamResult.Loaded"/> path (initial / dungeon-exit
|
|
||||||
/// expand) and the <see cref="LandblockStreamResult.Promoted"/> path
|
|
||||||
/// (Far→Near). <c>GameWindow</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
private readonly Action<uint>? _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<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
|
|
||||||
private readonly GpuWorldState _state;
|
private readonly GpuWorldState _state;
|
||||||
private StreamingRegion? _region;
|
private StreamingRegion? _region;
|
||||||
private RadiiReconfiguration? _pendingRadiiReconfiguration;
|
private RadiiReconfiguration? _pendingRadiiReconfiguration;
|
||||||
|
|
@ -194,14 +179,14 @@ public sealed class StreamingController
|
||||||
_enqueueLoad = enqueueLoad;
|
_enqueueLoad = enqueueLoad;
|
||||||
_enqueueUnload = enqueueUnload;
|
_enqueueUnload = enqueueUnload;
|
||||||
_drainCompletions = drainCompletions;
|
_drainCompletions = drainCompletions;
|
||||||
_applyTerrain = applyTerrain;
|
|
||||||
_clearPendingLoads = clearPendingLoads;
|
_clearPendingLoads = clearPendingLoads;
|
||||||
_onLandblockLoaded = onLandblockLoaded;
|
|
||||||
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
|
||||||
_state = state;
|
_state = state;
|
||||||
_retirements = retirementCoordinator
|
_presentation = new LandblockPresentationPipeline(
|
||||||
?? LandblockRetirementCoordinator.CreateLegacy(
|
applyTerrain,
|
||||||
state,
|
state,
|
||||||
|
onLandblockLoaded,
|
||||||
|
ensureEnvCellMeshes,
|
||||||
|
retirementCoordinator,
|
||||||
removeTerrain,
|
removeTerrain,
|
||||||
demoteNearLayer);
|
demoteNearLayer);
|
||||||
NearRadius = nearRadius;
|
NearRadius = nearRadius;
|
||||||
|
|
@ -249,6 +234,12 @@ public sealed class StreamingController
|
||||||
if (nearRadius == NearRadius && farRadius == FarRadius)
|
if (nearRadius == NearRadius && farRadius == FarRadius)
|
||||||
return;
|
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)
|
if (_collapsed || _region is null)
|
||||||
{
|
{
|
||||||
NearRadius = nearRadius;
|
NearRadius = nearRadius;
|
||||||
|
|
@ -348,14 +339,44 @@ public sealed class StreamingController
|
||||||
private void DemoteLandblock(uint id)
|
private void DemoteLandblock(uint id)
|
||||||
{
|
{
|
||||||
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
||||||
_retirements.BeginNearLayer(canonical);
|
_presentation.BeginNearLayerRetirement(canonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AdvanceGeneration()
|
private void AdvanceGeneration()
|
||||||
{
|
{
|
||||||
|
ConvergePendingPublications();
|
||||||
_generation = unchecked(_generation + 1);
|
_generation = unchecked(_generation + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ConvergePendingPublications()
|
||||||
|
{
|
||||||
|
IReadOnlyList<LandblockStreamResult> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
|
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
|
||||||
/// are landblock coordinates (0..255) of the current viewer — the camera
|
/// 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
|
// Presentation-owner failures never roll back spatial state. Resume
|
||||||
// unfinished owner ledgers before accepting replacement publications.
|
// 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);
|
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||||
|
|
||||||
|
|
@ -616,11 +641,12 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
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}");
|
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||||
|
AdvanceGeneration();
|
||||||
_collapsed = true;
|
_collapsed = true;
|
||||||
_collapsedCenter = centerId;
|
_collapsedCenter = centerId;
|
||||||
AdvanceGeneration();
|
|
||||||
_clearPendingLoads?.Invoke();
|
_clearPendingLoads?.Invoke();
|
||||||
_deferredApply.Clear();
|
_deferredApply.Clear();
|
||||||
_region = null;
|
_region = null;
|
||||||
|
|
@ -685,8 +711,8 @@ public sealed class StreamingController
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||||
_collapsed = false;
|
|
||||||
AdvanceGeneration();
|
AdvanceGeneration();
|
||||||
|
_collapsed = false;
|
||||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||||
|
|
||||||
foreach (var id in _state.LoadedLandblockIds)
|
foreach (var id in _state.LoadedLandblockIds)
|
||||||
|
|
@ -715,19 +741,20 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ForceReloadWindow()
|
public void ForceReloadWindow()
|
||||||
{
|
{
|
||||||
_collapsed = false;
|
|
||||||
AdvanceGeneration();
|
AdvanceGeneration();
|
||||||
|
_collapsed = false;
|
||||||
_clearPendingLoads?.Invoke();
|
_clearPendingLoads?.Invoke();
|
||||||
_deferredApply.Clear();
|
_deferredApply.Clear();
|
||||||
// Commit the old region boundary before any presentation owner is
|
// 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;
|
_region = null;
|
||||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||||||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
||||||
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
|
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
|
||||||
foreach (var id in ids)
|
foreach (var id in ids)
|
||||||
_retirements.BeginFull(id);
|
_presentation.BeginFullRetirement(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -757,18 +784,41 @@ public sealed class StreamingController
|
||||||
{
|
{
|
||||||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||||
if (chunk.Count == 0) break;
|
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
|
// Reject obsolete hard-window work before it can occupy the
|
||||||
// metered deferred queue. ApplyResult repeats this invariant
|
// metered deferred queue. ApplyResult repeats this invariant
|
||||||
// for direct callers and future drain paths.
|
// for direct callers and future drain paths.
|
||||||
if (IsStaleGeneration(result))
|
if (IsStaleGeneration(result)
|
||||||
|
&& !_presentation.HasPendingPublication(result))
|
||||||
continue;
|
continue;
|
||||||
if (IsPublicationBlockedByRetirement(result))
|
if (IsPublicationBlockedByRetirement(result))
|
||||||
_deferredApply.Add(result);
|
_deferredApply.Add(result);
|
||||||
else if (result is LandblockStreamResult.Unloaded
|
else if (result is LandblockStreamResult.Unloaded
|
||||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
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
|
else
|
||||||
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
||||||
}
|
}
|
||||||
|
|
@ -798,10 +848,25 @@ public sealed class StreamingController
|
||||||
// Advance read only after the apply commits. If it throws, the
|
// Advance read only after the apply commits. If it throws, the
|
||||||
// finally block removes prior committed entries but retains this
|
// finally block removes prior committed entries but retains this
|
||||||
// exact result and the untouched tail for retry.
|
// exact result and the untouched tail for retry.
|
||||||
|
try
|
||||||
|
{
|
||||||
ApplyResult(result);
|
ApplyResult(result);
|
||||||
read++;
|
read++;
|
||||||
applied++;
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -821,6 +886,13 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyResult(LandblockStreamResult result)
|
private void ApplyResult(LandblockStreamResult result)
|
||||||
{
|
{
|
||||||
|
if (_presentation.HasPendingPublication(result))
|
||||||
|
{
|
||||||
|
_presentation.ResumePublication(result);
|
||||||
|
ReconcileCompletedPendingPublication(result.LandblockId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (IsStaleGeneration(result))
|
if (IsStaleGeneration(result))
|
||||||
{
|
{
|
||||||
// A worker can finish one old load/unload after a hard recenter.
|
// 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
|
bool isNearCompletion = result is LandblockStreamResult.Promoted
|
||||||
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
|
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
|
// Normal recenter can enqueue a second high-priority Near job
|
||||||
// while an earlier one is already in flight. The streamer
|
// while an earlier one is already in flight. The streamer
|
||||||
|
|
@ -878,10 +952,10 @@ public sealed class StreamingController
|
||||||
switch (result)
|
switch (result)
|
||||||
{
|
{
|
||||||
case LandblockStreamResult.Loaded loaded:
|
case LandblockStreamResult.Loaded loaded:
|
||||||
ApplyAsFar(loaded.Build, loaded.MeshData);
|
_presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData);
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Promoted promoted:
|
case LandblockStreamResult.Promoted promoted:
|
||||||
ApplyAsFar(promoted.Build, promoted.MeshData);
|
_presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -900,16 +974,7 @@ public sealed class StreamingController
|
||||||
// layer that now owns the landblock.
|
// layer that now owns the landblock.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_applyTerrain(loaded.Build, loaded.MeshData);
|
_presentation.PublishLoaded(loaded);
|
||||||
_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);
|
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Promoted promoted:
|
case LandblockStreamResult.Promoted promoted:
|
||||||
// PromoteToNear carries a complete build and mesh because the
|
// 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
|
// that Far job never started, publish this as the real Near
|
||||||
// landblock; if the base is already resident, merge only the
|
// landblock; if the base is already resident, merge only the
|
||||||
// Near layer so existing live projections retain identity.
|
// Near layer so existing live projections retain identity.
|
||||||
_applyTerrain(promoted.Build, promoted.MeshData);
|
_presentation.PublishPromoted(
|
||||||
var promotedRenderIds =
|
promoted,
|
||||||
promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
|
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
|
||||||
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);
|
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Unloaded unloaded:
|
case LandblockStreamResult.Unloaded unloaded:
|
||||||
_retirements.BeginFull(unloaded.LandblockId);
|
_presentation.BeginFullRetirement(unloaded.LandblockId);
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Failed failed:
|
case LandblockStreamResult.Failed failed:
|
||||||
Console.WriteLine(
|
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;
|
if (_region is null
|
||||||
var farLandblock = new LoadedLandblock(
|
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
|
||||||
completed.LandblockId,
|
{
|
||||||
completed.Heightmap,
|
if (_state.IsLoaded(landblockId))
|
||||||
Array.Empty<WorldEntity>(),
|
_presentation.BeginFullRetirement(landblockId);
|
||||||
PhysicsDatBundle.Empty);
|
return;
|
||||||
var farBuild = new LandblockBuild(farLandblock);
|
|
||||||
|
|
||||||
_applyTerrain(farBuild, meshData);
|
|
||||||
_state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far);
|
|
||||||
_onLandblockLoaded?.Invoke(farLandblock.LandblockId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureEnvCellMeshesAfterPin(LandblockBuild build)
|
if (desiredTier == LandblockStreamTier.Far
|
||||||
|
&& _state.IsNearTier(landblockId))
|
||||||
{
|
{
|
||||||
if (build.EnvCells is { Shells.Length: > 0 } envCells)
|
_presentation.BeginNearLayerRetirement(landblockId);
|
||||||
_ensureEnvCellMeshes?.Invoke(envCells);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsStaleGeneration(LandblockStreamResult result) =>
|
private bool IsStaleGeneration(LandblockStreamResult result) =>
|
||||||
|
|
@ -978,7 +1023,7 @@ public sealed class StreamingController
|
||||||
|
|
||||||
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
|
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
|
||||||
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
|
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
|
||||||
&& _retirements.IsPending(result.LandblockId);
|
&& _presentation.IsRetirementPending(result.LandblockId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the landblock id associated with <paramref name="result"/>.
|
/// Returns the landblock id associated with <paramref name="result"/>.
|
||||||
|
|
|
||||||
|
|
@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
var meshes = new RecordingMeshAdapter(calls);
|
||||||
|
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||||
|
state.AddLandblock(
|
||||||
|
new LoadedLandblock(
|
||||||
|
landblockId,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>(),
|
||||||
|
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<string>();
|
||||||
|
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<InvalidOperationException>(() => 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<string>();
|
||||||
|
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<InvalidOperationException>(() => 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<LandblockStreamResult>();
|
||||||
|
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<InvalidOperationException>(() => 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<WorldEntity>()),
|
||||||
|
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<WorldEntity>()));
|
||||||
|
var outbox = new Queue<LandblockStreamResult>(
|
||||||
|
[
|
||||||
|
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<string>();
|
||||||
|
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<InvalidOperationException>(() => 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<LandblockStreamResult>();
|
||||||
|
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<InvalidOperationException>(() => 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<string>();
|
||||||
|
var state = new GpuWorldState(new LandblockSpawnAdapter(
|
||||||
|
new RecordingMeshAdapter(calls)));
|
||||||
|
var outbox = new Queue<LandblockStreamResult>();
|
||||||
|
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<InvalidOperationException>(() => 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<StreamingMutationException>(() => 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<LandblockStreamResult>(
|
||||||
|
[
|
||||||
|
new LandblockStreamResult.Loaded(
|
||||||
|
failedId,
|
||||||
|
LandblockStreamTier.Near,
|
||||||
|
new LandblockBuild(new LoadedLandblock(
|
||||||
|
failedId,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>())),
|
||||||
|
EmptyMesh()),
|
||||||
|
new LandblockStreamResult.Loaded(
|
||||||
|
tailId,
|
||||||
|
LandblockStreamTier.Near,
|
||||||
|
new LandblockBuild(new LoadedLandblock(
|
||||||
|
tailId,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>())),
|
||||||
|
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<StreamingMutationException>(() => 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<WorldEntity>()));
|
||||||
|
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<ushort>.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<LoadedCell>(),
|
||||||
|
[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<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static LandblockMeshData EmptyMesh() => new(
|
||||||
|
Array.Empty<TerrainVertex>(),
|
||||||
|
Array.Empty<uint>());
|
||||||
|
|
||||||
|
private static IReadOnlyList<LandblockStreamResult> Drain(
|
||||||
|
Queue<LandblockStreamResult> queue,
|
||||||
|
int max)
|
||||||
|
{
|
||||||
|
var drained = new List<LandblockStreamResult>(max);
|
||||||
|
while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result))
|
||||||
|
drained.Add(result);
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingMeshAdapter(List<string> 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue