perf(streaming): quiesce retired generations and budget teardown
Publish the retail blocking-for-cells edge before deferred recenter work, freeze old-world presentation/simulation/audio, and advance full-window retirement from exact metered entity and owner cursors. This removes synchronous portal teardown without allowing retained owners to remain observable.
This commit is contained in:
parent
b8f6317fe1
commit
bb16f74fd4
38 changed files with 1691 additions and 170 deletions
|
|
@ -56,6 +56,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
{
|
||||
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
|
||||
private readonly EntityScriptActivator? _entityScriptActivator;
|
||||
private readonly IWorldGenerationAvailability _availability;
|
||||
|
||||
/// <summary>
|
||||
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
|
||||
|
|
@ -72,11 +73,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
public GpuWorldState(
|
||||
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
||||
System.Action<uint>? onLandblockUnloaded = null,
|
||||
EntityScriptActivator? entityScriptActivator = null)
|
||||
EntityScriptActivator? entityScriptActivator = null,
|
||||
IWorldGenerationAvailability? availability = null)
|
||||
{
|
||||
_wbSpawnAdapter = wbSpawnAdapter;
|
||||
_onLandblockUnloaded = onLandblockUnloaded;
|
||||
_entityScriptActivator = entityScriptActivator;
|
||||
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||
}
|
||||
|
||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||
|
|
@ -151,7 +154,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
_loaded.ContainsKey(landblockId)
|
||||
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
|
||||
public bool IsLiveEntityVisible(uint serverGuid) =>
|
||||
serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(serverGuid);
|
||||
_availability.IsWorldAvailable
|
||||
&& serverGuid != 0
|
||||
&& _visibleLiveProjectionCounts.ContainsKey(serverGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Try to grab the loaded record for a landblock — useful for callers
|
||||
|
|
@ -217,6 +222,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
{
|
||||
get
|
||||
{
|
||||
if (!_availability.IsWorldAvailable)
|
||||
yield break;
|
||||
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
|
|
@ -232,6 +240,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
|
||||
bool includeAnimatedIndex)
|
||||
{
|
||||
if (!_availability.IsWorldAvailable)
|
||||
yield break;
|
||||
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
IReadOnlyDictionary<uint, WorldEntity>? byId = null;
|
||||
|
|
@ -299,6 +310,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
|
||||
destination.Clear();
|
||||
if (!_availability.IsWorldAvailable)
|
||||
return;
|
||||
|
||||
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
|
||||
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);
|
||||
|
|
|
|||
|
|
@ -141,8 +141,9 @@ public sealed class LandblockPresentationPipeline
|
|||
}
|
||||
_onLandblockLoaded = onLandblockLoaded;
|
||||
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
||||
_retirements = new LandblockRetirementCoordinator(
|
||||
_retirements = LandblockRetirementCoordinator.CreateBudgeted(
|
||||
state,
|
||||
retirementOwner.AdvanceOne,
|
||||
retirementOwner.Advance);
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +163,7 @@ public sealed class LandblockPresentationPipeline
|
|||
_staticPublisher?.Diagnostics ?? default);
|
||||
|
||||
public int PendingRetirementCount => _retirements.PendingCount;
|
||||
internal bool UsesBudgetedRetirementSteps => _retirements.UsesBudgetedSteps;
|
||||
public int PendingPublicationCount => _publications.Count;
|
||||
|
||||
internal bool MatchesState(GpuWorldState state) =>
|
||||
|
|
@ -189,10 +191,27 @@ public sealed class LandblockPresentationPipeline
|
|||
|
||||
public void AdvanceRetirements() => _retirements.Advance();
|
||||
|
||||
public void BeginFullRetirement(uint landblockId) =>
|
||||
public void AdvanceRetirements(StreamingWorkMeter meter) =>
|
||||
_retirements.Advance(meter);
|
||||
|
||||
public void BeginFullRetirement(uint landblockId)
|
||||
{
|
||||
_retirements.BeginFull(landblockId);
|
||||
if (_retirements.UsesBudgetedSteps)
|
||||
_retirements.Advance();
|
||||
}
|
||||
|
||||
public void BeginNearLayerRetirement(uint landblockId)
|
||||
{
|
||||
_retirements.BeginNearLayer(landblockId);
|
||||
if (_retirements.UsesBudgetedSteps)
|
||||
_retirements.Advance();
|
||||
}
|
||||
|
||||
internal void EnqueueFullRetirement(uint landblockId) =>
|
||||
_retirements.BeginFull(landblockId);
|
||||
|
||||
public void BeginNearLayerRetirement(uint landblockId) =>
|
||||
internal void EnqueueNearLayerRetirement(uint landblockId) =>
|
||||
_retirements.BeginNearLayer(landblockId);
|
||||
|
||||
public void PublishLoaded(LandblockStreamResult.Loaded loaded)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,58 @@ public sealed class LandblockPresentationRetirementOwner
|
|||
() => _render.RemoveEnvironmentCells(ticket.LandblockId));
|
||||
}
|
||||
|
||||
internal LandblockRetirementOperationResult AdvanceOne(
|
||||
LandblockRetirementTicket ticket)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ticket);
|
||||
bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
|
||||
return ticket.NextIncompleteStage switch
|
||||
{
|
||||
LandblockRetirementStage.EntityLighting =>
|
||||
ticket.RunEntityStep(
|
||||
LandblockRetirementStage.EntityLighting,
|
||||
entity => !staticOnly || entity.ServerGuid == 0,
|
||||
RemoveLighting),
|
||||
LandblockRetirementStage.EntityTranslucency =>
|
||||
ticket.RunEntityStep(
|
||||
LandblockRetirementStage.EntityTranslucency,
|
||||
entity => !staticOnly || entity.ServerGuid == 0,
|
||||
RemoveTranslucency),
|
||||
LandblockRetirementStage.PluginProjection =>
|
||||
ticket.RunEntityStep(
|
||||
LandblockRetirementStage.PluginProjection,
|
||||
static entity => entity.ServerGuid == 0,
|
||||
_staticPresentation.RemovePluginProjection),
|
||||
LandblockRetirementStage.Terrain =>
|
||||
ticket.RunOnceStep(
|
||||
LandblockRetirementStage.Terrain,
|
||||
() => _render.RemoveTerrain(ticket.LandblockId)),
|
||||
LandblockRetirementStage.Physics =>
|
||||
ticket.RunOnceStep(
|
||||
LandblockRetirementStage.Physics,
|
||||
() =>
|
||||
{
|
||||
if (ticket.Kind == LandblockRetirementKind.Full)
|
||||
_physics.RemoveLandblock(ticket.LandblockId);
|
||||
else
|
||||
_physics.DemoteToTerrain(ticket.LandblockId);
|
||||
}),
|
||||
LandblockRetirementStage.CellVisibility =>
|
||||
ticket.RunOnceStep(
|
||||
LandblockRetirementStage.CellVisibility,
|
||||
() => _render.RemoveCellVisibility(ticket.LandblockId)),
|
||||
LandblockRetirementStage.BuildingRegistry =>
|
||||
ticket.RunOnceStep(
|
||||
LandblockRetirementStage.BuildingRegistry,
|
||||
() => _render.RemoveBuildingRegistry(ticket.LandblockId)),
|
||||
LandblockRetirementStage.EnvironmentCells =>
|
||||
ticket.RunOnceStep(
|
||||
LandblockRetirementStage.EnvironmentCells,
|
||||
() => _render.RemoveEnvironmentCells(ticket.LandblockId)),
|
||||
_ => LandblockRetirementOperationResult.NoWork,
|
||||
};
|
||||
}
|
||||
|
||||
private void RemoveLighting(WorldEntity entity)
|
||||
{
|
||||
if (entity.ServerGuid == 0)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ public enum LandblockRetirementStage : ushort
|
|||
LegacyPresentation = 1 << 11,
|
||||
}
|
||||
|
||||
internal enum LandblockRetirementOperationResult : byte
|
||||
{
|
||||
NoWork,
|
||||
Progressed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One retryable landblock-retirement ledger. Successful owner operations are
|
||||
/// committed once; a later attempt resumes at the exact failed owner/entity.
|
||||
|
|
@ -70,6 +77,29 @@ public sealed class LandblockRetirementTicket
|
|||
}
|
||||
}
|
||||
|
||||
internal LandblockRetirementOperationResult RunOnceStep(
|
||||
LandblockRetirementStage stage,
|
||||
Action operation)
|
||||
{
|
||||
ValidateSingleStage(stage);
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
if ((CompletedStages & stage) != 0)
|
||||
return LandblockRetirementOperationResult.NoWork;
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
CompletedStages |= stage;
|
||||
_failures.Remove(stage);
|
||||
return LandblockRetirementOperationResult.Progressed;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_failures[stage] = error;
|
||||
return LandblockRetirementOperationResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RunForEachEntity(
|
||||
LandblockRetirementStage stage,
|
||||
Func<WorldEntity, bool> predicate,
|
||||
|
|
@ -109,6 +139,74 @@ public sealed class LandblockRetirementTicket
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances exactly one entity cursor, including predicate misses. This
|
||||
/// makes both owner work and the scan needed to find it visible to the
|
||||
/// frame budget.
|
||||
/// </summary>
|
||||
internal LandblockRetirementOperationResult RunEntityStep(
|
||||
LandblockRetirementStage stage,
|
||||
Func<WorldEntity, bool> predicate,
|
||||
Action<WorldEntity> operation)
|
||||
{
|
||||
ValidateSingleStage(stage);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
if ((CompletedStages & stage) != 0)
|
||||
return LandblockRetirementOperationResult.NoWork;
|
||||
|
||||
_entityCursors.TryGetValue(stage, out int cursor);
|
||||
if (cursor >= Entities.Count)
|
||||
{
|
||||
CompletedStages |= stage;
|
||||
_entityCursors.Remove(stage);
|
||||
_failures.Remove(stage);
|
||||
return LandblockRetirementOperationResult.Progressed;
|
||||
}
|
||||
|
||||
WorldEntity entity = Entities[cursor];
|
||||
if (predicate(entity))
|
||||
{
|
||||
try
|
||||
{
|
||||
operation(entity);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_entityCursors[stage] = cursor;
|
||||
_failures[stage] = error;
|
||||
return LandblockRetirementOperationResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
cursor++;
|
||||
if (cursor == Entities.Count)
|
||||
{
|
||||
CompletedStages |= stage;
|
||||
_entityCursors.Remove(stage);
|
||||
_failures.Remove(stage);
|
||||
}
|
||||
else
|
||||
{
|
||||
_entityCursors[stage] = cursor;
|
||||
}
|
||||
|
||||
return LandblockRetirementOperationResult.Progressed;
|
||||
}
|
||||
|
||||
internal LandblockRetirementStage NextIncompleteStage
|
||||
{
|
||||
get
|
||||
{
|
||||
ushort remaining = (ushort)(RequiredStages & ~CompletedStages);
|
||||
if (remaining == 0)
|
||||
return LandblockRetirementStage.None;
|
||||
|
||||
ushort lowest = (ushort)(remaining & (ushort)-(short)remaining);
|
||||
return (LandblockRetirementStage)lowest;
|
||||
}
|
||||
}
|
||||
|
||||
internal void BeginAttempt() => _callbackFailure = null;
|
||||
|
||||
internal void RecordCallbackFailure(Exception error) =>
|
||||
|
|
@ -170,9 +268,13 @@ public sealed class LandblockRetirementCoordinator
|
|||
|
||||
private readonly GpuWorldState _state;
|
||||
private readonly Action<LandblockRetirementTicket> _advancePresentation;
|
||||
private readonly Func<
|
||||
LandblockRetirementTicket,
|
||||
LandblockRetirementOperationResult>? _advancePresentationStep;
|
||||
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
|
||||
_requiredPresentationStages;
|
||||
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
|
||||
private readonly Queue<LandblockRetirementTicket> _pendingOrder = new();
|
||||
private readonly List<uint> _completedIds = new();
|
||||
private readonly Queue<Request> _requests = new();
|
||||
private readonly HashSet<Request> _seenDuringDrain = new();
|
||||
|
|
@ -187,12 +289,30 @@ public sealed class LandblockRetirementCoordinator
|
|||
ArgumentNullException.ThrowIfNull(advancePresentation);
|
||||
_state = state;
|
||||
_advancePresentation = advancePresentation;
|
||||
_advancePresentationStep = null;
|
||||
_requiredPresentationStages = requiredPresentationStages
|
||||
?? (kind => kind == LandblockRetirementKind.Full
|
||||
? ProductionPresentationStages
|
||||
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
|
||||
}
|
||||
|
||||
private LandblockRetirementCoordinator(
|
||||
GpuWorldState state,
|
||||
Func<LandblockRetirementTicket, LandblockRetirementOperationResult>
|
||||
advancePresentationStep,
|
||||
Action<LandblockRetirementTicket> advancePresentation,
|
||||
Func<LandblockRetirementKind, LandblockRetirementStage>
|
||||
requiredPresentationStages)
|
||||
{
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
_advancePresentationStep = advancePresentationStep
|
||||
?? throw new ArgumentNullException(nameof(advancePresentationStep));
|
||||
_advancePresentation = advancePresentation
|
||||
?? throw new ArgumentNullException(nameof(advancePresentation));
|
||||
_requiredPresentationStages = requiredPresentationStages
|
||||
?? throw new ArgumentNullException(nameof(requiredPresentationStages));
|
||||
}
|
||||
|
||||
public int PendingCount { get; private set; }
|
||||
|
||||
public bool IsPending(uint landblockId) =>
|
||||
|
|
@ -200,6 +320,7 @@ public sealed class LandblockRetirementCoordinator
|
|||
|
||||
internal bool MatchesState(GpuWorldState state) =>
|
||||
ReferenceEquals(_state, state);
|
||||
internal bool UsesBudgetedSteps => _advancePresentationStep is not null;
|
||||
|
||||
public void BeginFull(uint landblockId) => EnqueueRequest(
|
||||
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
|
||||
|
|
@ -210,8 +331,81 @@ public sealed class LandblockRetirementCoordinator
|
|||
public void Advance() => EnqueueRequest(
|
||||
new Request(RequestKind.Advance, 0u));
|
||||
|
||||
internal static LandblockRetirementCoordinator CreateBudgeted(
|
||||
GpuWorldState state,
|
||||
Func<LandblockRetirementTicket, LandblockRetirementOperationResult>
|
||||
advancePresentationStep,
|
||||
Action<LandblockRetirementTicket> advancePresentation)
|
||||
{
|
||||
return new LandblockRetirementCoordinator(
|
||||
state,
|
||||
advancePresentationStep,
|
||||
advancePresentation,
|
||||
kind => kind == LandblockRetirementKind.Full
|
||||
? ProductionPresentationStages
|
||||
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances stable FIFO retirement work under the caller's one frame meter.
|
||||
/// Every reservation maps to exactly one entity cursor or owner stage.
|
||||
/// </summary>
|
||||
public void Advance(StreamingWorkMeter meter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meter);
|
||||
if (_advancePresentationStep is null)
|
||||
{
|
||||
Advance();
|
||||
return;
|
||||
}
|
||||
|
||||
while (_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
|
||||
{
|
||||
if (ticket.IsComplete)
|
||||
{
|
||||
RemoveCompletedTicket(ticket);
|
||||
continue;
|
||||
}
|
||||
|
||||
LandblockRetirementStage stage = ticket.NextIncompleteStage;
|
||||
if (stage == LandblockRetirementStage.None)
|
||||
throw new InvalidOperationException(
|
||||
"An incomplete retirement ticket has no unfinished stage.");
|
||||
|
||||
StreamingWorkCost cost = new(
|
||||
EntityOperations: 1,
|
||||
GlRetireOperations:
|
||||
stage is LandblockRetirementStage.MeshReferences
|
||||
or LandblockRetirementStage.Terrain
|
||||
? 1
|
||||
: 0);
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
cost,
|
||||
$"retire-{stage}-0x{ticket.LandblockId:X8}");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return;
|
||||
|
||||
LandblockRetirementOperationResult result = AdvanceTicketOne(ticket);
|
||||
if (result == LandblockRetirementOperationResult.Failed)
|
||||
{
|
||||
meter.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
meter.Complete();
|
||||
if (ticket.IsComplete)
|
||||
RemoveCompletedTicket(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
private void AdvanceCore()
|
||||
{
|
||||
if (_advancePresentationStep is not null)
|
||||
{
|
||||
AdvanceBudgetedEagerAttempt();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pending.Count == 0)
|
||||
return;
|
||||
|
||||
|
|
@ -278,6 +472,9 @@ public sealed class LandblockRetirementCoordinator
|
|||
if (existing[i].Kind == kind)
|
||||
{
|
||||
LandblockRetirementTicket existingTicket = existing[i];
|
||||
if (_advancePresentationStep is not null)
|
||||
return;
|
||||
|
||||
AdvanceTicket(existingTicket);
|
||||
if (existingTicket.IsComplete)
|
||||
{
|
||||
|
|
@ -331,14 +528,19 @@ public sealed class LandblockRetirementCoordinator
|
|||
}
|
||||
existing.Add(ticket);
|
||||
PendingCount++;
|
||||
if (_advancePresentationStep is not null)
|
||||
_pendingOrder.Enqueue(ticket);
|
||||
|
||||
AdvanceTicket(ticket);
|
||||
if (ticket.IsComplete)
|
||||
if (_advancePresentationStep is null)
|
||||
{
|
||||
existing.Remove(ticket);
|
||||
PendingCount--;
|
||||
if (existing.Count == 0)
|
||||
_pending.Remove(canonical);
|
||||
AdvanceTicket(ticket);
|
||||
if (ticket.IsComplete)
|
||||
{
|
||||
existing.Remove(ticket);
|
||||
PendingCount--;
|
||||
if (existing.Count == 0)
|
||||
_pending.Remove(canonical);
|
||||
}
|
||||
}
|
||||
// Visibility is a delivered-by-attempt broadcast: every subscriber is
|
||||
// invoked and failures are aggregated, but failed subscribers are not
|
||||
|
|
@ -380,6 +582,96 @@ public sealed class LandblockRetirementCoordinator
|
|||
}
|
||||
}
|
||||
|
||||
private void AdvanceBudgetedEagerAttempt()
|
||||
{
|
||||
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
|
||||
return;
|
||||
|
||||
if (ticket.IsComplete)
|
||||
{
|
||||
RemoveCompletedTicket(ticket);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibility/debug callers explicitly asking for an unmetered
|
||||
// advance retain the former one-attempt behavior: every independent
|
||||
// stage is attempted once, while successful stages/entities remain
|
||||
// committed in the same exact ticket. Production calls the metered
|
||||
// overload and never enters this path.
|
||||
AdvanceTicket(ticket);
|
||||
if (ticket.IsComplete)
|
||||
RemoveCompletedTicket(ticket);
|
||||
}
|
||||
|
||||
private LandblockRetirementOperationResult AdvanceTicketOne(
|
||||
LandblockRetirementTicket ticket)
|
||||
{
|
||||
ticket.BeginAttempt();
|
||||
LandblockRetirementStage stage = ticket.NextIncompleteStage;
|
||||
LandblockRetirementOperationResult result;
|
||||
try
|
||||
{
|
||||
result = stage switch
|
||||
{
|
||||
LandblockRetirementStage.MeshReferences =>
|
||||
ticket.RunOnceStep(
|
||||
stage,
|
||||
() => _state.ReleaseLandblockMeshReferences(
|
||||
ticket.LandblockId)),
|
||||
LandblockRetirementStage.StaticScripts =>
|
||||
ticket.RunEntityStep(
|
||||
stage,
|
||||
static entity => entity.ServerGuid == 0,
|
||||
_state.StopStaticEntityScript),
|
||||
LandblockRetirementStage.Classification =>
|
||||
ticket.RunOnceStep(
|
||||
stage,
|
||||
() => _state.InvalidateLandblockClassification(
|
||||
ticket.LandblockId)),
|
||||
LandblockRetirementStage.None =>
|
||||
LandblockRetirementOperationResult.NoWork,
|
||||
_ => _advancePresentationStep!(ticket),
|
||||
};
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ticket.RecordCallbackFailure(error);
|
||||
result = LandblockRetirementOperationResult.Failed;
|
||||
}
|
||||
|
||||
if (ticket.TryTakeNewFailure(out Exception? failure))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"streaming: retirement for 0x{ticket.LandblockId:X8} " +
|
||||
$"({ticket.Kind}) remains pending: {failure}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void RemoveCompletedTicket(LandblockRetirementTicket ticket)
|
||||
{
|
||||
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? head)
|
||||
|| !ReferenceEquals(head, ticket))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Retirement FIFO completion did not match its active head.");
|
||||
}
|
||||
_pendingOrder.Dequeue();
|
||||
|
||||
uint id = Canonicalize(ticket.LandblockId);
|
||||
if (!_pending.TryGetValue(id, out List<LandblockRetirementTicket>? tickets)
|
||||
|| !tickets.Remove(ticket))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Completed retirement ticket is missing from its owner ledger.");
|
||||
}
|
||||
|
||||
PendingCount--;
|
||||
if (tickets.Count == 0)
|
||||
_pending.Remove(id);
|
||||
}
|
||||
|
||||
private static uint Canonicalize(uint id) =>
|
||||
(id & 0xFFFF0000u) | 0xFFFFu;
|
||||
|
||||
|
|
|
|||
|
|
@ -674,6 +674,13 @@ internal sealed class LocalPlayerTeleportController
|
|||
+ $"new lb=({landblockX},{landblockY}) "
|
||||
+ $"dist={Vector3.Distance(translated, controller.Position):F1}");
|
||||
|
||||
// Retail SmartBox enters blocking_for_cells before old-world object,
|
||||
// physics, landscape, and ambient owners may advance again. Begin the
|
||||
// reveal generation before a recenter can start detaching that world.
|
||||
_worldReveal.Begin(WorldRevealKind.Portal);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
|
||||
Vector3 worldPosition;
|
||||
if (transition.ChangesStreamingCenter)
|
||||
{
|
||||
|
|
@ -707,9 +714,6 @@ internal sealed class LocalPlayerTeleportController
|
|||
_pendingCell = position.LandblockId;
|
||||
_holdSeconds = 0f;
|
||||
_forced = false;
|
||||
_worldReveal.Begin(WorldRevealKind.Portal);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
_streaming.SetPriority(
|
||||
StreamingRegion.EncodeLandblockId(landblockX, landblockY),
|
||||
NearRingRadius);
|
||||
|
|
@ -746,7 +750,7 @@ internal sealed class LocalPlayerTeleportController
|
|||
_holdSeconds = 0f;
|
||||
_forced = false;
|
||||
|
||||
bool originConverged = _streaming.ResetRecenter(clearSession);
|
||||
_streaming.ResetRecenter(clearSession);
|
||||
if (_lifetimeGeneration != generation)
|
||||
return generation;
|
||||
|
||||
|
|
@ -763,12 +767,6 @@ internal sealed class LocalPlayerTeleportController
|
|||
|
||||
_streaming.ClearPriority();
|
||||
|
||||
if (clearSession && !originConverged)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The ending session's streaming-origin retirement has not converged.");
|
||||
}
|
||||
|
||||
return generation;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,21 @@ internal sealed class StreamingCompletionQueue
|
|||
_retainedCpuBytes = 0;
|
||||
}
|
||||
|
||||
public bool TryRemoveOne()
|
||||
{
|
||||
for (int priority = 0; priority < _queues.Length; priority++)
|
||||
{
|
||||
Queue<StreamingQueuedCompletion> queue = _queues[priority];
|
||||
if (!queue.TryDequeue(out StreamingQueuedCompletion completion))
|
||||
continue;
|
||||
|
||||
Release(completion);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public StreamingCompletionQueueSnapshot CaptureSnapshot()
|
||||
{
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
|
|
|
|||
|
|
@ -29,11 +29,24 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
public List<uint>? ResidentIds;
|
||||
public int RetirementCursor;
|
||||
public bool PreparationCommitted;
|
||||
public IEnumerator<uint>? ResidentEnumerator;
|
||||
public (int X, int Y, bool IsSealedDungeon)? Destination;
|
||||
public bool DestinationConfigured;
|
||||
public bool DestinationLoadEnqueued;
|
||||
}
|
||||
|
||||
private sealed class FullWindowRetirement
|
||||
{
|
||||
public bool GenerationAdvanced;
|
||||
public bool PendingLoadsCleared;
|
||||
public bool CompletionQueueCleared;
|
||||
public bool RegionCleared;
|
||||
public List<uint>? ResidentIds;
|
||||
public IEnumerator<uint>? ResidentEnumerator;
|
||||
public int RetirementCursor;
|
||||
public bool PreparationCommitted;
|
||||
}
|
||||
|
||||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||
private readonly Action<uint, ulong> _enqueueUnload;
|
||||
private readonly ILandblockCompletionSource _completionSource;
|
||||
|
|
@ -55,6 +68,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
private bool _advancingRadiiReconfiguration;
|
||||
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
|
||||
private OriginRecenterRetirement? _originRecenterRetirement;
|
||||
private FullWindowRetirement? _fullWindowRetirement;
|
||||
private bool _advancingOriginRecenter;
|
||||
// Hard streaming boundaries advance this token. Worker completions carry
|
||||
// the generation captured at enqueue time, so an old overlapping load or
|
||||
|
|
@ -459,7 +473,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
private void DemoteLandblock(uint id)
|
||||
{
|
||||
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
||||
_presentation.BeginNearLayerRetirement(canonical);
|
||||
_presentation.EnqueueNearLayerRetirement(canonical);
|
||||
}
|
||||
|
||||
private bool AdvanceGeneration()
|
||||
|
|
@ -530,9 +544,37 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_activeWorkMeter = meter;
|
||||
try
|
||||
{
|
||||
if (_fullWindowRetirement is not null)
|
||||
{
|
||||
bool retirementWasPending =
|
||||
_presentation.PendingRetirementCount != 0;
|
||||
TryAdvanceFullWindowRetirement(meter);
|
||||
if (_presentation.UsesBudgetedRetirementSteps
|
||||
|| retirementWasPending)
|
||||
{
|
||||
_presentation.AdvanceRetirements(meter);
|
||||
}
|
||||
if (_fullWindowRetirement is
|
||||
{
|
||||
PreparationCommitted: true,
|
||||
}
|
||||
&& _presentation.PendingRetirementCount == 0)
|
||||
{
|
||||
_fullWindowRetirement = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_originRecenterRetirement is not null)
|
||||
{
|
||||
_presentation.AdvanceRetirements();
|
||||
bool retirementWasPending =
|
||||
_presentation.PendingRetirementCount != 0;
|
||||
TryAdvanceOriginRecenterPreparation(meter);
|
||||
if (_presentation.UsesBudgetedRetirementSteps
|
||||
|| retirementWasPending)
|
||||
{
|
||||
_presentation.AdvanceRetirements(meter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -544,12 +586,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
|
||||
}
|
||||
|
||||
// Presentation-owner failures never roll back spatial state. Resume
|
||||
// unfinished owner ledgers before accepting replacement publications.
|
||||
_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.
|
||||
bool retirementWasPendingAtFrameStart =
|
||||
_presentation.PendingRetirementCount != 0;
|
||||
_presentation.AdvanceRetirements(meter);
|
||||
if (!ConvergePendingPublications())
|
||||
return;
|
||||
|
||||
|
|
@ -584,6 +626,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
|
||||
DrainAndApply();
|
||||
// Retirement is cleanup after immediate spatial withdrawal. Spend
|
||||
// remaining capacity only after visible/destination publication
|
||||
// work has had access to this frame's shared meter.
|
||||
if (_presentation.UsesBudgetedRetirementSteps
|
||||
&& !retirementWasPendingAtFrameStart)
|
||||
_presentation.AdvanceRetirements(meter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -882,10 +930,13 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a reload of the current streaming window. Logical residency is
|
||||
/// detached immediately; render, physics, and static-lighting owners then
|
||||
/// converge through the retained presentation-retirement ledger before
|
||||
/// <see cref="NormalTick"/> bootstraps the window again.
|
||||
/// Starts a reload of the current streaming window. The next and later
|
||||
/// <see cref="Tick"/> calls invalidate accepted work, capture and detach
|
||||
/// stable residents, and converge render, physics, and static-lighting
|
||||
/// owners under the shared frame budget before <see cref="NormalTick"/>
|
||||
/// bootstraps the window again. Hard login/portal transitions separately
|
||||
/// publish their immediate observable edge through
|
||||
/// <see cref="WorldGenerationQuiescence"/>.
|
||||
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
|
||||
/// every old-window owner retires while the old coordinate frame is active.
|
||||
/// </summary>
|
||||
|
|
@ -916,10 +967,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
throw new InvalidOperationException(
|
||||
"No streaming-origin recenter transaction is pending.");
|
||||
|
||||
if (!TryAdvanceOriginRecenterPreparation())
|
||||
return false;
|
||||
_presentation.AdvanceRetirements();
|
||||
return _presentation.PendingRetirementCount == 0;
|
||||
return _originRecenterRetirement.PreparationCommitted
|
||||
&& _presentation.PendingRetirementCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1040,8 +1089,9 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
return true;
|
||||
}
|
||||
|
||||
private bool TryAdvanceOriginRecenterPreparation()
|
||||
private bool TryAdvanceOriginRecenterPreparation(StreamingWorkMeter meter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meter);
|
||||
OriginRecenterRetirement transaction = _originRecenterRetirement
|
||||
?? throw new InvalidOperationException(
|
||||
"No streaming-origin recenter transaction is pending.");
|
||||
|
|
@ -1055,58 +1105,159 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
{
|
||||
if (!transaction.RadiiConverged)
|
||||
{
|
||||
if (_pendingRadiiReconfiguration is not null)
|
||||
AdvanceRadiiReconfiguration();
|
||||
if (_pendingRadiiReconfiguration is not null
|
||||
&& !TryRunStreamingWork(
|
||||
meter,
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: Math.Max(
|
||||
1,
|
||||
_pendingRadiiReconfiguration.Mutations.Count)),
|
||||
"recenter-radii-convergence",
|
||||
() =>
|
||||
{
|
||||
AdvanceRadiiReconfiguration();
|
||||
return _pendingRadiiReconfiguration is null;
|
||||
}))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (_pendingRadiiReconfiguration is not null)
|
||||
return false;
|
||||
transaction.RadiiConverged = true;
|
||||
}
|
||||
if (!transaction.GenerationAdvanced)
|
||||
{
|
||||
// AdvanceGeneration meters any retained publication it must
|
||||
// converge through the active frame meter. The token increment
|
||||
// itself is constant-time and must not create a nested meter
|
||||
// reservation around that retry.
|
||||
if (!AdvanceGeneration())
|
||||
return false;
|
||||
transaction.GenerationAdvanced = true;
|
||||
}
|
||||
if (!transaction.PendingLoadsCleared)
|
||||
{
|
||||
try
|
||||
bool ClearPendingLoads()
|
||||
{
|
||||
_clearPendingLoads?.Invoke();
|
||||
transaction.PendingLoadsCleared = true;
|
||||
try
|
||||
{
|
||||
_clearPendingLoads?.Invoke();
|
||||
transaction.PendingLoadsCleared = true;
|
||||
return true;
|
||||
}
|
||||
catch (StreamingMutationException error) when (error.MutationCommitted)
|
||||
{
|
||||
transaction.PendingLoadsCleared = true;
|
||||
Console.WriteLine(
|
||||
$"streaming: committed pending-load clear reported failure: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (StreamingMutationException error) when (error.MutationCommitted)
|
||||
{
|
||||
transaction.PendingLoadsCleared = true;
|
||||
Console.WriteLine(
|
||||
$"streaming: committed pending-load clear reported failure: {error}");
|
||||
|
||||
if (!TryRunStreamingWork(
|
||||
meter,
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"recenter-clear-worker-inbox",
|
||||
ClearPendingLoads))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!transaction.CompletionQueueCleared)
|
||||
{
|
||||
_completionQueue.Clear();
|
||||
while (_completionQueue.Count != 0)
|
||||
{
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"recenter-release-completion");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
if (!_completionQueue.TryRemoveOne())
|
||||
{
|
||||
meter.Fail();
|
||||
throw new InvalidOperationException(
|
||||
"Completion queue count changed during recenter release.");
|
||||
}
|
||||
meter.Complete();
|
||||
}
|
||||
transaction.CompletionQueueCleared = true;
|
||||
}
|
||||
if (!transaction.RegionCleared)
|
||||
{
|
||||
_collapsed = false;
|
||||
_region = null;
|
||||
if (!TryRunStreamingWork(
|
||||
meter,
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"recenter-clear-region",
|
||||
() =>
|
||||
{
|
||||
_collapsed = false;
|
||||
_region = null;
|
||||
return true;
|
||||
}))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
transaction.RegionCleared = true;
|
||||
}
|
||||
if (transaction.ResidentIds is null)
|
||||
{
|
||||
transaction.ResidentIds = new List<uint>(_state.LoadedLandblockIds);
|
||||
FullWindowRetirementCount++;
|
||||
LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
|
||||
transaction.ResidentIds = [];
|
||||
transaction.ResidentEnumerator =
|
||||
_state.LoadedLandblockIds.GetEnumerator();
|
||||
}
|
||||
|
||||
while (transaction.RetirementCursor < transaction.ResidentIds.Count)
|
||||
while (transaction.ResidentEnumerator is { } residentEnumerator)
|
||||
{
|
||||
uint id = transaction.ResidentIds[transaction.RetirementCursor];
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"recenter-capture-resident-id");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
bool moved;
|
||||
try
|
||||
{
|
||||
_presentation.BeginFullRetirement(id);
|
||||
moved = residentEnumerator.MoveNext();
|
||||
if (moved)
|
||||
transaction.ResidentIds.Add(residentEnumerator.Current);
|
||||
else
|
||||
{
|
||||
residentEnumerator.Dispose();
|
||||
transaction.ResidentEnumerator = null;
|
||||
FullWindowRetirementCount++;
|
||||
LastFullWindowRetirementLandblockCount =
|
||||
transaction.ResidentIds.Count;
|
||||
}
|
||||
meter.Complete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
List<uint> residentIds = transaction.ResidentIds
|
||||
?? throw new InvalidOperationException(
|
||||
"Origin-recenter resident capture did not commit.");
|
||||
while (transaction.RetirementCursor < residentIds.Count)
|
||||
{
|
||||
uint id = residentIds[transaction.RetirementCursor];
|
||||
int entityCount = _state.TryGetLandblock(
|
||||
id,
|
||||
out LoadedLandblock? loaded)
|
||||
? loaded!.Entities.Count
|
||||
: 0;
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: Math.Max(1, entityCount)),
|
||||
$"recenter-detach-0x{id:X8}");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
_presentation.EnqueueFullRetirement(id);
|
||||
transaction.RetirementCursor++;
|
||||
meter.Complete();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
@ -1116,6 +1267,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// otherwise retain it for the next frame.
|
||||
if (!_state.IsLoaded(id))
|
||||
transaction.RetirementCursor++;
|
||||
meter.Fail();
|
||||
Console.WriteLine(
|
||||
$"streaming: origin-recenter retirement for 0x{id:X8} " +
|
||||
$"will resume: {error}");
|
||||
|
|
@ -1140,21 +1292,178 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
|
||||
private void BeginFullWindowRetirement()
|
||||
{
|
||||
if (!AdvanceGeneration())
|
||||
return;
|
||||
_collapsed = false;
|
||||
_clearPendingLoads?.Invoke();
|
||||
_completionQueue.Clear();
|
||||
// Commit the old region boundary before any presentation owner is
|
||||
// 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<uint>(_state.LoadedLandblockIds);
|
||||
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
|
||||
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
|
||||
foreach (var id in ids)
|
||||
_presentation.BeginFullRetirement(id);
|
||||
_fullWindowRetirement ??= new FullWindowRetirement();
|
||||
}
|
||||
|
||||
private bool TryAdvanceFullWindowRetirement(StreamingWorkMeter meter)
|
||||
{
|
||||
FullWindowRetirement transaction = _fullWindowRetirement
|
||||
?? throw new InvalidOperationException(
|
||||
"No full-window retirement transaction is pending.");
|
||||
if (transaction.PreparationCommitted)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
if (!transaction.GenerationAdvanced)
|
||||
{
|
||||
if (!AdvanceGeneration())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
transaction.GenerationAdvanced = true;
|
||||
}
|
||||
|
||||
if (!transaction.PendingLoadsCleared)
|
||||
{
|
||||
bool ClearPendingLoads()
|
||||
{
|
||||
try
|
||||
{
|
||||
_clearPendingLoads?.Invoke();
|
||||
transaction.PendingLoadsCleared = true;
|
||||
return true;
|
||||
}
|
||||
catch (StreamingMutationException error) when (error.MutationCommitted)
|
||||
{
|
||||
transaction.PendingLoadsCleared = true;
|
||||
Console.WriteLine(
|
||||
$"streaming: committed reload pending-load clear reported failure: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryRunStreamingWork(
|
||||
meter,
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"reload-clear-worker-inbox",
|
||||
ClearPendingLoads))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!transaction.CompletionQueueCleared)
|
||||
{
|
||||
while (_completionQueue.Count != 0)
|
||||
{
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"reload-release-completion");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
if (!_completionQueue.TryRemoveOne())
|
||||
{
|
||||
meter.Fail();
|
||||
throw new InvalidOperationException(
|
||||
"Completion queue count changed during reload release.");
|
||||
}
|
||||
meter.Complete();
|
||||
}
|
||||
transaction.CompletionQueueCleared = true;
|
||||
}
|
||||
|
||||
if (!transaction.RegionCleared)
|
||||
{
|
||||
if (!TryRunStreamingWork(
|
||||
meter,
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"reload-clear-region",
|
||||
() =>
|
||||
{
|
||||
_collapsed = false;
|
||||
_region = null;
|
||||
return true;
|
||||
}))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
transaction.RegionCleared = true;
|
||||
}
|
||||
|
||||
if (transaction.ResidentIds is null)
|
||||
{
|
||||
transaction.ResidentIds = [];
|
||||
transaction.ResidentEnumerator =
|
||||
_state.LoadedLandblockIds.GetEnumerator();
|
||||
}
|
||||
while (transaction.ResidentEnumerator is { } residentEnumerator)
|
||||
{
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"reload-capture-resident-id");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
bool moved;
|
||||
try
|
||||
{
|
||||
moved = residentEnumerator.MoveNext();
|
||||
if (moved)
|
||||
transaction.ResidentIds.Add(residentEnumerator.Current);
|
||||
else
|
||||
{
|
||||
residentEnumerator.Dispose();
|
||||
transaction.ResidentEnumerator = null;
|
||||
FullWindowRetirementCount++;
|
||||
LastFullWindowRetirementLandblockCount =
|
||||
transaction.ResidentIds.Count;
|
||||
}
|
||||
meter.Complete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
List<uint> residentIds = transaction.ResidentIds
|
||||
?? throw new InvalidOperationException(
|
||||
"Full-window resident capture did not commit.");
|
||||
while (transaction.RetirementCursor < residentIds.Count)
|
||||
{
|
||||
uint id = residentIds[transaction.RetirementCursor];
|
||||
int entityCount = _state.TryGetLandblock(
|
||||
id,
|
||||
out LoadedLandblock? loaded)
|
||||
? loaded!.Entities.Count
|
||||
: 0;
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: Math.Max(1, entityCount)),
|
||||
$"reload-detach-0x{id:X8}");
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
_presentation.EnqueueFullRetirement(id);
|
||||
transaction.RetirementCursor++;
|
||||
meter.Complete();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (!_state.IsLoaded(id))
|
||||
transaction.RetirementCursor++;
|
||||
meter.Fail();
|
||||
Console.WriteLine(
|
||||
$"streaming: full-window retirement for 0x{id:X8} " +
|
||||
$"will resume: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
transaction.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"streaming: full-window preparation will resume: {error}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1209,6 +1518,32 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
}
|
||||
}
|
||||
|
||||
private static bool TryRunStreamingWork(
|
||||
StreamingWorkMeter meter,
|
||||
StreamingWorkCost cost,
|
||||
string stage,
|
||||
Func<bool> operation)
|
||||
{
|
||||
StreamingWorkAdmission admission = meter.TryReserve(cost, stage);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
bool completed = operation();
|
||||
if (completed)
|
||||
meter.Complete();
|
||||
else
|
||||
meter.Fail();
|
||||
return completed;
|
||||
}
|
||||
catch
|
||||
{
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void AdmitCompletions(StreamingWorkMeter meter)
|
||||
{
|
||||
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
|
||||
|
|
@ -1439,7 +1774,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
|
||||
break;
|
||||
case LandblockStreamResult.Unloaded unloaded:
|
||||
_presentation.BeginFullRetirement(unloaded.LandblockId);
|
||||
_presentation.EnqueueFullRetirement(unloaded.LandblockId);
|
||||
break;
|
||||
case LandblockStreamResult.Failed failed:
|
||||
Console.WriteLine(
|
||||
|
|
@ -1458,14 +1793,14 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
|
||||
{
|
||||
if (_state.IsLoaded(landblockId))
|
||||
_presentation.BeginFullRetirement(landblockId);
|
||||
_presentation.EnqueueFullRetirement(landblockId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (desiredTier == LandblockStreamTier.Far
|
||||
&& _state.IsNearTier(landblockId))
|
||||
{
|
||||
_presentation.BeginNearLayerRetirement(landblockId);
|
||||
_presentation.EnqueueNearLayerRetirement(landblockId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
|
|||
public bool IsPending => _pending is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Begins a new recenter transaction and performs its first retirement
|
||||
/// attempt immediately. A teleport replacement must call <see cref="Reset"/>
|
||||
/// before supplying a different destination.
|
||||
/// Begins a new recenter transaction. Its retained preparation and
|
||||
/// retirement cursors advance only from <see cref="StreamingController.Tick"/>
|
||||
/// under that frame's shared work meter. A teleport replacement must call
|
||||
/// <see cref="Reset"/> before supplying a different destination.
|
||||
/// </summary>
|
||||
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
|
||||
{
|
||||
|
|
@ -71,8 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances retained presentation teardown. Returns true only after the
|
||||
/// new origin is committed and destination streaming has been unblocked.
|
||||
/// Observes retained presentation teardown and commits the new origin once
|
||||
/// the streaming frame owner has converged it. Returns true only after the
|
||||
/// destination streaming gate has been released.
|
||||
/// </summary>
|
||||
public bool Advance()
|
||||
{
|
||||
|
|
|
|||
109
src/AcDream.App/Streaming/WorldGenerationQuiescence.cs
Normal file
109
src/AcDream.App/Streaming/WorldGenerationQuiescence.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using AcDream.App.Audio;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only generation gate shared by world simulation and presentation.
|
||||
/// A hard login/portal reveal boundary makes the prior world unavailable
|
||||
/// immediately while its physical owners retire over later frames.
|
||||
/// </summary>
|
||||
public interface IWorldGenerationAvailability
|
||||
{
|
||||
bool IsWorldAvailable { get; }
|
||||
long QuiescedGeneration { get; }
|
||||
}
|
||||
|
||||
internal sealed class AlwaysAvailableWorldGeneration
|
||||
: IWorldGenerationAvailability
|
||||
{
|
||||
public static AlwaysAvailableWorldGeneration Instance { get; } = new();
|
||||
public bool IsWorldAvailable => true;
|
||||
public long QuiescedGeneration => 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-writer state behind <see cref="IWorldGenerationAvailability"/>.
|
||||
/// Superseding reveal generations stay continuously quiesced; only the exact
|
||||
/// active generation may reopen the world.
|
||||
/// </summary>
|
||||
internal sealed class WorldGenerationAvailabilityState
|
||||
: IWorldGenerationAvailability
|
||||
{
|
||||
public bool IsWorldAvailable => QuiescedGeneration == 0;
|
||||
public long QuiescedGeneration { get; private set; }
|
||||
|
||||
public void Begin(long generation)
|
||||
{
|
||||
if (generation <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(generation));
|
||||
|
||||
QuiescedGeneration = generation;
|
||||
}
|
||||
|
||||
public bool End(long generation)
|
||||
{
|
||||
if (generation <= 0 || QuiescedGeneration != generation)
|
||||
return false;
|
||||
|
||||
QuiescedGeneration = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the immediate observable edge of retail
|
||||
/// <c>SmartBox::UseTime @ 0x00455410</c>'s <c>blocking_for_cells</c>
|
||||
/// branch. Deferred teardown may retain memory, but the old generation cannot
|
||||
/// keep rendering, targeting, ticking, or producing world audio.
|
||||
/// </summary>
|
||||
internal sealed class WorldGenerationQuiescence
|
||||
{
|
||||
private readonly WorldGenerationAvailabilityState _availability;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly GpuWorldState _world;
|
||||
private readonly IWorldAudioQuiescence? _audio;
|
||||
|
||||
public WorldGenerationQuiescence(
|
||||
WorldGenerationAvailabilityState availability,
|
||||
SelectionState selection,
|
||||
GpuWorldState world,
|
||||
IWorldAudioQuiescence? audio)
|
||||
{
|
||||
_availability = availability
|
||||
?? throw new ArgumentNullException(nameof(availability));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
_audio = audio;
|
||||
}
|
||||
|
||||
public IWorldGenerationAvailability Availability => _availability;
|
||||
|
||||
public void Begin(long generation)
|
||||
{
|
||||
bool firstEdge = _availability.IsWorldAvailable;
|
||||
uint? selected = firstEdge ? _selection.SelectedObjectId : null;
|
||||
bool clearWorldSelection =
|
||||
selected is uint selectedGuid
|
||||
&& _world.IsLiveEntityVisible(selectedGuid);
|
||||
|
||||
_availability.Begin(generation);
|
||||
if (!firstEdge)
|
||||
return;
|
||||
|
||||
if (clearWorldSelection)
|
||||
{
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.Cleared);
|
||||
}
|
||||
|
||||
_audio?.SuspendWorldAudio();
|
||||
}
|
||||
|
||||
public void End(long generation)
|
||||
{
|
||||
if (_availability.End(generation))
|
||||
_audio?.ResumeWorldAudio();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ internal sealed class WorldRevealCoordinator
|
|||
{
|
||||
private readonly WorldRevealReadinessBarrier _readiness;
|
||||
private readonly WorldRevealLifecycleTelemetry _lifecycle;
|
||||
private readonly WorldGenerationQuiescence? _quiescence;
|
||||
private long _activeGeneration;
|
||||
|
||||
public WorldRevealCoordinator(
|
||||
Func<uint, int, bool> isRenderNeighborhoodReady,
|
||||
|
|
@ -19,7 +21,8 @@ internal sealed class WorldRevealCoordinator
|
|||
Action<uint, int> prepareCompositeTextures,
|
||||
Action invalidateCompositeTextures,
|
||||
Func<uint, bool> isSpawnClaimUnhydratable,
|
||||
Action<string>? log = null)
|
||||
Action<string>? log = null,
|
||||
WorldGenerationQuiescence? quiescence = null)
|
||||
{
|
||||
_readiness = new WorldRevealReadinessBarrier(
|
||||
isRenderNeighborhoodReady,
|
||||
|
|
@ -30,6 +33,7 @@ internal sealed class WorldRevealCoordinator
|
|||
invalidateCompositeTextures,
|
||||
isSpawnClaimUnhydratable);
|
||||
_lifecycle = new WorldRevealLifecycleTelemetry(log);
|
||||
_quiescence = quiescence;
|
||||
}
|
||||
|
||||
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
|
||||
|
|
@ -38,7 +42,10 @@ internal sealed class WorldRevealCoordinator
|
|||
public long Begin(WorldRevealKind kind)
|
||||
{
|
||||
_readiness.Begin();
|
||||
return _lifecycle.Begin(kind);
|
||||
long generation = _lifecycle.Begin(kind);
|
||||
_activeGeneration = generation;
|
||||
_quiescence?.Begin(generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -63,7 +70,25 @@ internal sealed class WorldRevealCoordinator
|
|||
public void ObserveWorldViewportVisible() =>
|
||||
_lifecycle.ObserveWorldViewportVisible();
|
||||
|
||||
public void Complete() => _lifecycle.Complete();
|
||||
public void Complete()
|
||||
{
|
||||
_lifecycle.Complete();
|
||||
EndQuiescence();
|
||||
}
|
||||
|
||||
public void Cancel() => _lifecycle.Cancel();
|
||||
public void Cancel()
|
||||
{
|
||||
_lifecycle.Cancel();
|
||||
EndQuiescence();
|
||||
}
|
||||
|
||||
private void EndQuiescence()
|
||||
{
|
||||
long generation = _activeGeneration;
|
||||
if (generation == 0)
|
||||
return;
|
||||
|
||||
_activeGeneration = 0;
|
||||
_quiescence?.End(generation);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue