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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
20
src/AcDream.App/Streaming/GpuLandblockRetirement.cs
Normal file
20
src/AcDream.App/Streaming/GpuLandblockRetirement.cs
Normal 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
328
src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
Normal file
328
src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
src/AcDream.App/Streaming/StreamingMutationException.cs
Normal file
20
src/AcDream.App/Streaming/StreamingMutationException.cs
Normal 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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue