fix(streaming): preserve portal destination ownership

Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
This commit is contained in:
Erik 2026-07-25 08:35:12 +02:00
parent 2c848d4167
commit 823936ec31
57 changed files with 2551 additions and 324 deletions

View file

@ -754,7 +754,8 @@ internal sealed class LivePresentationCompositionPhase
d.Gl,
dispatcherLease.Resource,
foundation.SceneLighting,
foundation.TextureCache),
foundation.TextureCache,
foundation.MeshAdapter),
static value => value.Dispose());
IUiViewportRenderer? previousRenderer = viewport.Renderer;
viewport.Renderer = paperdollLease.Resource;
@ -795,7 +796,8 @@ internal sealed class LivePresentationCompositionPhase
d.Gl,
dispatcherLease.Resource,
foundation.SceneLighting,
foundation.TextureCache),
foundation.TextureCache,
foundation.MeshAdapter),
static value => value.Dispose());
IUiViewportRenderer? previousRenderer = creatureViewport.Renderer;
creatureViewport.Renderer = creatureAppraisalLease.Resource;

View file

@ -329,6 +329,9 @@ internal sealed class SessionPlayerCompositionPhase
content.Audio?.Engine);
var compositeWarmupSource =
new CompositeWarmupEntitySource(live.WorldState);
var revealRenderResources = new WorldRevealRenderResourceScheduler(
foundation.MeshAdapter.SetDestinationRevealUploadPriority,
foundation.TextureCache.SetDestinationRevealUploadPriority);
var worldReveal = new WorldRevealCoordinator(
streaming.IsRenderNeighborhoodResident,
d.PhysicsEngine.IsSpawnCellReady,
@ -351,7 +354,8 @@ internal sealed class SessionPlayerCompositionPhase
spawnClaimClassifier.IsUnhydratable,
d.Log,
worldQuiescence,
streaming);
streaming,
revealRenderResources);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer(
@ -450,11 +454,13 @@ internal sealed class SessionPlayerCompositionPhase
live.Lights,
d.ClassificationCache,
d.PlayerIdentity);
var dormantLiveEntities = new DormantLiveEntityStore();
var deletion = new LiveEntityDeletionController(
live.LiveEntities,
d.Objects,
teardown,
d.PlayerIdentity,
dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null);
var hydration = new LiveEntityHydrationController(
live.LiveEntities,
@ -472,6 +478,7 @@ internal sealed class SessionPlayerCompositionPhase
localPhysicsTimestamps,
d.PlayerIdentity,
deletion,
dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null);
bindings.Adopt(
"landblock-loaded hydration",
@ -511,7 +518,8 @@ internal sealed class SessionPlayerCompositionPhase
var liveness = new LiveEntityLivenessController(
live.LiveEntities,
d.PlayerIdentity,
deletion);
deletion,
dormantLiveEntities);
var sessionEvents = new LiveEntitySessionController(
d.InboundEntityEvents,
hydration,

View file

@ -218,7 +218,7 @@ internal sealed class LiveSessionRuntimeFactory
_world.Hydration.ResetSessionState();
_player.Shortcuts.Items = Array.Empty<ShortcutEntry>();
_player.DesiredComponents.Clear();
_ui.Paperdoll?.MarkDirty();
_ui.Paperdoll?.ResetSession();
// X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic

View file

@ -277,6 +277,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal const long TargetArrayBytes = 4L * 1024 * 1024;
internal const int MaximumLayersPerArray = 64;
internal const int DefaultMaximumUploadsPerFrame = 16;
internal const int DestinationRevealMaximumUploadsPerFrame = 64;
internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumLogicalEvictionsPerFrame = 16;
internal const int MaximumAtlasCreationsPerFrame = 1;
@ -298,6 +299,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable
private long _frameUploadBytes;
private int _frameAtlasCreationCount;
private bool _uploadBudgetBlocked;
private bool _destinationRevealUploadPriority;
private int _pendingAtlasWidth;
private int _pendingAtlasHeight;
private long _pendingAtlasAllocationBytes;
@ -390,9 +392,16 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal long FrameUploadBytes => _frameUploadBytes;
internal bool CanStartUpload =>
!_uploadBudgetBlocked
&& _frameUploadCount < _maximumUploadsPerFrame
&& _frameUploadCount < CurrentMaximumUploadsPerFrame
&& (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame);
private int CurrentMaximumUploadsPerFrame =>
_destinationRevealUploadPriority
? Math.Max(
_maximumUploadsPerFrame,
DestinationRevealMaximumUploadsPerFrame)
: _maximumUploadsPerFrame;
internal Residency.ResidencyDomainSnapshot CaptureResidency()
{
long retiringBytes = 0;
@ -439,7 +448,7 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal bool CanUpload(long bytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);
if (_frameUploadCount >= _maximumUploadsPerFrame)
if (_frameUploadCount >= CurrentMaximumUploadsPerFrame)
return false;
// Always allow one item so a texture larger than the normal frame
@ -479,10 +488,12 @@ internal sealed class CompositeTextureArrayCache : IDisposable
return false;
}
public void BeginFrame()
public void BeginFrame(bool destinationRevealUploadPriority = false)
{
ThrowIfUnavailable();
_retirementLedger.RetryPendingPublications();
_destinationRevealUploadPriority =
destinationRevealUploadPriority;
_frameUploadCount = 0;
_frameUploadBytes = 0;
_frameAtlasCreationCount = 0;

View file

@ -378,13 +378,15 @@ internal sealed class CreatureAppraisalViewportRenderer :
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime)
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter)
{
_renderer = new PrivateEntityViewportRenderer(
gl,
dispatcher,
lightUbo,
textureLifetime,
meshAdapter,
CreatureAppraisalEntityBuilder.RenderId,
_camera,
"creature examination");

View file

@ -53,6 +53,7 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view;
private readonly IPaperdollDollFactory _factory;
private WorldEntity? _doll;
private bool _dirty = true;
public PaperdollFramePresenter(
@ -78,20 +79,111 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{
if (_factory.TryBuild(out WorldEntity? doll))
{
_renderer.SetDoll(doll);
// Same-generation CreateObject refreshes can repeat the exact
// player ObjDesc at a portal boundary. Retail redresses its
// private inventory object in place; releasing and reacquiring
// an identical synthetic owner briefly blanks the viewport and
// churns its texture composites.
if (!HasEquivalentAppearance(_doll, doll))
{
_renderer.SetDoll(doll);
_doll = doll;
}
_dirty = false;
}
else
{
// Retail's private viewport remains present while the live
// player projection is still materializing. Keep the dirty
// edge armed and clear any doll from the prior session.
_renderer.SetDoll(null);
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
// private m_pInventoryObject intact when the SmartBox player
// is temporarily unavailable. Keep the successful doll and
// retry this dirty redress on the next visible frame.
}
}
_view.SetTextureHandle(_renderer.Render(width, height));
}
/// <summary>
/// Clears the private object only at the owning character-session
/// boundary, matching gmPaperDollUI's private-object lifetime.
/// </summary>
public void ResetSession()
{
_renderer.SetDoll(null);
_doll = null;
_dirty = true;
}
private static bool HasEquivalentAppearance(
WorldEntity? current,
WorldEntity? candidate)
{
if (current is null || candidate is null)
return ReferenceEquals(current, candidate);
if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
|| current.Scale != candidate.Scale
|| current.HiddenPartsMask != candidate.HiddenPartsMask
|| current.MeshRefs.Count != candidate.MeshRefs.Count
|| current.PartOverrides.Count != candidate.PartOverrides.Count)
{
return false;
}
for (int i = 0; i < current.MeshRefs.Count; i++)
{
MeshRef left = current.MeshRefs[i];
MeshRef right = candidate.MeshRefs[i];
if (left.GfxObjId != right.GfxObjId
|| left.PartTransform != right.PartTransform
|| !DictionaryEquals(
left.SurfaceOverrides,
right.SurfaceOverrides))
{
return false;
}
}
for (int i = 0; i < current.PartOverrides.Count; i++)
{
if (current.PartOverrides[i] != candidate.PartOverrides[i])
return false;
}
PaletteOverride? leftPalette = current.PaletteOverride;
PaletteOverride? rightPalette = candidate.PaletteOverride;
if (leftPalette is null || rightPalette is null)
return leftPalette is null && rightPalette is null;
if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
|| leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
{
return false;
}
for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
{
if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
return false;
}
return true;
}
private static bool DictionaryEquals(
IReadOnlyDictionary<uint, uint>? left,
IReadOnlyDictionary<uint, uint>? right)
{
if (left is null || right is null)
return left is null && right is null;
if (left.Count != right.Count)
return false;
foreach ((uint key, uint value) in left)
{
if (!right.TryGetValue(key, out uint rightValue)
|| rightValue != value)
{
return false;
}
}
return true;
}
}
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>

