fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -0,0 +1,20 @@
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable context retained after a landblock's spatial/world-state
/// membership has been detached. Presentation owners consume this snapshot
/// asynchronously from the state transition, so a failed renderer cleanup
/// cannot keep the old landblock logically resident.
/// </summary>
public sealed record GpuLandblockRetirement(
uint LandblockId,
LandblockRetirementKind Kind,
IReadOnlyList<WorldEntity> Entities);
public enum LandblockRetirementKind
{
Full,
NearLayer,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,328 @@
using AcDream.Core.World;
namespace AcDream.App.Streaming;
[Flags]
public enum LandblockRetirementStage : ushort
{
None = 0,
MeshReferences = 1 << 0,
StaticScripts = 1 << 1,
Classification = 1 << 2,
EntityLighting = 1 << 3,
EntityTranslucency = 1 << 4,
PluginProjection = 1 << 5,
Terrain = 1 << 6,
Physics = 1 << 7,
CellVisibility = 1 << 8,
BuildingRegistry = 1 << 9,
EnvironmentCells = 1 << 10,
LegacyPresentation = 1 << 11,
}
/// <summary>
/// One retryable landblock-retirement ledger. Successful owner operations are
/// committed once; a later attempt resumes at the exact failed owner/entity.
/// </summary>
public sealed class LandblockRetirementTicket
{
private readonly Dictionary<LandblockRetirementStage, int> _entityCursors = new();
private readonly Dictionary<LandblockRetirementStage, Exception> _failures = new();
private Exception? _callbackFailure;
private Exception? _lastReportedFailure;
internal LandblockRetirementTicket(
GpuLandblockRetirement state,
LandblockRetirementStage requiredStages)
{
State = state;
RequiredStages = requiredStages;
}
public GpuLandblockRetirement State { get; }
public uint LandblockId => State.LandblockId;
public LandblockRetirementKind Kind => State.Kind;
public IReadOnlyList<WorldEntity> Entities => State.Entities;
public LandblockRetirementStage RequiredStages { get; }
public LandblockRetirementStage CompletedStages { get; private set; }
public bool IsComplete =>
(CompletedStages & RequiredStages) == RequiredStages
&& _callbackFailure is null;
public bool RunOnce(LandblockRetirementStage stage, Action operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return true;
try
{
operation();
CompletedStages |= stage;
_failures.Remove(stage);
return true;
}
catch (Exception error)
{
_failures[stage] = error;
return false;
}
}
public bool RunForEachEntity(
LandblockRetirementStage stage,
Func<WorldEntity, bool> predicate,
Action<WorldEntity> operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return true;
_entityCursors.TryGetValue(stage, out int cursor);
while (cursor < Entities.Count)
{
WorldEntity entity = Entities[cursor];
if (predicate(entity))
{
try
{
operation(entity);
}
catch (Exception error)
{
_entityCursors[stage] = cursor;
_failures[stage] = error;
return false;
}
}
cursor++;
_entityCursors[stage] = cursor;
}
CompletedStages |= stage;
_entityCursors.Remove(stage);
_failures.Remove(stage);
return true;
}
internal void BeginAttempt() => _callbackFailure = null;
internal void RecordCallbackFailure(Exception error) =>
_callbackFailure = error;
internal bool TryTakeNewFailure(out Exception? error)
{
error = _callbackFailure ?? _failures.Values.FirstOrDefault();
if (error is null || ReferenceEquals(error, _lastReportedFailure))
return false;
_lastReportedFailure = error;
return true;
}
private static void ValidateSingleStage(LandblockRetirementStage stage)
{
ushort value = (ushort)stage;
if (value == 0 || (value & (value - 1)) != 0)
throw new ArgumentOutOfRangeException(
nameof(stage),
stage,
"A retirement operation must own exactly one stage bit.");
}
}
/// <summary>
/// Game-window-lifetime owner for landblock retirement. World-state detachment
/// commits first; renderer/cache/script cleanup advances afterward from a
/// per-owner ledger. The coordinator is intentionally shared by replacement
/// <see cref="StreamingController"/> instances so a quality-setting rebuild
/// cannot abandon an unfinished retirement.
/// </summary>
public sealed class LandblockRetirementCoordinator
{
private const LandblockRetirementStage CoreStages =
LandblockRetirementStage.MeshReferences
| LandblockRetirementStage.StaticScripts
| LandblockRetirementStage.Classification;
public const LandblockRetirementStage ProductionPresentationStages =
LandblockRetirementStage.EntityLighting
| LandblockRetirementStage.EntityTranslucency
| LandblockRetirementStage.PluginProjection
| LandblockRetirementStage.Terrain
| LandblockRetirementStage.Physics
| LandblockRetirementStage.CellVisibility
| LandblockRetirementStage.BuildingRegistry
| LandblockRetirementStage.EnvironmentCells;
private readonly GpuWorldState _state;
private readonly Action<LandblockRetirementTicket> _advancePresentation;
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
_requiredPresentationStages;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly List<uint> _completedIds = new();
public LandblockRetirementCoordinator(
GpuWorldState state,
Action<LandblockRetirementTicket> advancePresentation,
Func<LandblockRetirementKind, LandblockRetirementStage>? requiredPresentationStages = null)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(advancePresentation);
_state = state;
_advancePresentation = advancePresentation;
_requiredPresentationStages = requiredPresentationStages
?? (kind => kind == LandblockRetirementKind.Full
? ProductionPresentationStages
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
}
public int PendingCount { get; private set; }
public bool IsPending(uint landblockId) =>
_pending.ContainsKey(Canonicalize(landblockId));
public void BeginFull(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.Full);
public void BeginNearLayer(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.NearLayer);
public void Advance()
{
if (_pending.Count == 0)
return;
_completedIds.Clear();
foreach ((uint id, List<LandblockRetirementTicket> tickets) in _pending)
{
for (int i = tickets.Count - 1; i >= 0; i--)
{
LandblockRetirementTicket ticket = tickets[i];
AdvanceTicket(ticket);
if (!ticket.IsComplete)
continue;
tickets.RemoveAt(i);
PendingCount--;
}
if (tickets.Count == 0)
_completedIds.Add(id);
}
for (int i = 0; i < _completedIds.Count; i++)
_pending.Remove(_completedIds[i]);
}
internal static LandblockRetirementCoordinator CreateLegacy(
GpuWorldState state,
Action<uint>? removeTerrain,
Action<uint>? demoteNearLayer)
{
LandblockRetirementStage Required(LandblockRetirementKind kind) =>
kind switch
{
LandblockRetirementKind.Full when removeTerrain is not null =>
LandblockRetirementStage.LegacyPresentation,
LandblockRetirementKind.NearLayer when demoteNearLayer is not null =>
LandblockRetirementStage.LegacyPresentation,
_ => LandblockRetirementStage.None,
};
void AdvanceLegacy(LandblockRetirementTicket ticket)
{
Action<uint>? callback = ticket.Kind == LandblockRetirementKind.Full
? removeTerrain
: demoteNearLayer;
if (callback is not null)
{
ticket.RunOnce(
LandblockRetirementStage.LegacyPresentation,
() => callback(ticket.LandblockId));
}
}
return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required);
}
private void Begin(uint landblockId, LandblockRetirementKind kind)
{
uint canonical = Canonicalize(landblockId);
if (_pending.TryGetValue(canonical, out List<LandblockRetirementTicket>? existing))
{
for (int i = 0; i < existing.Count; i++)
{
if (existing[i].Kind == kind)
{
AdvanceTicket(existing[i]);
return;
}
}
}
GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
if (stateRetirement is null)
return;
LandblockRetirementStage required =
CoreStages | _requiredPresentationStages(kind);
var ticket = new LandblockRetirementTicket(stateRetirement, required);
if (existing is null)
{
existing = new List<LandblockRetirementTicket>(1);
_pending.Add(canonical, existing);
}
existing.Add(ticket);
PendingCount++;
AdvanceTicket(ticket);
if (ticket.IsComplete)
{
existing.Remove(ticket);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
}
}
private void AdvanceTicket(LandblockRetirementTicket ticket)
{
ticket.BeginAttempt();
ticket.RunOnce(
LandblockRetirementStage.MeshReferences,
() => _state.ReleaseLandblockMeshReferences(ticket.LandblockId));
ticket.RunForEachEntity(
LandblockRetirementStage.StaticScripts,
static entity => entity.ServerGuid == 0,
_state.StopStaticEntityScript);
ticket.RunOnce(
LandblockRetirementStage.Classification,
() => _state.InvalidateLandblockClassification(ticket.LandblockId));
try
{
_advancePresentation(ticket);
}
catch (Exception error)
{
ticket.RecordCallbackFailure(error);
}
if (ticket.TryTakeNewFailure(out Exception? failure))
{
Console.WriteLine(
$"streaming: retirement for 0x{ticket.LandblockId:X8} " +
$"({ticket.Kind}) remains pending: {failure}");
}
}
private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu;
}

