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:
Erik 2026-07-24 18:29:52 +02:00
parent b8f6317fe1
commit bb16f74fd4
38 changed files with 1691 additions and 170 deletions

View file

@ -122,7 +122,14 @@ public sealed class AudioHookSink : IAnimationHookSink
if (waveId == 0) return;
WaveData? wave = _cache.GetWave(waveId);
if (wave is null) return;
_engine.Play3DWave(waveId, wave, worldPos, volume, priority, pitch);
_engine.Play3DWave(
entityId,
waveId,
wave,
worldPos,
volume,
priority,
pitch);
}
}

View file

@ -54,7 +54,13 @@ namespace AcDream.App.Audio;
/// unaffected.
/// </para>
/// </summary>
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
internal interface IWorldAudioQuiescence
{
void SuspendWorldAudio();
void ResumeWorldAudio();
}
public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
{
// ── Backends ─────────────────────────────────────────────────────────────
private AL? _al;
@ -71,12 +77,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
private sealed class Slot3D
{
public uint SourceId;
public uint OwnerId;
public float PlayingGain; // gain at play time (for eviction compare)
public bool InUse;
public uint PriorityBase; // raw priority from SoundEntry.Priority
}
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
private int _pool3DCursor; // round-robin start
private bool _worldAudioSuspended;
private readonly uint[] _poolUi = new uint[PoolSizeUi];
@ -238,6 +246,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
/// Returns true on success, false if the buffer was rejected.
/// </summary>
public bool Play3DWave(
uint ownerId,
uint waveId,
WaveData wave,
Vector3 position,
@ -245,7 +254,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
float priority,
float pitch = 1.0f)
{
if (!_available || _al is null) return false;
if (_worldAudioSuspended || !_available || _al is null) return false;
float effectiveGain = volume * SfxVolume;
if (effectiveGain < 0.001f) return false; // silent; skip
@ -284,11 +293,39 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
slot.PlayingGain = effectiveGain;
slot.InUse = true;
slot.OwnerId = ownerId;
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
return true;
}
/// <summary>
/// Stops every world-space voice while preserving the independent UI
/// source pool. Retail suppresses ambient/object audio while cell loading
/// blocks world maintenance; stopped voices are not resumed afterward.
/// </summary>
public void SuspendWorldAudio()
{
_worldAudioSuspended = true;
for (int i = 0; i < _pool3D.Length; i++)
StopWorldSlot(_pool3D[i]);
}
public void ResumeWorldAudio() => _worldAudioSuspended = false;
internal void StopAllForOwner(uint ownerId)
{
if (ownerId == 0)
return;
for (int i = 0; i < _pool3D.Length; i++)
{
Slot3D slot = _pool3D[i];
if (slot.InUse && slot.OwnerId == ownerId)
StopWorldSlot(slot);
}
}
/// <summary>
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
@ -469,4 +506,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
return state == (int)SourceState.Playing;
}
private void StopWorldSlot(Slot3D slot)
{
if (_available && _al is not null && slot.SourceId != 0)
{
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
}
slot.OwnerId = 0;
slot.PlayingGain = 0f;
slot.PriorityBase = 0;
slot.InUse = false;
}
}

View file

@ -384,7 +384,8 @@ internal sealed class FrameRootCompositionPhase
retailPViewCells,
worldScenePasses,
d.RenderRange,
worldSceneDiagnostics);
worldSceneDiagnostics,
live.WorldAvailability);
Fault(FrameRootCompositionPoint.WorldRendererCreated);
bindings = new FrameRootRuntimeBindings();
@ -469,7 +470,8 @@ internal sealed class FrameRootCompositionPhase
live.WorldState,
session.LiveSession,
session.LocalPlayerFrame,
session.LiveSpatialReconciler);
session.LiveSpatialReconciler,
live.WorldAvailability);
var cameraFrame = new CameraFrameController(
host.CameraController,
d.InputCapture,
@ -496,7 +498,8 @@ internal sealed class FrameRootCompositionPhase
new StopwatchClientMonotonicTimeSource()),
session.LocalTeleport,
new PlayerModeAutoEntryFramePhase(session.PlayerModeAutoEntry),
cameraFrame);
cameraFrame,
live.WorldAvailability);
Fault(FrameRootCompositionPoint.UpdateRootCreated);
var bindingsLease = scope.Own(

View file

@ -78,6 +78,7 @@ internal sealed record LivePresentationResult(
EntitySpawnAdapter EntitySpawnAdapter,
EntityScriptActivator EntityScriptActivator,
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
WorldGenerationAvailabilityState WorldAvailability,
GpuWorldState WorldState,
LiveEntityRuntime LiveEntities,
ProjectileController ProjectileController,
@ -319,10 +320,12 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler.Unregister,
(entity, info) => staticAnimationScheduler.Rebind(entity, info));
var worldAvailability = new WorldGenerationAvailabilityState();
var worldState = new GpuWorldState(
wbSpawnAdapter,
d.ClassificationCache.InvalidateLandblock,
entityScriptActivator);
entityScriptActivator,
worldAvailability);
bindings = new LivePresentationRuntimeBindings();
if (d.DevWorldEntities is { } devWorldEntities)
{
@ -524,6 +527,7 @@ internal sealed class LivePresentationCompositionPhase
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
worldAvailability,
worldState,
liveEntities,
projectileController,
@ -568,6 +572,7 @@ internal sealed class LivePresentationCompositionPhase
EntitySpawnAdapter entitySpawnAdapter,
EntityScriptActivator entityScriptActivator,
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
WorldGenerationAvailabilityState worldAvailability,
GpuWorldState worldState,
LiveEntityRuntime liveEntities,
ProjectileController projectileController,
@ -942,6 +947,7 @@ internal sealed class LivePresentationCompositionPhase
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
worldAvailability,
worldState,
liveEntities,
projectileController,

View file

@ -321,6 +321,11 @@ internal sealed class SessionPlayerCompositionPhase
var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier(
content.Dats,
d.DatLock);
var worldQuiescence = new WorldGenerationQuiescence(
live.WorldAvailability,
d.Selection,
live.WorldState,
content.Audio?.Engine);
var worldReveal = new WorldRevealCoordinator(
streaming.IsRenderNeighborhoodResident,
d.PhysicsEngine.IsSpawnCellReady,
@ -333,7 +338,8 @@ internal sealed class SessionPlayerCompositionPhase
radius),
live.DrawDispatcher.InvalidateCompositeWarmupReadiness,
spawnClaimClassifier.IsUnhydratable,
d.Log);
d.Log,
worldQuiescence);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer(

View file

@ -1,5 +1,6 @@
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
@ -59,6 +60,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
private readonly IWorldScenePassExecutor _passes;
private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics;
private readonly IWorldGenerationAvailability _availability;
public WorldSceneRenderer(
IRenderFrameFoundationSource foundation,
@ -73,7 +75,8 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
IRetailPViewCellSource pviewCells,
IWorldScenePassExecutor passes,
IWorldRenderRangeSource renderRange,
IWorldSceneDiagnostics diagnostics)
IWorldSceneDiagnostics diagnostics,
IWorldGenerationAvailability? availability = null)
{
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
_login = login ?? throw new ArgumentNullException(nameof(login));
@ -89,6 +92,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
@ -100,6 +104,13 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
try
{
_selection?.BeginFrame();
if (!_availability.IsWorldAvailable)
{
_selection?.CompleteFrame();
selectionFrameStarted = false;
return default;
}
RenderFrameFoundation foundation = _foundation.Foundation;
if (foundation.PortalViewportVisible)
{

View file

@ -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);

View file

@ -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)

View file

@ -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)