View file

@ -21,13 +21,15 @@ public sealed class PaperdollViewportRenderer :
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime)
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter)
{
_renderer = new PrivateEntityViewportRenderer(
gl,
dispatcher,
lightUbo,
textureLifetime,
meshAdapter,
DollEntityBuilder.DollRenderId,
new DollViewportCamera(),
"paperdoll");

View file

@ -55,7 +55,7 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly Random _random;
private readonly Action<string?>? _displayNotice;
private IDisposable? _displayNoticeLifetime;
private readonly PortalMeshReferenceOwner _meshReferences;
private readonly SyntheticEntityMeshReferenceOwner _meshReferences;
private bool _visible;
private double _rotationElapsed;
@ -104,7 +104,7 @@ public sealed class PortalTunnelPresentation : IDisposable
MeshRefs = SetupMesh.Flatten(setup),
};
_meshReferences = new PortalMeshReferenceOwner(
_meshReferences = new SyntheticEntityMeshReferenceOwner(
meshAdapter,
setup.Parts.Select(part => (ulong)(uint)part));
}

View file

@ -32,9 +32,12 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly IWbMeshAdapter _meshAdapter;
private readonly IPrivateEntityViewportCamera _camera;
private readonly HashSet<uint> _animatedIds;
private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner>
_retiringMeshReferences = [];
private uint _fbo;
private uint _colorTex;
@ -42,12 +45,14 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
private int _fbW;
private int _fbH;
private WorldEntity? _entity;
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public PrivateEntityViewportRenderer(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter,
uint renderId,
IPrivateEntityViewportCamera camera,
string diagnosticName)
@ -57,6 +62,8 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
_meshAdapter = meshAdapter
?? throw new ArgumentNullException(nameof(meshAdapter));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
@ -69,10 +76,63 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
public void SetEntity(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
if (ReferenceEquals(_entity, entity))
return;
_textureOwnerLease.Replace(entity is not null);
SyntheticEntityMeshReferenceOwner? replacement = null;
if (entity is not null)
{
replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
CollectMeshIds(entity));
replacement.Acquire();
}
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
try
{
_textureOwnerLease.Replace(entity is not null);
}
catch (Exception textureFailure)
{
if (replacement is null)
throw;
try
{
replacement.Dispose();
}
catch (Exception rollbackFailure)
{
throw new AggregateException(
$"The {_diagnosticName} texture-owner replacement failed "
+ "and the replacement mesh-owner rollback did not converge.",
textureFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(textureFailure)
.Throw();
}
_meshReferences = replacement;
_entity = entity;
if (previous is not null)
{
try
{
previous.Dispose();
}
catch
{
_retiringMeshReferences.Add(previous);
throw;
}
}
}
public uint Render(int width, int height)
@ -246,7 +306,78 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
public void Dispose()
{
_entity = null;
_textureOwnerLease.Dispose();
DeleteFramebuffer();
if (_meshReferences is { } current)
{
_meshReferences = null;
_retiringMeshReferences.Add(current);
}
List<Exception>? failures = null;
try
{
_textureOwnerLease.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
try
{
ReleaseRetiringMeshReferences();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
try
{
DeleteFramebuffer();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
if (failures is not null)
{
throw new AggregateException(
$"The {_diagnosticName} resources did not fully release.",
failures);
}
}
private static IEnumerable<ulong> CollectMeshIds(WorldEntity entity)
{
for (int i = 0; i < entity.MeshRefs.Count; i++)
yield return entity.MeshRefs[i].GfxObjId;
for (int i = 0; i < entity.PartOverrides.Count; i++)
yield return entity.PartOverrides[i].GfxObjId;
}
private void ReleaseRetiringMeshReferences()
{
List<Exception>? failures = null;
for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--)
{
SyntheticEntityMeshReferenceOwner owner =
_retiringMeshReferences[i];
try
{
owner.Dispose();
if (owner.IsDisposed)
_retiringMeshReferences.RemoveAt(i);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
{
throw new AggregateException(
$"One or more {_diagnosticName} mesh owners remain pending.",
failures);
}
}
}

View file

@ -3,13 +3,14 @@ using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the synthetic portal creature's mesh references. Acquisition happens
/// only after the presentation has been published to its runtime owner, so a
/// partial backend failure can never strand an unreachable constructor-local
/// reference. Per-reference physical state makes both acquisition and teardown
/// resumable without replaying successful mutations.
/// Owns one synthetic/private entity's mesh references independently of world
/// cell residence. Acquisition happens only after the presentation has been
/// published to its runtime owner, so a partial backend failure can never
/// strand an unreachable constructor-local reference. Per-reference physical
/// state makes both acquisition and teardown resumable without replaying
/// successful mutations.
/// </summary>
internal sealed class PortalMeshReferenceOwner : IDisposable
internal sealed class SyntheticEntityMeshReferenceOwner : IDisposable
{
private sealed class ReferenceState(ulong gfxObjId)
{
@ -25,7 +26,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
private bool _reconciling;
private bool _reconcileAgain;
public PortalMeshReferenceOwner(
public SyntheticEntityMeshReferenceOwner(
IWbMeshAdapter meshAdapter,
IEnumerable<ulong> gfxObjIds)
{
@ -62,7 +63,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
catch (Exception rollbackFailure)
{
throw new AggregateException(
"Portal mesh acquisition failed and its rollback did not fully converge.",
"Synthetic entity mesh acquisition failed and its rollback did not fully converge.",
acquisitionFailure,
rollbackFailure);
}
@ -132,7 +133,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
string operation = targetHeld ? "acquisition" : "release";
(failures ??= []).Add(new InvalidOperationException(
$"Portal mesh 0x{reference.GfxObjId:X10} reference {operation} failed.",
$"Synthetic entity mesh 0x{reference.GfxObjId:X10} reference {operation} failed.",
error));
}
}
@ -148,7 +149,7 @@ internal sealed class PortalMeshReferenceOwner : IDisposable
if (failures is not null)
throw new AggregateException(
"One or more portal mesh references failed to reconcile.",
"One or more synthetic entity mesh references failed to reconcile.",
failures);
}
}

View file

@ -12,7 +12,9 @@ using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
public sealed unsafe class TextureCache
: Wb.IEntityTextureLifetime,
IDisposable
{
private readonly GL _gl;
private readonly IDatReaderWriter _dats;
@ -39,6 +41,7 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
private bool _destinationRevealUploadPriority;
// Standalone Texture2DArray caches. Shared world surfaces use WB's atlas;
// this base cache remains for consumers such as particle rendering.
@ -65,6 +68,9 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0;
internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0;
internal void SetDestinationRevealUploadPriority(bool enabled) =>
_destinationRevealUploadPriority = enabled;
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
// time so the dump method doesn't have to query GL state. Keyed by
@ -557,7 +563,8 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
/// </summary>
public void TickParticleTextureCache() => _particleTextures?.Tick();
public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame();
public void BeginCompositeTextureFrame() =>
_compositeTextures?.BeginFrame(_destinationRevealUploadPriority);
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two

View file

@ -21,9 +21,12 @@ namespace AcDream.App.Rendering.Wb;
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para>
/// </summary>
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
public sealed class WbMeshAdapter
: IDisposable,
IWbMeshAdapter
{
internal const int MaximumUploadsPerFrame = 8;
internal const int DestinationRevealMaximumUploadsPerFrame = 64;
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
@ -48,22 +51,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly ObjectMeshManager? _meshManager;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IPreparedAssetSource? _ownedPreparedAssets;
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
MaximumUploadsPerFrame,
MaximumUploadBytesPerFrame,
MaximumArrayAllocationBytesPerFrame,
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
private readonly MeshUploadFrameBudget _ordinaryUploadBudget =
CreateUploadBudget(MaximumUploadsPerFrame);
private readonly MeshUploadFrameBudget _destinationRevealUploadBudget =
CreateUploadBudget(DestinationRevealMaximumUploadsPerFrame);
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
private bool _destinationRevealUploadPriority;
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
@ -91,6 +84,27 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
_meshManager?.CpuCacheDiagnostics ?? default;
internal void SetDestinationRevealUploadPriority(bool enabled) =>
_destinationRevealUploadPriority = enabled;
private static MeshUploadFrameBudget CreateUploadBudget(
int maximumObjects) =>
new(new MeshUploadBudgetLimits(
maximumObjects,
MaximumUploadBytesPerFrame,
MaximumArrayAllocationBytesPerFrame,
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
/// <summary>
/// Constructs the UI-Studio/tooling WB pipeline. Production composition
/// supplies the validated pak source through the internal overload below;
@ -457,7 +471,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<MeshUploadQueueItem>? requeue = null;
_uploadBudget.Reset();
MeshUploadFrameBudget uploadBudget =
_destinationRevealUploadPriority
? _destinationRevealUploadBudget
: _ordinaryUploadBudget;
int maximumUploads =
_destinationRevealUploadPriority
? DestinationRevealMaximumUploadsPerFrame
: MaximumUploadsPerFrame;
uploadBudget.Reset();
_mipmapsBudgeted.Clear();
int staleDiscardCount = 0;
bool arenaBackpressured = false;
@ -470,7 +492,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{
GlobalMeshMaintenanceStep maintenance =
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
_uploadBudget.RecordBufferMaintenance(
uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
@ -478,8 +500,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
}
while (globalBuffer?.IsMigrationInProgress != true
&& _uploadBudget.BufferCopyBytes == 0
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame)
&& uploadBudget.BufferCopyBytes == 0
&& uploadBudget.ObjectCount < maximumUploads)
{
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
MaximumStaleDiscardsPerFrame - staleDiscardCount);
@ -501,7 +523,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
}
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
{
_uploadBudget.RecordBufferMaintenance(
uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
@ -544,7 +566,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
next.Data,
_mipmapsBudgeted,
next.Generation);
if (!_uploadBudget.TryAdmit(cost))
if (!uploadBudget.TryAdmit(cost))
break;
}
catch (NotSupportedException error)
@ -597,29 +619,29 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// frames. It never competes with destination materialization, and the
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
// revisits do not alternate shrink/grow every frame.
if (_uploadBudget.ObjectCount == 0
if (uploadBudget.ObjectCount == 0
&& LastMipmapArrayCount == 0
&& meshManager.StagedMeshCount == 0
&& globalBuffer?.IsMigrationInProgress == false
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
{
_uploadBudget.RecordBufferMaintenance(
uploadBudget.RecordBufferMaintenance(
trim.AllocationBytes,
trim.CopyBytes,
trim.NewBufferCount);
meshManager.SetArenaBackpressure(true);
}
LastUploadCount = _uploadBudget.ObjectCount;
LastUploadBytes = _uploadBudget.SourceBytes;
LastUploadCount = uploadBudget.ObjectCount;
LastUploadBytes = uploadBudget.SourceBytes;
LastStaleDiscardCount = staleDiscardCount;
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes;
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes;
LastNewArrayCount = _uploadBudget.NewArrayCount;
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes;
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes;
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes;
LastNewBufferCount = _uploadBudget.NewBufferCount;
LastArrayAllocationBytes = uploadBudget.ArrayAllocationBytes;
LastPlannedMipmapBytes = uploadBudget.MipmapBytes;
LastNewArrayCount = uploadBudget.NewArrayCount;
LastBufferUploadBytes = uploadBudget.BufferUploadBytes;
LastBufferAllocationBytes = uploadBudget.BufferAllocationBytes;
LastBufferCopyBytes = uploadBudget.BufferCopyBytes;
LastNewBufferCount = uploadBudget.NewBufferCount;
if (texProbe)
EmitTexFlushProbe(pendingBefore);

View file

@ -339,7 +339,6 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation
_particles.UseWorldView();
_reveal?.ObserveWorldViewportVisible();
_reveal?.Complete();
}
public void PublishViewProjection(in WorldCameraFrame camera) =>

View file

@ -0,0 +1,53 @@
using AcDream.App.Streaming;
namespace AcDream.App.Rendering;
/// <summary>
/// Applies one reveal generation to every render-resource participant and
/// restores their ordinary profile only when that exact generation ends.
/// Replacing an active generation keeps priority continuously enabled.
/// </summary>
internal sealed class WorldRevealRenderResourceScheduler
: IWorldRevealRenderResourceScheduler
{
private readonly Action<bool>[] _participants;
private long _activeGeneration;
public WorldRevealRenderResourceScheduler(
params Action<bool>[] participants)
{
ArgumentNullException.ThrowIfNull(participants);
if (participants.Length == 0)
throw new ArgumentException(
"At least one reveal upload participant is required.",
nameof(participants));
if (participants.Any(static participant => participant is null))
throw new ArgumentException(
"Reveal upload participants cannot contain null.",
nameof(participants));
_participants = [.. participants];
}
public void BeginDestinationReveal(long revealGeneration)
{
if (revealGeneration <= 0)
throw new ArgumentOutOfRangeException(nameof(revealGeneration));
_activeGeneration = revealGeneration;
for (int i = 0; i < _participants.Length; i++)
_participants[i](true);
}
public void EndDestinationReveal(long revealGeneration)
{
if (revealGeneration <= 0)
throw new ArgumentOutOfRangeException(nameof(revealGeneration));
if (_activeGeneration != revealGeneration)
return;
for (int i = 0; i < _participants.Length; i++)
_participants[i](false);
_activeGeneration = 0;
}
}

View file

@ -13,6 +13,17 @@ public sealed record GpuLandblockRetirement(
LandblockRetirementKind Kind,
IReadOnlyList<WorldEntity> Entities);
/// <summary>
/// Exact result of the atomic spatial-generation swap used before a shared
/// world-origin recenter. Spatial membership is already unreachable when this
/// value returns; the retained per-landblock receipts let presentation owners
/// retire their resources later under the ordinary frame budget.
/// </summary>
internal sealed record GpuWorldRecenterRetirement(
IReadOnlyList<GpuLandblockRetirement> Landblocks,
int SpatialOperationCount,
Exception? ObserverFailure);
public enum LandblockRetirementKind
{
Full,

View file

@ -460,6 +460,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
internal long VisibilityCommitCount => _visibilityCommitCount;
internal int OriginRecenterSpatialOperationCount =>
Math.Max(
1,
_loaded.Count
+ _pendingByLandblock.Count
+ _pendingRenderIdsByLandblock.Count
+ _pendingNearTierLandblocks.Count
+ _projectionLocations.Count);
/// <summary>
/// Groups spatial mutations into one visibility publication transaction.
@ -1257,6 +1265,207 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
detachedEntities);
}
/// <summary>
/// Atomically withdraws the complete old spatial generation before a
/// shared-origin change. The origin is common to every resident transform,
/// so admitting hundreds of independent landblock detaches across frames
/// only prolongs portal transit; it does not expose a useful intermediate
/// state. This operation swaps the spatial indexes as one transaction,
/// retains live logical objects, and returns exact landblock receipts so
/// renderer/physics/script destruction can remain frame-budgeted.
/// </summary>
internal GpuWorldRecenterRetirement DetachAllForOriginRecenter()
{
var ids = new List<uint>(
_loaded.Count
+ _pendingByLandblock.Count
+ _pendingRenderIdsByLandblock.Count);
var seenIds = new HashSet<uint>();
void AddId(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
if (seenIds.Add(canonical))
ids.Add(canonical);
}
// Preserve the accepted renderer traversal order for already-resident
// landblocks, then append presentation-only and pending-only owners in
// their stable insertion order.
for (int i = 0; i < _renderTraversalLandblockSlots.Count; i++)
{
uint id = _renderTraversalLandblockSlots[i];
if (id != 0u)
AddId(id);
}
foreach (uint id in _pendingByLandblock.Keys)
AddId(id);
foreach (uint id in _pendingRenderIdsByLandblock.Keys)
AddId(id);
foreach (uint id in _pendingNearTierLandblocks)
AddId(id);
foreach (uint id in _tierByLandblock.Keys)
AddId(id);
foreach (uint id in _aabbs.Keys)
AddId(id);
var retirements = new List<GpuLandblockRetirement>(ids.Count);
for (int i = 0; i < ids.Count; i++)
{
uint id = ids[i];
IReadOnlyList<WorldEntity> loaded =
_loaded.TryGetValue(id, out LoadedLandblock? landblock)
? landblock.Entities
: Array.Empty<WorldEntity>();
IReadOnlyList<WorldEntity> pending =
_pendingByLandblock.TryGetValue(id, out List<WorldEntity>? bucket)
? bucket
: Array.Empty<WorldEntity>();
IReadOnlyList<WorldEntity> detached = loaded;
if (pending.Count != 0)
{
if (loaded.Count == 0)
{
detached = pending;
}
else
{
var combined = new List<WorldEntity>(
loaded.Count + pending.Count);
var seenEntities = new HashSet<WorldEntity>(
ReferenceEqualityComparer.Instance);
for (int entityIndex = 0;
entityIndex < loaded.Count;
entityIndex++)
{
WorldEntity entity = loaded[entityIndex];
if (seenEntities.Add(entity))
combined.Add(entity);
}
for (int entityIndex = 0;
entityIndex < pending.Count;
entityIndex++)
{
WorldEntity entity = pending[entityIndex];
if (seenEntities.Add(entity))
combined.Add(entity);
}
detached = combined;
}
}
retirements.Add(new GpuLandblockRetirement(
id,
LandblockRetirementKind.Full,
detached));
}
// Capture live projections from their small dedicated index rather
// than walking every DAT-static entity in the 25x25 world window.
var retainedByLandblock =
new Dictionary<uint, List<(WorldEntity Entity, int BucketIndex)>>();
var rescued = new HashSet<WorldEntity>(
_persistentRescued,
ReferenceEqualityComparer.Instance);
foreach ((WorldEntity entity, ProjectionLocation location) in
_projectionLocations)
{
if (_persistentGuids.Contains(entity.ServerGuid))
{
if (rescued.Add(entity))
{
_persistentRescued.Add(entity);
EntityVanishProbe.Log(
$"[ent] RESCUE guid=0x{entity.ServerGuid:X8} " +
$"from=recenter lb=0x{location.LandblockId:X8}");
}
continue;
}
if (!retainedByLandblock.TryGetValue(
location.LandblockId,
out List<(WorldEntity Entity, int BucketIndex)>? retained))
{
retained = [];
retainedByLandblock.Add(location.LandblockId, retained);
}
retained.Add((entity, location.BucketIndex));
}
int spatialOperationCount = OriginRecenterSpatialOperationCount;
Exception? observerFailure = null;
MutationBatch mutation = BeginMutationBatch();
try
{
foreach ((uint guid, int count) in _visibleLiveProjectionCounts)
{
if (count > 0 && !_visibilityBeforeMutation.ContainsKey(guid))
_visibilityBeforeMutation.Add(guid, true);
}
_visibleLiveProjectionCounts.Clear();
if (_flatEntities.Count != 0)
_flatMembershipDirty = true;
_flatEntities.Clear();
_flatEntityIndices.Clear();
_projectionLocations.Clear();
_loadedLiveByLandblock.Clear();
_primaryProjectionByGuid.Clear();
_additionalProjectionsByGuid.Clear();
_loaded.Clear();
_renderTraversalLandblockSlots.Clear();
_freeRenderTraversalLandblockSlots.Clear();
_renderTraversalSlotByLandblock.Clear();
_tierByLandblock.Clear();
_aabbs.Clear();
_animatedIndexByLandblock.Clear();
_pendingByLandblock.Clear();
_pendingRenderIdsByLandblock.Clear();
_pendingNearTierLandblocks.Clear();
_landblockEntriesView.Clear();
_landblockEntriesWithoutAnimatedIndexView.Clear();
_landblockBoundsView.Clear();
InvalidateLandblockRenderViews(entries: true, bounds: true);
foreach ((uint id, List<(WorldEntity Entity, int BucketIndex)> retained) in
retainedByLandblock)
{
retained.Sort(static (left, right) =>
left.BucketIndex.CompareTo(right.BucketIndex));
var pending = new List<WorldEntity>(retained.Count);
for (int i = 0; i < retained.Count; i++)
{
WorldEntity entity = retained[i].Entity;
pending.Add(entity);
SetProjectionLocation(
entity,
id,
isLoaded: false,
i);
}
_pendingByLandblock.Add(id, pending);
}
}
finally
{
try
{
mutation.Dispose();
}
catch (Exception error)
{
observerFailure = error;
}
}
return new GpuWorldRecenterRetirement(
retirements,
spatialOperationCount,
observerFailure);
}
/// <summary>
/// Compatibility edge for direct state tests. Production streaming uses
/// <see cref="LandblockRetirementCoordinator"/> so each presentation owner

View file

@ -240,6 +240,11 @@ public sealed class LandblockPresentationPipeline
public void AdvanceRetirements(StreamingWorkMeter meter) =>
_retirements.Advance(meter);
internal void AdvancePriorityRetirement(
uint landblockId,
StreamingWorkMeter meter) =>
_retirements.AdvancePriority(landblockId, meter);
public void BeginFullRetirement(uint landblockId)
{
_retirements.BeginFull(landblockId);
@ -260,6 +265,29 @@ public sealed class LandblockPresentationPipeline
internal void EnqueueNearLayerRetirement(uint landblockId) =>
_retirements.BeginNearLayer(landblockId);
/// <summary>
/// Withdraws the complete old spatial generation in one transaction, then
/// transfers its exact receipts to the ordinary budgeted retirement FIFO.
/// </summary>
internal GpuWorldRecenterRetirement DetachAllForOriginRecenter()
{
GpuWorldRecenterRetirement detached =
_state.DetachAllForOriginRecenter();
Exception? adoptionFailure =
_retirements.AdoptDetachedFull(detached.Landblocks);
Exception? failure = (detached.ObserverFailure, adoptionFailure) switch
{
(null, null) => null,
({ } observer, null) => observer,
(null, { } adoption) => adoption,
({ } observer, { } adoption) => new AggregateException(
"Origin-recenter spatial observers and retirement adoption failed.",
observer,
adoption),
};
return detached with { ObserverFailure = failure };
}
public void PublishLoaded(LandblockStreamResult.Loaded loaded)
{
ArgumentNullException.ThrowIfNull(loaded);

View file

@ -59,16 +59,15 @@ public sealed class LandblockPresentationRetirementOwner
public void Advance(LandblockRetirementTicket ticket)
{
ArgumentNullException.ThrowIfNull(ticket);
bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
ticket.RunForEachEntity(
LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveLighting);
static entity => entity.ServerGuid == 0,
_staticPresentation.RemoveLighting);
ticket.RunForEachEntity(
LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveTranslucency);
static entity => entity.ServerGuid == 0,
_staticPresentation.RemoveTranslucency);
ticket.RunForEachEntity(
LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
@ -105,19 +104,18 @@ public sealed class LandblockPresentationRetirementOwner
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),
static entity => entity.ServerGuid == 0,
_staticPresentation.RemoveLighting),
LandblockRetirementStage.EntityTranslucency =>
ticket.RunEntityStep(
LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveTranslucency),
static entity => entity.ServerGuid == 0,
_staticPresentation.RemoveTranslucency),
LandblockRetirementStage.PluginProjection =>
ticket.RunEntityStep(
LandblockRetirementStage.PluginProjection,
@ -152,20 +150,4 @@ public sealed class LandblockPresentationRetirementOwner
_ => LandblockRetirementOperationResult.NoWork,
};
}
private void RemoveLighting(WorldEntity entity)
{
if (entity.ServerGuid == 0)
_staticPresentation.RemoveLighting(entity);
else
_lighting.UnregisterOwner(entity.Id);
}
private void RemoveTranslucency(WorldEntity entity)
{
if (entity.ServerGuid == 0)
_staticPresentation.RemoveTranslucency(entity);
else
_translucency.ClearEntity(entity.Id);
}
}

View file

@ -242,6 +242,13 @@ public sealed class LandblockRetirementTicket
/// </summary>
public sealed class LandblockRetirementCoordinator
{
private enum BudgetedAdvanceResult : byte
{
Progressed,
Yielded,
Failed,
}
private enum RequestKind : byte
{
BeginFull,
@ -275,7 +282,11 @@ public sealed class LandblockRetirementCoordinator
_requiredPresentationStages;
private readonly Action<GpuLandblockRetirement>? _onDetached;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly Queue<LandblockRetirementTicket> _pendingOrder = new();
private readonly LinkedList<LandblockRetirementTicket> _pendingOrder = new();
private readonly Dictionary<
LandblockRetirementTicket,
LinkedListNode<LandblockRetirementTicket>> _pendingNodes =
new(ReferenceEqualityComparer.Instance);
private readonly List<uint> _completedIds = new();
private readonly Queue<Request> _requests = new();
private readonly HashSet<Request> _seenDuringDrain = new();
@ -326,6 +337,104 @@ public sealed class LandblockRetirementCoordinator
ReferenceEquals(_state, state);
internal bool UsesBudgetedSteps => _advancePresentationStep is not null;
/// <summary>
/// Adopts exact receipts produced by the atomic origin-recenter spatial
/// swap. Spatial detachment has already committed; this method only
/// establishes the same retryable presentation ledgers as BeginFull.
/// </summary>
internal Exception? AdoptDetachedFull(
IReadOnlyList<GpuLandblockRetirement> retirements)
{
ArgumentNullException.ThrowIfNull(retirements);
var incomingIds = new HashSet<uint>();
for (int index = 0; index < retirements.Count; index++)
{
GpuLandblockRetirement retirement = retirements[index];
if (retirement.Kind != LandblockRetirementKind.Full)
{
throw new ArgumentException(
"Origin-recenter adoption accepts only full retirements.",
nameof(retirements));
}
uint canonical = Canonicalize(retirement.LandblockId);
if (!incomingIds.Add(canonical))
{
throw new ArgumentException(
$"Origin-recenter receipts contain duplicate landblock " +
$"0x{canonical:X8}.",
nameof(retirements));
}
if (_pending.TryGetValue(
canonical,
out List<LandblockRetirementTicket>? existing)
&& existing.Any(
static ticket =>
ticket.Kind == LandblockRetirementKind.Full))
{
throw new InvalidOperationException(
$"Landblock 0x{canonical:X8} already has a full " +
"retirement receipt.");
}
}
LandblockRetirementStage required =
CoreStages
| _requiredPresentationStages(LandblockRetirementKind.Full);
List<Exception>? failures = null;
for (int index = 0; index < retirements.Count; index++)
{
GpuLandblockRetirement retirement = retirements[index];
uint canonical = Canonicalize(retirement.LandblockId);
_pending.TryGetValue(
canonical,
out List<LandblockRetirementTicket>? existing);
var ticket = new LandblockRetirementTicket(
retirement,
required);
if (existing is null)
{
existing = new List<LandblockRetirementTicket>(1);
_pending.Add(canonical, existing);
}
existing.Add(ticket);
PendingCount++;
if (_advancePresentationStep is not null)
EnqueueBudgetedTicket(ticket);
try
{
_onDetached?.Invoke(retirement);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
if (_advancePresentationStep is null)
{
AdvanceTicket(ticket);
if (ticket.IsComplete)
{
existing.Remove(ticket);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
}
}
}
return failures switch
{
null => null,
{ Count: 1 } => failures[0],
_ => new AggregateException(
"One or more detached landblock observers failed.",
failures),
};
}
public void BeginFull(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
@ -365,40 +474,56 @@ public sealed class LandblockRetirementCoordinator
return;
}
while (_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
while (_pendingOrder.First is { } head)
{
LandblockRetirementTicket ticket = head.Value;
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)
if (AdvanceBudgetedTicket(ticket, meter)
!= BudgetedAdvanceResult.Progressed)
return;
if (ticket.IsComplete)
RemoveCompletedTicket(ticket);
}
}
LandblockRetirementOperationResult result = AdvanceTicketOne(ticket);
if (result == LandblockRetirementOperationResult.Failed)
/// <summary>
/// Advances only retirement receipts for one canonical landblock. This is
/// the dependency lane used by a destination replacement: unrelated
/// detached-world cleanup retains stable FIFO order, while the exact old
/// owner that fences the destination may complete out of order under the
/// same frame meter.
/// </summary>
internal void AdvancePriority(uint landblockId, StreamingWorkMeter meter)
{
ArgumentNullException.ThrowIfNull(meter);
if (_advancePresentationStep is null)
{
Advance();
return;
}
uint canonical = Canonicalize(landblockId);
while (_pending.TryGetValue(
canonical,
out List<LandblockRetirementTicket>? tickets))
{
if (tickets.Count == 0)
throw new InvalidOperationException(
"A pending retirement owner has no tickets.");
LandblockRetirementTicket ticket = tickets[0];
if (!ticket.IsComplete
&& AdvanceBudgetedTicket(ticket, meter)
!= BudgetedAdvanceResult.Progressed)
{
meter.Fail();
return;
}
meter.Complete();
if (ticket.IsComplete)
RemoveCompletedTicket(ticket);
}
@ -535,7 +660,7 @@ public sealed class LandblockRetirementCoordinator
existing.Add(ticket);
PendingCount++;
if (_advancePresentationStep is not null)
_pendingOrder.Enqueue(ticket);
EnqueueBudgetedTicket(ticket);
_onDetached?.Invoke(stateRetirement);
@ -592,8 +717,9 @@ public sealed class LandblockRetirementCoordinator
private void AdvanceBudgetedEagerAttempt()
{
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
if (_pendingOrder.First is not { } head)
return;
LandblockRetirementTicket ticket = head.Value;
if (ticket.IsComplete)
{
@ -657,15 +783,63 @@ public sealed class LandblockRetirementCoordinator
return result;
}
private void RemoveCompletedTicket(LandblockRetirementTicket ticket)
private BudgetedAdvanceResult AdvanceBudgetedTicket(
LandblockRetirementTicket ticket,
StreamingWorkMeter meter)
{
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? head)
|| !ReferenceEquals(head, ticket))
LandblockRetirementStage stage = ticket.NextIncompleteStage;
if (stage == LandblockRetirementStage.None)
{
throw new InvalidOperationException(
"Retirement FIFO completion did not match its active head.");
"An incomplete retirement ticket has no unfinished stage.");
}
_pendingOrder.Dequeue();
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 BudgetedAdvanceResult.Yielded;
LandblockRetirementOperationResult result = AdvanceTicketOne(ticket);
if (result == LandblockRetirementOperationResult.Failed)
{
meter.Fail();
return BudgetedAdvanceResult.Failed;
}
meter.Complete();
return BudgetedAdvanceResult.Progressed;
}
private void EnqueueBudgetedTicket(LandblockRetirementTicket ticket)
{
LinkedListNode<LandblockRetirementTicket> node =
_pendingOrder.AddLast(ticket);
if (!_pendingNodes.TryAdd(ticket, node))
{
_pendingOrder.Remove(node);
throw new InvalidOperationException(
"A retirement ticket cannot enter the budgeted FIFO twice.");
}
}
private void RemoveCompletedTicket(LandblockRetirementTicket ticket)
{
if (!_pendingNodes.Remove(
ticket,
out LinkedListNode<LandblockRetirementTicket>? node))
{
throw new InvalidOperationException(
"Completed retirement ticket is missing from its budgeted order.");
}
_pendingOrder.Remove(node);
uint id = Canonicalize(ticket.LandblockId);
if (!_pending.TryGetValue(id, out List<LandblockRetirementTicket>? tickets)

View file

@ -506,6 +506,12 @@ internal sealed class LocalPlayerTeleportController
return;
break;
case TeleportAnimEvent.PlayExitSound:
// gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination
// cell blocking at the exact portal/world viewport swap.
// LoginComplete remains one WorldFadeIn second later.
_worldReveal.RevealWorldViewport();
if (!IsCurrentLifetime(generation, sequence))
return;
_presentation.ExitTunnel();
if (!IsCurrentLifetime(generation, sequence))
return;

View file

@ -58,6 +58,9 @@ internal sealed class StreamingCompletionQueue
public int Count => _count;
public long RetainedCpuBytes => _retainedCpuBytes;
public bool HasPriority(StreamingCompletionPriority priority) =>
_queues[(int)priority].Count != 0;
public void Enqueue(StreamingQueuedCompletion completion)
{
_queues[(int)completion.Priority].Enqueue(completion);
@ -69,10 +72,15 @@ internal sealed class StreamingCompletionQueue
public bool TryPeekNext(
Func<LandblockStreamResult, bool> isBlocked,
out StreamingQueuedCompletion? completion)
out StreamingQueuedCompletion? completion,
StreamingCompletionPriority maximumPriority =
StreamingCompletionPriority.Far)
{
ArgumentNullException.ThrowIfNull(isBlocked);
for (int priority = 0; priority < _queues.Length; priority++)
int lastPriority = Math.Min(
(int)maximumPriority,
_queues.Length - 1);
for (int priority = 0; priority <= lastPriority; priority++)
{
Queue<StreamingQueuedCompletion> queue = _queues[priority];
if (queue.Count == 0)

View file

@ -33,10 +33,8 @@ public sealed class StreamingController
public bool PendingLoadsCleared;
public bool CompletionQueueCleared;
public bool RegionCleared;
public List<uint>? ResidentIds;
public int RetirementCursor;
public bool SpatialGenerationDetached;
public bool PreparationCommitted;
public IEnumerator<uint>? ResidentEnumerator;
public (int X, int Y, bool IsSealedDungeon)? Destination;
public bool DestinationConfigured;
public bool DestinationLoadEnqueued;
@ -75,7 +73,6 @@ public sealed class StreamingController
private string? _maximumWorkFrameStage;
private double _maximumWorkOperationMilliseconds;
private string? _maximumWorkOperationStage;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration;
@ -538,7 +535,8 @@ public sealed class StreamingController
return true;
}
private bool ConvergePendingPublications()
private bool ConvergePendingPublications(
bool preferDestination = false)
{
IReadOnlyList<LandblockStreamResult> pending =
_presentation.GetPendingPublicationResults();
@ -554,22 +552,48 @@ public sealed class StreamingController
try
{
bool progressed = false;
for (int i = 0; i < pending.Count; i++)
bool destinationPending = false;
if (preferDestination)
{
LandblockStreamResult result = pending[i];
using StreamingWorkMeter.LaneScope lane =
_activeWorkMeter.EnterLane(
IsDestinationWork(result.LandblockId)
? StreamingWorkLane.Destination
: StreamingWorkLane.NonDestination);
LandblockPublicationAdvance advance =
_presentation.ResumePublication(
result,
_activeWorkMeter,
ensureProgress: !progressed);
progressed |= advance.Progressed;
if (!advance.Completed)
return false;
for (int i = 0; i < pending.Count; i++)
{
if (!IsDestinationWork(pending[i].LandblockId))
continue;
destinationPending = true;
break;
}
}
// A partially prepared background landblock must not serialize the
// reveal-critical destination behind dictionary insertion order.
// Each publication owns independent receipts, so destination
// transactions can safely resume first without replaying or
// discarding the non-destination cursor.
for (int destinationPass = 1; destinationPass >= 0; destinationPass--)
{
bool requireDestination = destinationPass != 0;
for (int i = 0; i < pending.Count; i++)
{
LandblockStreamResult result = pending[i];
bool isDestination = IsDestinationWork(result.LandblockId);
if (destinationPending && !isDestination)
continue;
if (isDestination != requireDestination)
continue;
using StreamingWorkMeter.LaneScope lane =
_activeWorkMeter.EnterLane(
isDestination
? StreamingWorkLane.Destination
: StreamingWorkLane.NonDestination);
LandblockPublicationAdvance advance =
_presentation.ResumePublication(
result,
_activeWorkMeter,
ensureProgress: !progressed);
progressed |= advance.Progressed;
if (!advance.Completed)
return false;
}
}
return true;
}
@ -615,6 +639,7 @@ public sealed class StreamingController
bool retirementWasPending =
_presentation.PendingRetirementCount != 0;
TryAdvanceFullWindowRetirement(meter);
AdvanceDestinationRetirementDependency(meter);
if (_presentation.UsesBudgetedRetirementSteps
|| retirementWasPending)
{
@ -636,6 +661,7 @@ public sealed class StreamingController
bool retirementWasPending =
_presentation.PendingRetirementCount != 0;
TryAdvanceOriginRecenterPreparation(meter);
AdvanceDestinationRetirementDependency(meter);
if (_presentation.UsesBudgetedRetirementSteps
|| retirementWasPending)
{
@ -657,8 +683,15 @@ public sealed class StreamingController
// or retire the fully known owner set through the normal ledger.
bool retirementWasPendingAtFrameStart =
_presentation.PendingRetirementCount != 0;
AdvanceDestinationRetirementDependency(meter);
_presentation.AdvanceRetirements(meter);
if (!ConvergePendingPublications())
bool destinationPublicationIncomplete =
_destinationReservation is not null
&& !IsRenderNeighborhoodResident(
DestinationLandblockId,
DestinationRadius);
if (!ConvergePendingPublications(
preferDestination: destinationPublicationIncomplete))
return;
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
@ -691,7 +724,8 @@ public sealed class StreamingController
NormalTick(observerCx, observerCy);
}
DrainAndApply();
DrainAndApply(
preferDestination: destinationPublicationIncomplete);
// 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.
@ -709,6 +743,22 @@ public sealed class StreamingController
}
}
private void AdvanceDestinationRetirementDependency(
StreamingWorkMeter meter)
{
if (_destinationReservation is null
|| !_presentation.IsRetirementPending(DestinationLandblockId))
{
return;
}
using StreamingWorkMeter.LaneScope lane =
meter.EnterLane(StreamingWorkLane.Destination);
_presentation.AdvancePriorityRetirement(
DestinationLandblockId,
meter);
}
private void ObserveWorkLifetime(StreamingWorkMeterSnapshot snapshot)
{
_lifetimeWorkOverruns = SaturatingAdd(
@ -912,21 +962,53 @@ public sealed class StreamingController
{
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff();
foreach (var id in bootstrap.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
EnqueueLoadsByRevealPriority(
bootstrap.ToLoadNear,
LandblockStreamJobKind.LoadNear);
foreach (var id in bootstrap.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
_region.MarkResidentFromBootstrap();
}
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{
var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
EnqueueLoadsByRevealPriority(
diff.ToPromote,
LandblockStreamJobKind.PromoteToNear);
EnqueueLoadsByRevealPriority(
diff.ToLoadNear,
LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToDemote) DemoteLandblock(id);
foreach (var id in diff.ToUnload) EnqueueUnload(id);
}
}
/// <summary>
/// Places the canonical reveal neighborhood at the front of the worker's
/// existing Near FIFO. Completion priority alone is too late: a
/// non-destination build can otherwise begin a multi-frame publication
/// before the destination result has even reached the render thread.
/// </summary>
private void EnqueueLoadsByRevealPriority(
IReadOnlyList<uint> landblockIds,
LandblockStreamJobKind kind,
bool skipLoaded = false)
{
for (int destinationPass = 1; destinationPass >= 0; destinationPass--)
{
bool requireDestination = destinationPass != 0;
for (int i = 0; i < landblockIds.Count; i++)
{
uint id = landblockIds[i];
if ((!skipLoaded || !_state.IsLoaded(id))
&& IsDestinationWork(id) == requireDestination)
{
EnqueueLoad(id, kind);
}
}
}
}
/// <summary>
/// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon
@ -1016,8 +1098,10 @@ public sealed class StreamingController
if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id);
var boot = rebuilt.ComputeFirstTickDiff();
foreach (var id in boot.ToLoadNear)
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
EnqueueLoadsByRevealPriority(
boot.ToLoadNear,
LandblockStreamJobKind.LoadNear,
skipLoaded: true);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
@ -1053,8 +1137,10 @@ public sealed class StreamingController
}
/// <summary>
/// Advances every retained old-window teardown and reports whether the
/// composition root may safely change the shared world origin.
/// Reports whether every old resident has been detached and captured by
/// an exact retirement receipt. Deferred receipt cleanup may continue
/// after the shared origin changes; same-landblock publication remains
/// fenced by <see cref="IsPublicationBlockedByRetirement"/>.
/// </summary>
internal bool IsOriginRecenterRetirementComplete()
{
@ -1062,14 +1148,14 @@ public sealed class StreamingController
throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
return _originRecenterRetirement.PreparationCommitted
&& _presentation.PendingRetirementCount == 0;
return _originRecenterRetirement.PreparationCommitted;
}
/// <summary>
/// Releases the streaming bootstrap gate after the composition root has
/// committed the new shared origin. No old-window presentation owner may
/// still be pending at this edge.
/// committed the new shared origin. Old detached receipts continue under
/// the shared frame meter and fence only a replacement publication with
/// the same canonical landblock key.
/// </summary>
internal bool TryCommitOriginRecenter(
int destinationX,
@ -1104,10 +1190,6 @@ public sealed class StreamingController
if (!transaction.PreparationCommitted)
throw new InvalidOperationException(
"Streaming-origin retirement preparation has not completed.");
if (_presentation.PendingRetirementCount != 0)
throw new InvalidOperationException(
"The streaming origin cannot change while old-window presentation retirement is pending.");
var destination = (destinationX, destinationY, isSealedDungeon);
if (transaction.Destination is { } retained && retained != destination)
{
@ -1174,8 +1256,6 @@ public sealed class StreamingController
{
if (_originRecenterRetirement is not { PreparationCommitted: true })
return false;
if (_presentation.PendingRetirementCount != 0)
return false;
_collapsed = false;
_collapsedCenter = 0u;
@ -1293,35 +1373,33 @@ public sealed class StreamingController
}
transaction.RegionCleared = true;
}
if (transaction.ResidentIds is null)
{
transaction.ResidentIds = [];
transaction.ResidentEnumerator =
_state.LoadedLandblockIds.GetEnumerator();
}
while (transaction.ResidentEnumerator is { } residentEnumerator)
if (!transaction.SpatialGenerationDetached)
{
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(EntityOperations: 1),
"recenter-capture-resident-id");
new StreamingWorkCost(
EntityOperations:
_state.OriginRecenterSpatialOperationCount),
"recenter-detach-spatial-generation",
ensureProgress: true);
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;
}
GpuWorldRecenterRetirement detached =
_presentation.DetachAllForOriginRecenter();
transaction.SpatialGenerationDetached = true;
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount =
detached.Landblocks.Count;
meter.Complete();
if (detached.ObserverFailure is not null)
{
Console.WriteLine(
"streaming: committed origin-recenter spatial " +
$"generation reported failure: {detached.ObserverFailure}");
return false;
}
}
catch
{
@ -1330,46 +1408,6 @@ public sealed class StreamingController
}
}
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)
{
// A delivered visibility-observer failure may surface after
// detachment has committed. Advance that exact cursor only
// when world state proves the old resident is unreachable;
// 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}");
return false;
}
}
transaction.PreparationCommitted = true;
return true;
}
@ -1567,17 +1605,24 @@ public sealed class StreamingController
/// order but no class bypasses time, bytes, entities, uploads, or retire
/// operation limits.
/// </summary>
private void DrainAndApply()
private void DrainAndApply(bool preferDestination = false)
{
StreamingWorkMeter meter = _activeWorkMeter
?? throw new InvalidOperationException(
"Completion scheduling requires an active frame meter.");
AdmitCompletions(meter);
bool destinationQueued =
preferDestination
&& _completionQueue.HasPriority(
StreamingCompletionPriority.Destination);
bool executed = false;
while (_completionQueue.TryPeekNext(
_isPublicationBlockedByRetirement,
out StreamingQueuedCompletion? completion))
out StreamingQueuedCompletion? completion,
destinationQueued
? StreamingCompletionPriority.Unload
: StreamingCompletionPriority.Far))
{
StreamingQueuedCompletion work = completion
?? throw new InvalidOperationException(
@ -1650,8 +1695,13 @@ public sealed class StreamingController
: ClassifyCompletion(result);
LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(result);
// A stale result belongs to a generation that was already
// cancelled. Reading it releases worker-outbox ownership; it does
// not admit payload into the current world. Charge only elapsed
// time so a large completed old window cannot consume the
// destination generation's completion quota for many frames.
StreamingWorkCost admissionCost = stale
? new StreamingWorkCost(CompletionAdmissions: 1)
? default
: new StreamingWorkCost(
CompletionAdmissions:
estimate.Work.CompletionAdmissions,

View file

@ -3,11 +3,11 @@ using AcDream.App.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Coordinates streaming-origin lifetime boundaries. Presentation must retire
/// the complete old window first; a teleport then changes
/// Coordinates streaming-origin lifetime boundaries. Spatial ownership must
/// detach the complete old window first; a teleport then changes
/// <see cref="LiveWorldOriginState"/> before destination streaming resumes,
/// while a session boundary releases the controller without recentering. The
/// transaction remains pending across frames when an owner teardown needs retry.
/// while deferred resource receipts continue under the shared frame budget.
/// A session boundary releases the controller without recentering.
/// </summary>
internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence
{
@ -72,9 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
}
/// <summary>
/// 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.
/// Observes old-window spatial detachment and commits the new origin once
/// every detached owner has an exact retirement receipt. Returns true only
/// after the destination streaming gate has been released.
/// </summary>
public bool Advance()
{

View file

@ -22,7 +22,12 @@ public sealed record StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 2.0,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 8 * MiB,
MaxEntityOperations: 256,
// Entity cursors are intentionally very small operations (often one
// dictionary/index write). The elapsed-time ceiling remains the
// authoritative CPU guard; 256 left more than 90% of that budget
// unused and stretched destination publication past retail's
// five-second wait-notice edge.
MaxEntityOperations: 4_096,
MaxGpuUploadBytes: 8 * MiB,
MaxGlRetireOperations: 64,
DestinationReserveFraction: 0.75f);

View file

@ -15,6 +15,16 @@ internal interface IWorldRevealStreamingScheduler
void EndDestinationReservation(long revealGeneration);
}
/// <summary>
/// Generation-scoped renderer-resource profile used only while the normal
/// world viewport is withheld for a login or portal destination.
/// </summary>
internal interface IWorldRevealRenderResourceScheduler
{
void BeginDestinationReveal(long revealGeneration);
void EndDestinationReveal(long revealGeneration);
}
/// <summary>
/// Owns one login/portal reveal lifetime across readiness and lifecycle
/// diagnostics. This keeps the destination barrier and its observations on a
@ -27,7 +37,9 @@ internal sealed class WorldRevealCoordinator
private readonly WorldRevealLifecycleTelemetry _lifecycle;
private readonly WorldGenerationQuiescence? _quiescence;
private readonly IWorldRevealStreamingScheduler? _streaming;
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
private long _activeGeneration;
private bool _worldViewportReleased;
public WorldRevealCoordinator(
Func<uint, int, bool> isRenderNeighborhoodReady,
@ -39,7 +51,8 @@ internal sealed class WorldRevealCoordinator
Func<uint, bool> isSpawnClaimUnhydratable,
Action<string>? log = null,
WorldGenerationQuiescence? quiescence = null,
IWorldRevealStreamingScheduler? streaming = null)
IWorldRevealStreamingScheduler? streaming = null,
IWorldRevealRenderResourceScheduler? renderResources = null)
{
_readiness = new WorldRevealReadinessBarrier(
isRenderNeighborhoodReady,
@ -52,6 +65,7 @@ internal sealed class WorldRevealCoordinator
_lifecycle = new WorldRevealLifecycleTelemetry(log);
_quiescence = quiescence;
_streaming = streaming;
_renderResources = renderResources;
}
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
@ -66,11 +80,13 @@ internal sealed class WorldRevealCoordinator
_readiness.Begin();
long generation = _lifecycle.Begin(kind);
_activeGeneration = generation;
_worldViewportReleased = false;
_quiescence?.Begin(generation);
_streaming?.BeginDestinationReservation(
generation,
destinationCell,
WorldRevealReadinessBarrier.RequiredRenderRadius(destinationCell));
_renderResources?.BeginDestinationReveal(generation);
return generation;
}
@ -99,26 +115,43 @@ internal sealed class WorldRevealCoordinator
public bool ObserveWait(TimeSpan elapsed) =>
_lifecycle.ObserveWait(elapsed);
/// <summary>
/// Reopens the destination world at retail's TunnelFadeOut -&gt;
/// WorldFadeIn viewport swap without completing the one-second
/// WorldFadeIn/LoginComplete tail.
/// </summary>
public void RevealWorldViewport()
{
long generation = _activeGeneration;
if (generation == 0 || _worldViewportReleased)
return;
_streaming?.EndDestinationReservation(generation);
_renderResources?.EndDestinationReveal(generation);
_quiescence?.End(generation);
_worldViewportReleased = true;
}
public void Complete()
{
_lifecycle.Complete();
EndQuiescence();
EndLifetime();
}
public void Cancel()
{
_lifecycle.Cancel();
EndQuiescence();
EndLifetime();
}
private void EndQuiescence()
private void EndLifetime()
{
long generation = _activeGeneration;
if (generation == 0)
return;
RevealWorldViewport();
_activeGeneration = 0;
_streaming?.EndDestinationReservation(generation);
_quiescence?.End(generation);
_worldViewportReleased = false;
}
}

View file

@ -0,0 +1,123 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.World;
internal enum DormantCreateDisposition
{
NoDormantRecord,
ExistingGeneration,
NewGeneration,
StaleGeneration,
}
/// <summary>
/// Cold, data-only ownership for ACE objects that left the client's active
/// visibility set. Active render, physics, animation, effect, and collision
/// owners are torn down before a snapshot enters this store.
///
/// ACE retains KnownObjects across ordinary teleports and does not reliably
/// repeat CreateObject when a destination is revisited. Keeping the accepted
/// CreateObject snapshot therefore preserves the server object's identity
/// without retaining any frame-time or GPU work.
/// </summary>
internal sealed class DormantLiveEntityStore
{
private readonly Dictionary<uint, WorldSession.EntitySpawn> _spawns = new();
public int Count => _spawns.Count;
public DormantCreateDisposition ClassifyCreate(
WorldSession.EntitySpawn incoming)
{
if (!_spawns.TryGetValue(incoming.Guid, out WorldSession.EntitySpawn retained))
return DormantCreateDisposition.NoDormantRecord;
if (PhysicsTimestampGate.IsNewer(
retained.InstanceSequence,
incoming.InstanceSequence))
{
return DormantCreateDisposition.NewGeneration;
}
if (PhysicsTimestampGate.IsNewer(
incoming.InstanceSequence,
retained.InstanceSequence))
{
return DormantCreateDisposition.StaleGeneration;
}
return DormantCreateDisposition.ExistingGeneration;
}
public void Retain(WorldSession.EntitySpawn snapshot)
{
if (_spawns.TryGetValue(snapshot.Guid, out WorldSession.EntitySpawn current)
&& PhysicsTimestampGate.IsNewer(
snapshot.InstanceSequence,
current.InstanceSequence))
{
return;
}
_spawns[snapshot.Guid] = snapshot;
}
public bool RemoveThroughAcceptedCreate(WorldSession.EntitySpawn accepted)
{
DormantCreateDisposition disposition = ClassifyCreate(accepted);
if (disposition is DormantCreateDisposition.StaleGeneration
or DormantCreateDisposition.NoDormantRecord)
{
return false;
}
return _spawns.Remove(accepted.Guid);
}
public bool RemoveExact(DeleteObject.Parsed delete)
{
if (!_spawns.TryGetValue(delete.Guid, out WorldSession.EntitySpawn retained)
|| retained.InstanceSequence != delete.InstanceSequence)
{
return false;
}
return _spawns.Remove(delete.Guid);
}
public WorldSession.EntitySpawn[] SnapshotLandblock(uint landblockId)
{
uint canonical = CanonicalLandblock(landblockId);
return _spawns.Values
.Where(spawn => spawn.Position is { } position
&& CanonicalLandblock(position.LandblockId) == canonical
&& spawn.SetupTableId is not null)
.ToArray();
}
public bool Contains(uint guid, ushort generation) =>
_spawns.TryGetValue(guid, out WorldSession.EntitySpawn spawn)
&& spawn.InstanceSequence == generation;
public bool TryGetExact(
uint guid,
ushort generation,
out WorldSession.EntitySpawn snapshot)
{
if (_spawns.TryGetValue(guid, out snapshot)
&& snapshot.InstanceSequence == generation)
{
return true;
}
snapshot = default;
return false;
}
public void Clear() => _spawns.Clear();
private static uint CanonicalLandblock(uint cellId) =>
(cellId & 0xFFFF0000u) | 0xFFFFu;
}

View file

@ -20,6 +20,7 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
private readonly ClientObjectTable _objects;
private readonly ILiveEntityTeardownCoordinator _teardown;
private readonly ILocalPlayerIdentitySource _identity;
private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic;
public LiveEntityDeletionController(
@ -27,12 +28,14 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
ClientObjectTable objects,
ILiveEntityTeardownCoordinator teardown,
ILocalPlayerIdentitySource identity,
DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_dormant = dormant ?? new DormantLiveEntityStore();
_diagnostic = diagnostic;
}
@ -46,7 +49,8 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
if (delete.Guid == _identity.ServerGuid)
return false;
if (!_runtime.TryGetRecord(delete.Guid, out _))
bool hasActiveRecord = _runtime.TryGetRecord(delete.Guid, out _);
if (!hasActiveRecord)
_teardown.ForgetUnknownOwner(delete.Guid);
bool removed = _runtime.UnregisterLiveEntity(
@ -54,16 +58,45 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
isLocalPlayer: false,
beforeTeardown: () =>
ObjectTableWiring.ApplyEntityDelete(_objects, delete));
bool removedDormant = _dormant.RemoveExact(delete);
if (!removed && removedDormant)
ObjectTableWiring.ApplyEntityDelete(_objects, delete);
if (removed)
{
_dormant.RemoveExact(delete);
_diagnostic?.Invoke(
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
}
return removed;
else if (removedDormant)
{
_diagnostic?.Invoke(
$"live: delete dormant guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
}
return removed || removedDormant;
}
public bool Prune(LiveEntityPruneCandidate candidate) =>
Delete(new DeleteObject.Parsed(
candidate.ServerGuid,
candidate.Generation));
public bool Prune(LiveEntityPruneCandidate candidate)
{
if (!_runtime.TryGetRecord(
candidate.ServerGuid,
out LiveEntityRecord record)
|| record.Generation != candidate.Generation)
{
return false;
}
WorldSession.EntitySpawn snapshot = record.Snapshot;
bool removed = _runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(
candidate.ServerGuid,
candidate.Generation),
isLocalPlayer: false);
if (!removed)
return false;
_dormant.Retain(snapshot);
_diagnostic?.Invoke(
$"live: dormant guid=0x{candidate.ServerGuid:X8} instSeq={candidate.Generation}");
return true;
}
}

View file

@ -178,6 +178,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
private readonly IAcceptedLocalPhysicsTimestampPublisher _timestamps;
private readonly ILocalPlayerIdentitySource _identity;
private readonly LiveEntityDeletionController _deletion;
private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic;
public LiveEntityHydrationController(
@ -192,6 +193,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
IAcceptedLocalPhysicsTimestampPublisher timestamps,
ILocalPlayerIdentitySource identity,
LiveEntityDeletionController deletion,
DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
@ -205,12 +207,41 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_timestamps = timestamps ?? throw new ArgumentNullException(nameof(timestamps));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_deletion = deletion ?? throw new ArgumentNullException(nameof(deletion));
_dormant = dormant ?? new DormantLiveEntityStore();
_diagnostic = diagnostic;
}
internal event Action<uint>? AppearanceApplied;
public void OnCreate(WorldSession.EntitySpawn spawn)
{
DormantCreateDisposition dormantDisposition =
_dormant.ClassifyCreate(spawn);
if (dormantDisposition is DormantCreateDisposition.StaleGeneration)
return;
// A same-incarnation packet must advance from the exact accepted
// dormant timestamps, not seed a fresh gate from whichever packet
// happened to wake the object. Rehydrate the cold canonical snapshot,
// then let the ordinary same-generation path compare every channel.
if (dormantDisposition is DormantCreateDisposition.ExistingGeneration
&& _dormant.TryGetExact(
spawn.Guid,
spawn.InstanceSequence,
out WorldSession.EntitySpawn retained))
{
OnCreateCore(retained, dormantDisposition);
if (!spawn.Equals(retained))
OnCreateCore(spawn, DormantCreateDisposition.NoDormantRecord);
return;
}
OnCreateCore(spawn, dormantDisposition);
}
private void OnCreateCore(
WorldSession.EntitySpawn spawn,
DormantCreateDisposition dormantDisposition)
{
// DatCollection uses one mutable reader cursor shared with streaming.
// Registration, canonical reread, and projection construction remain
@ -228,9 +259,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
// An accepted retransmit can still be parked behind a retryable
// teardown tombstone. It owns no active record and therefore must
// not mutate retained qualities or route an update tail.
// not mutate retained qualities or route an update tail. Keep its
// dormant snapshot until an active record actually accepts
// ownership; otherwise a transient teardown failure would discard
// the only ACE revisit source.
if (registration.Record is not { } record)
return;
_dormant.RemoveThroughAcceptedCreate(spawn);
ulong createIntegrationVersion = record.CreateIntegrationVersion;
try
@ -243,7 +279,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_objects,
spawn,
replaceGeneration: result.Disposition is
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration,
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration
|| dormantDisposition is DormantCreateDisposition.NewGeneration,
accepting: () => _runtime.IsCurrentCreateIntegration(
record,
createIntegrationVersion))
@ -420,6 +457,11 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
/// </summary>
public void OnLandblockLoaded(uint loadedLandblockId)
{
WorldSession.EntitySpawn[] dormant =
_dormant.SnapshotLandblock(loadedLandblockId);
for (int i = 0; i < dormant.Length; i++)
OnCreate(dormant[i]);
if (_runtime.Count == 0)
return;

View file

@ -89,6 +89,7 @@ internal sealed class LiveEntityLivenessController
private readonly LiveEntityRuntime _runtime;
private readonly ILocalPlayerIdentitySource _identity;
private readonly ILiveEntityPruneSink _prune;
private readonly DormantLiveEntityStore? _dormant;
private readonly LiveEntityLivenessTracker _tracker = new();
private readonly List<LiveEntityLivenessSample> _samples = new();
private double _nextMaintenanceAt;
@ -96,11 +97,13 @@ internal sealed class LiveEntityLivenessController
public LiveEntityLivenessController(
LiveEntityRuntime runtime,
ILocalPlayerIdentitySource identity,
ILiveEntityPruneSink prune)
ILiveEntityPruneSink prune,
DormantLiveEntityStore? dormant = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
_prune = prune ?? throw new ArgumentNullException(nameof(prune));
_dormant = dormant;
}
public void Tick(double now)
@ -153,6 +156,7 @@ internal sealed class LiveEntityLivenessController
public void Clear()
{
_tracker.Clear();
_dormant?.Clear();
_samples.Clear();
_nextMaintenanceAt = 0;
}

View file

@ -211,7 +211,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
/// The one logical record for a server object incarnation. The record survives
/// loaded/pending landblock movement and temporary loss of its render bucket.
/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the
/// retail 25-second leave-visibility lifecycle ends it.
/// active side of the leave-visibility lifecycle ends it. ACE-compatible
/// cold snapshot ownership lives outside this active runtime.
/// </summary>
public sealed class LiveEntityRecord
{