View file

@ -57,8 +57,12 @@ public sealed class LandblockStreamer : IDisposable
private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox;
private readonly CancellationTokenSource _cancel = new();
private readonly object _inboxGate = new();
private Thread? _worker;
private Exception? _workerFailure;
private int _disposed;
private readonly object _disposeGate = new();
private bool _disposeCompleted;
/// <summary>
/// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/>
@ -75,7 +79,7 @@ public sealed class LandblockStreamer : IDisposable
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
@ -117,25 +121,26 @@ public sealed class LandblockStreamer : IDisposable
/// <summary>
/// Activate the dedicated background worker thread. Idempotent and
/// thread-safe: concurrent callers will only spawn one worker; subsequent
/// calls are no-ops. Atomic via <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>.
/// calls are no-ops. Serialized with disposal so a worker can never start
/// after the owning DAT lifetime has been released.
/// </summary>
public void Start()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// A.5 T10-T12 follow-up: atomically install the worker so concurrent
// Start() callers don't both pass the null check and spawn duplicate
// threads. Construct the candidate; CAS it into _worker; if we lost
// the race, the candidate goes unstarted and is GCed.
var candidate = new Thread(WorkerLoop)
lock (_disposeGate)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
candidate.Start();
// else: another caller won the race; their thread is running.
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
if (_worker is not null)
return;
var worker = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
worker.Start();
_worker = worker;
}
}
/// <summary>
@ -151,7 +156,7 @@ public sealed class LandblockStreamer : IDisposable
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation));
WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation));
}
/// <summary>
@ -160,9 +165,7 @@ public sealed class LandblockStreamer : IDisposable
/// </summary>
public void EnqueueUnload(uint landblockId, ulong generation = 0)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
WriteJob(new LandblockStreamJob.Unload(landblockId, generation));
}
/// <summary>
@ -176,9 +179,25 @@ public sealed class LandblockStreamer : IDisposable
/// </summary>
public void ClearPendingLoads()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
WriteJob(new LandblockStreamJob.ClearLoads());
}
private void WriteJob(LandblockStreamJob job)
{
// Serialize the writer's terminal transition with enqueue. Without
// this small lifecycle gate a worker crash could complete the channel
// between the caller's state check and TryWrite, silently dropping a
// landblock request. Enqueues are destination-boundary events, not a
// per-frame hot path.
lock (_inboxGate)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
if (_workerFailure is { } failure)
throw new InvalidOperationException("The landblock streaming worker has terminated.", failure);
if (!_inbox.Writer.TryWrite(job))
throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work.");
}
}
/// <summary>
@ -248,6 +267,11 @@ public sealed class LandblockStreamer : IDisposable
{
// Last-ditch: surface via outbox so the caller at least sees
// something. We never retry a crashed worker.
lock (_inboxGate)
{
_workerFailure = ex;
_inbox.Writer.TryComplete(ex);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
}
finally
@ -397,18 +421,21 @@ public sealed class LandblockStreamer : IDisposable
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
_cancel.Cancel();
_inbox.Writer.TryComplete();
// Generous join: the owner disposes the DatCollection after this, which
// unmaps the dats' memory-mapped views — an abandoned worker mid-dat-read
// would take the process down with an AccessViolation in
// MemoryMappedBlockAllocator.ReadBlock (dat-race investigation 2026-06-09).
// Cancellation is honored between jobs, so the wait is bounded by one
// landblock load; 15s only ever elapses if the worker is genuinely hung.
if (_worker is not null && !_worker.Join(TimeSpan.FromSeconds(15)))
Console.Error.WriteLine(
"[streamer] worker did not stop within 15s — dat teardown may race an in-flight load");
_cancel.Dispose();
lock (_disposeGate)
{
if (_disposeCompleted)
return;
System.Threading.Interlocked.Exchange(ref _disposed, 1);
_cancel.Cancel();
lock (_inboxGate)
_inbox.Writer.TryComplete();
// The owner releases the memory-mapped DAT immediately after this
// object. Join the actual worker without a grace-period timeout so
// no native read can survive into that teardown.
_worker?.Join();
_cancel.Dispose();
_disposeCompleted = true;
}
}
}