View file

@ -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;

View file

@ -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;
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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()
{

View 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();
}
}

View file

@ -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);
}
}

View file

@ -1,5 +1,7 @@
namespace AcDream.App.Update;
using AcDream.App.Streaming;
/// <summary>
/// The host-supplied input for one update callback.
/// </summary>
@ -28,10 +30,13 @@ internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource
{
public double CurrentScriptTime { get; private set; }
public UpdateFrameTiming Advance(UpdateFrameInput input)
public UpdateFrameTiming Advance(
UpdateFrameInput input,
bool advanceScriptClock = true)
{
double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds);
CurrentScriptTime += deltaSeconds;
if (advanceScriptClock)
CurrentScriptTime += deltaSeconds;
return new UpdateFrameTiming(
deltaSeconds,
(float)deltaSeconds,
@ -122,6 +127,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
private readonly ILocalPlayerTeleportFramePhase _teleport;
private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry;
private readonly ICameraFramePhase _camera;
private readonly IWorldGenerationAvailability _availability;
public UpdateFrameOrchestrator(
IUpdateFrameTeardownPhase teardown,
@ -134,7 +140,8 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
ILiveEntityLivenessFramePhase liveness,
ILocalPlayerTeleportFramePhase teleport,
IPlayerModeAutoEntryFramePhase playerModeAutoEntry,
ICameraFramePhase camera)
ICameraFramePhase camera,
IWorldGenerationAvailability? availability = null)
{
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink));
@ -149,6 +156,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
_playerModeAutoEntry = playerModeAutoEntry
?? throw new ArgumentNullException(nameof(playerModeAutoEntry));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public void Tick(UpdateFrameInput input)
@ -165,12 +173,15 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
_failureSink.ReportTeardownFailure(error);
}
UpdateFrameTiming timing = _clock.Advance(input);
UpdateFrameTiming timing = _clock.Advance(
input,
advanceScriptClock: _availability.IsWorldAvailable);
_scriptClockPublisher.PublishTime(timing.ScriptTime);
_streaming.Tick();
_input.Tick(timing);
_liveFrame.Tick(timing.SimulationDeltaSecondsSingle);
_liveness.Tick();
if (_availability.IsWorldAvailable)
_liveness.Tick();
_teleport.Tick(timing.SimulationDeltaSecondsSingle);
_playerModeAutoEntry.TryEnter();
_camera.Tick(timing);

View file

@ -24,13 +24,15 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
private readonly ILiveSessionFramePhase _session;
private readonly IPostNetworkCommandFramePhase _localPlayer;
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
private readonly IWorldGenerationAvailability _availability;
public RetailLiveFrameCoordinator(
ILiveObjectFramePhase objects,
GpuWorldState worldState,
ILiveSessionFramePhase session,
IPostNetworkCommandFramePhase localPlayer,
ILiveSpatialReconcilePhase spatialReconciler)
ILiveSpatialReconcilePhase spatialReconciler,
IWorldGenerationAvailability? availability = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
@ -38,16 +40,19 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_spatialReconciler = spatialReconciler
?? throw new ArgumentNullException(nameof(spatialReconciler));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public void Tick(float deltaSeconds)
{
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
_objects.Tick(frameDelta);
if (_availability.IsWorldAvailable)
_objects.Tick(frameDelta);
using (_worldState.BeginMutationBatch())
_session.Tick();
_localPlayer.RunPostNetworkCommandPhase();
_spatialReconciler.Reconcile();
if (_availability.IsWorldAvailable)
_spatialReconciler.Reconcile();
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>