View file

@ -22,9 +22,8 @@ public sealed class StreamingController
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action<uint>? _demoteNearLayer;
private readonly Action? _clearPendingLoads;
private readonly LandblockRetirementCoordinator _retirements;
/// <summary>
/// #138: fired after a landblock's entity layer (re)loads — both the full
@ -42,6 +41,9 @@ public sealed class StreamingController
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration;
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
// unload cannot mutate the replacement window after a portal recenter.
@ -63,23 +65,15 @@ public sealed class StreamingController
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
/// scenery + entities). Runtime changes go through
/// <see cref="ReconfigureRadii"/>.
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — the
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
/// </remarks>
public int NearRadius { get; }
public int NearRadius { get; private set; }
/// <summary>
/// Far-tier radius (LBs from observer that load terrain only). Set at
/// construction; readable thereafter.
/// Far-tier radius (LBs from observer that load terrain only).
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
/// </remarks>
public int FarRadius { get; }
public int FarRadius { get; private set; }
/// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
@ -194,22 +188,122 @@ public sealed class StreamingController
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_demoteNearLayer = demoteNearLayer;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
_state = state;
_retirements = retirementCoordinator
?? LandblockRetirementCoordinator.CreateLegacy(
state,
removeTerrain,
demoteNearLayer);
NearRadius = nearRadius;
FarRadius = farRadius;
}
/// <summary>
/// Reconciles an active outdoor streaming window to new quality radii
/// without replacing the worker/controller or blindly bootstrapping data
/// already published in <see cref="GpuWorldState"/>. Pending jobs from the
/// old window are invalidated by generation; resident blocks are retained,
/// promoted, demoted, loaded, or unloaded from their actual current tier.
/// A sealed dungeon remains radius-zero until its normal exit edge while
/// remembering the new outdoor radii for that exit.
/// </summary>
public void ReconfigureRadii(int nearRadius, int farRadius)
{
if (nearRadius < 0)
throw new ArgumentOutOfRangeException(nameof(nearRadius));
if (farRadius < nearRadius)
throw new ArgumentOutOfRangeException(
nameof(farRadius),
"Far radius must be greater than or equal to near radius.");
// Queue callbacks are injected seams and can synchronously reenter the
// controller. Last-request-wins deferral keeps the active mutation
// cursor stable; the request is applied after the current transaction
// converges instead of recursively replaying its active step.
if (_advancingRadiiReconfiguration)
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
// A prior callback may have failed after this controller accepted the
// request. Resume that exact transaction before considering another
// target; replacing it would orphan already-admitted generation work.
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
// This explicit call is newer than any request deferred by a prior
// failed attempt and therefore supersedes it.
_deferredRadiiRequest = null;
if (nearRadius == NearRadius && farRadius == FarRadius)
return;
if (_collapsed || _region is null)
{
NearRadius = nearRadius;
FarRadius = farRadius;
return;
}
var rebuilt = new StreamingRegion(
_region.CenterX,
_region.CenterY,
nearRadius,
farRadius);
TwoTierDiff bootstrap = rebuilt.ComputeFirstTickDiff();
var desiredNear = new HashSet<uint>(bootstrap.ToLoadNear);
var desiredFar = new HashSet<uint>(bootstrap.ToLoadFar);
var mutations = new List<RadiiMutation>();
uint[] loaded = [.. _state.LoadedLandblockIds];
for (int i = 0; i < loaded.Length; i++)
{
uint id = loaded[i];
if (desiredNear.Contains(id))
{
if (!_state.IsNearTier(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear)));
}
else if (desiredFar.Contains(id))
{
if (_state.IsNearTier(id))
mutations.Add(new RadiiMutation(() => DemoteLandblock(id)));
}
else
{
mutations.Add(new RadiiMutation(() => EnqueueUnload(id)));
}
}
foreach (uint id in desiredNear)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadNear)));
foreach (uint id in desiredFar)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadFar)));
_pendingRadiiReconfiguration = new RadiiReconfiguration(
nearRadius,
farRadius,
rebuilt,
mutations);
AdvanceRadiiReconfiguration();
}
/// <summary>
/// Compatibility constructor for deterministic tests and callers that do
/// not own an asynchronous worker. Production must use the generation-aware
@ -227,7 +321,8 @@ public sealed class StreamingController
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
@ -240,7 +335,8 @@ public sealed class StreamingController
demoteNearLayer,
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes)
ensureEnvCellMeshes,
retirementCoordinator)
{
}
@ -252,11 +348,7 @@ public sealed class StreamingController
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
// App-owned Near resources need the still-present static entity list
// for exact light/translucency owner cleanup. Retire them before
// GpuWorldState drops the static layer and its render pins.
_demoteNearLayer?.Invoke(canonical);
_state.RemoveEntitiesFromLandblock(canonical);
_retirements.BeginNearLayer(canonical);
}
private void AdvanceGeneration()
@ -280,6 +372,18 @@ public sealed class StreamingController
/// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
else if (_deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
// Presentation-owner failures never roll back spatial state. Resume
// unfinished owner ledgers before accepting replacement publications.
_retirements.Advance();
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
@ -313,6 +417,125 @@ public sealed class StreamingController
DrainAndApply();
}
private void AdvanceRadiiReconfiguration()
{
if (_advancingRadiiReconfiguration)
return;
RadiiReconfiguration pending = _pendingRadiiReconfiguration
?? throw new InvalidOperationException("No radii reconfiguration is pending.");
var failures = new List<Exception>();
_advancingRadiiReconfiguration = true;
try
{
if (!pending.GenerationAdvanced)
{
AdvanceGeneration();
pending.GenerationAdvanced = true;
}
if (!pending.PendingLoadsCleared)
{
try
{
_clearPendingLoads?.Invoke();
pending.PendingLoadsCleared = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
pending.PendingLoadsCleared = true;
failures.Add(error);
}
}
// Do not admit new-generation work until cancellation of the old
// inbox is durable. Otherwise a successful retry of ClearPendingLoads
// would also erase mutations already marked complete by this ledger.
for (int i = 0; pending.PendingLoadsCleared && i < pending.Mutations.Count; i++)
{
RadiiMutation mutation = pending.Mutations[i];
if (mutation.Completed)
continue;
try
{
mutation.Apply();
mutation.Completed = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
mutation.Completed = true;
failures.Add(error);
}
}
bool converged = pending.PendingLoadsCleared
&& pending.Mutations.All(static mutation => mutation.Completed);
if (converged)
{
// Publish the new logical window only after every queue/retirement
// admission is durable. A later Tick can now trust Resident as the
// complete desired set and will never strand a hole.
pending.Region.MarkResidentFromBootstrap();
_region = pending.Region;
NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius;
_deferredApply.Clear();
_pendingRadiiReconfiguration = null;
}
}
finally
{
_advancingRadiiReconfiguration = false;
}
if (failures.Count != 0)
{
throw new AggregateException(
"Streaming quality reconfiguration did not complete cleanly.",
failures);
}
if (_pendingRadiiReconfiguration is null
&& _deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
}
private sealed class RadiiReconfiguration
{
public RadiiReconfiguration(
int nearRadius,
int farRadius,
StreamingRegion region,
List<RadiiMutation> mutations)
{
NearRadius = nearRadius;
FarRadius = farRadius;
Region = region;
Mutations = mutations;
}
public int NearRadius { get; }
public int FarRadius { get; }
public StreamingRegion Region { get; }
public List<RadiiMutation> Mutations { get; }
public bool GenerationAdvanced { get; set; }
public bool PendingLoadsCleared { get; set; }
}
private sealed class RadiiMutation
{
public RadiiMutation(Action apply) => Apply = apply;
public Action Apply { get; }
public bool Completed { get; set; }
}
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
@ -400,6 +623,7 @@ public sealed class StreamingController
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
_region = null;
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) EnqueueUnload(id);
@ -495,16 +719,15 @@ public sealed class StreamingController
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by _retirements.
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
foreach (var id in ids)
{
_state.RemoveLandblock(id);
_removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries
}
_region = null; // NormalTick re-creates + bootstraps the full window next frame
_retirements.BeginFull(id);
}
/// <summary>
@ -541,7 +764,9 @@ public sealed class StreamingController
// for direct callers and future drain paths.
if (IsStaleGeneration(result))
continue;
if (result is LandblockStreamResult.Unloaded
if (IsPublicationBlockedByRetirement(result))
_deferredApply.Add(result);
else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
ApplyResult(result); // free (unload) or behind-the-fade near ring
else
@ -553,9 +778,39 @@ public sealed class StreamingController
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
int budget = MaxCompletionsPerFrame;
int i = 0;
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; }
if (i > 0) _deferredApply.RemoveRange(0, i);
int applied = 0;
int read = 0;
int write = 0;
try
{
while (applied < budget && read < _deferredApply.Count)
{
LandblockStreamResult result = _deferredApply[read];
if (IsPublicationBlockedByRetirement(result))
{
if (write != read)
_deferredApply[write] = result;
write++;
read++;
continue;
}
// Advance read only after the apply commits. If it throws, the
// finally block removes prior committed entries but retains this
// exact result and the untouched tail for retry.
ApplyResult(result);
read++;
applied++;
}
}
finally
{
// One linear tail shift replaces budget-many RemoveAt shifts. The
// retained blocked prefix and the untouched FIFO tail keep their
// original relative order.
if (read > write)
_deferredApply.RemoveRange(write, read - write);
}
}
/// <summary>
@ -683,8 +938,7 @@ public sealed class StreamingController
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
_removeTerrain?.Invoke(unloaded.LandblockId);
_retirements.BeginFull(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
@ -722,6 +976,10 @@ public sealed class StreamingController
result is not LandblockStreamResult.WorkerCrashed
&& result.Generation != _generation;
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
&& _retirements.IsPending(result.LandblockId);
/// <summary>
/// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by

View file

@ -0,0 +1,20 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Reports whether a streaming queue/retirement mutation crossed its commit
/// point before a callback failed. Retry ledgers use this to avoid submitting
/// the same generation operation twice after a post-commit diagnostic error.
/// </summary>
public sealed class StreamingMutationException : Exception
{
public StreamingMutationException(
string message,
bool mutationCommitted,
Exception? innerException = null)
: base(message, innerException)
{
MutationCommitted = mutationCommitted;
}
public bool MutationCommitted { get; }
}