fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -17,11 +18,12 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
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;
/// <summary>
@ -34,8 +36,16 @@ public sealed class StreamingController
/// tests that don't exercise re-hydration.
/// </summary>
private readonly Action<uint>? _onLandblockLoaded;
// Invoked only after LandblockSpawnAdapter has pinned every synthetic
// EnvCell geometry id. This re-arms the schema-aware preparation path if
// an unowned cached mesh was evicted between worker schedule and publish.
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
// 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.
private ulong _generation;
// True while streaming is collapsed to the single dungeon landblock the
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
@ -127,6 +137,43 @@ public sealed class StreamingController
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
/// <summary>
/// True once every in-bounds landblock in the requested Chebyshev ring has
/// crossed the render-thread publication barrier. Worker completion and
/// world-state registration are not sufficient: all static GfxObj and
/// EnvCell shell meshes must have completed their render-thread upload.
/// Portal-space exit uses this alongside physics residency so the world
/// cannot be revealed while its render slots are still absent.
/// </summary>
public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius)
{
if (radius < 0)
throw new ArgumentOutOfRangeException(nameof(radius));
// LandDefs::InboundValidCellId validates both map axes and the low-word
// class (outdoor cell, EnvCell, or canonical landblock sentinel).
if (!AcDream.Core.Physics.LandDefs.InboundValidCellId(cellOrLandblockId))
return false;
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
{
int nx = cx + dx;
int ny = cy + dy;
// Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer
// 0xFF coordinate has no loadable neighbour beyond it.
if (nx < 0 || nx > 254 || ny < 0 || ny > 254)
continue;
uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical))
return false;
}
return true;
}
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
// re-uploads the WHOLE window) and the landblock count it dropped last time.
public int ForceReloadCount { get; private set; }
@ -135,8 +182,40 @@ public sealed class StreamingController
// Completions that were drained past a priority item get buffered here
// so they still apply over subsequent frames without loss.
private readonly List<LandblockStreamResult> _deferredApply = new();
public StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LandblockBuild, LandblockMeshData> applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_demoteNearLayer = demoteNearLayer;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
}
/// <summary>
/// Compatibility constructor for deterministic tests and callers that do
/// not own an asynchronous worker. Production must use the generation-aware
/// overload above so hard-recenter results can be rejected by incarnation.
/// </summary>
internal StreamingController(
Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
@ -145,19 +224,44 @@ public sealed class StreamingController
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null)
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
drainCompletions,
applyTerrain,
state,
nearRadius,
farRadius,
removeTerrain,
demoteNearLayer,
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
}
private void EnqueueLoad(uint id, LandblockStreamJobKind kind) =>
_enqueueLoad(id, kind, _generation);
private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation);
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);
}
private void AdvanceGeneration()
{
_generation = unchecked(_generation + 1);
}
/// <summary>
@ -263,18 +367,18 @@ 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);
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in bootstrap.ToLoadNear) EnqueueLoad(id, 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);
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToLoadNear) EnqueueLoad(id, 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);
}
}
@ -293,10 +397,12 @@ public sealed class StreamingController
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
_collapsed = true;
_collapsedCenter = centerId;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) _enqueueUnload(id);
if (id != centerId) EnqueueUnload(id);
// Pin a radius-0 region so RecenterTo never re-expands while inside,
// and so the post-exit rebuild starts from a clean, consistent state.
@ -306,7 +412,9 @@ public sealed class StreamingController
// The dungeon landblock itself must be (or become) loaded. If a prior
// ClearPendingLoads cancelled its queued load, re-enqueue it.
if (!_state.IsLoaded(centerId))
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
else if (!_state.IsNearTier(centerId))
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
}
/// <summary>
@ -321,7 +429,7 @@ public sealed class StreamingController
// Always preserve the true dungeon landblock (_collapsedCenter), never the
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
if (id != _collapsedCenter) _enqueueUnload(id);
if (id != _collapsedCenter) EnqueueUnload(id);
}
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
@ -354,16 +462,17 @@ public sealed class StreamingController
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
$"(was collapsed on 0x{_collapsedCenter:X8})");
_collapsed = false;
AdvanceGeneration();
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
foreach (var id in _state.LoadedLandblockIds)
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
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);
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
_region = rebuilt;
}
@ -383,6 +492,9 @@ public sealed class StreamingController
public void ForceReloadWindow()
{
_collapsed = false;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
@ -424,6 +536,11 @@ public sealed class StreamingController
if (chunk.Count == 0) break;
foreach (var result in chunk)
{
// Reject obsolete hard-window work before it can occupy the
// metered deferred queue. ApplyResult repeats this invariant
// for direct callers and future drain paths.
if (IsStaleGeneration(result))
continue;
if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
ApplyResult(result); // free (unload) or behind-the-fade near ring
@ -449,19 +566,120 @@ public sealed class StreamingController
/// </summary>
private void ApplyResult(LandblockStreamResult result)
{
if (IsStaleGeneration(result))
{
// A worker can finish one old load/unload after a hard recenter.
// Landblock id membership cannot distinguish overlapping windows;
// generation is the logical streaming incarnation boundary.
return;
}
if (result is LandblockStreamResult.Unloaded
&& _region?.TryGetDesiredTier(result.LandblockId, out _) == true)
{
// Normal recenter does not advance the hard-window generation. A
// rapid away->back can therefore re-own an id while its same-
// generation unload is already in the worker outbox. Current
// region ownership wins; the old unload must not tear it down.
return;
}
if (result is LandblockStreamResult.Loaded
or LandblockStreamResult.Promoted)
{
if (_region is null
|| !_region.TryGetDesiredTier(result.LandblockId, out var desiredTier))
{
// A hard recenter/collapse can leave one worker job already in
// flight. Its completion belongs to the old region and must not
// resurrect a landblock the new StreamingRegion never owns.
return;
}
bool isNearCompletion = result is LandblockStreamResult.Promoted
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
if (isNearCompletion && _state.IsNearTier(result.LandblockId))
{
// Normal recenter can enqueue a second high-priority Near job
// while an earlier one is already in flight. The streamer
// supersedes Far/Unload work but intentionally does not dedupe
// high-priority jobs. Publication is idempotent at this owner:
// never append statics, replay defaults, or reapply the complete
// Near transaction to an already-Near landblock.
return;
}
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
{
// Recenter can demote a Near job before its first completion.
// StreamingRegion already considers that landblock Far, so it
// deliberately emits no second LoadFar job. If no terrain is
// resident yet, publish the completed heightmap/mesh as a Far
// result while discarding its entity and EnvCell layers. This
// is the same payload a fresh LoadFar would have produced and
// closes the in-flight Near -> desired Far lifecycle without a
// duplicate worker read or a permanent terrain hole.
if (!_state.IsLoaded(result.LandblockId))
{
switch (result)
{
case LandblockStreamResult.Loaded loaded:
ApplyAsFar(loaded.Build, loaded.MeshData);
break;
case LandblockStreamResult.Promoted promoted:
ApplyAsFar(promoted.Build, promoted.MeshData);
break;
}
}
return;
}
}
switch (result)
{
case LandblockStreamResult.Loaded loaded:
if (loaded.Tier == LandblockStreamTier.Far
&& _state.IsNearTier(loaded.LandblockId))
{
// This Far completion was queued before a newer Near load
// or promotion. Applying it would erase the entity/cell
// layer that now owns the landblock.
break;
}
_applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(loaded.Landblock);
_state.AddLandblock(
loaded.Landblock,
loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId),
loaded.Tier);
EnsureEnvCellMeshesAfterPin(loaded.Build);
// #138: after the landblock is in _loaded (so live projection
// hot-paths), restore any retained server objects ACE won't
// re-send. Fired AFTER AddLandblock, never before.
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
break;
case LandblockStreamResult.Promoted promoted:
// PromoteToNear carries a complete build and mesh because the
// streamer deliberately lets it supersede a queued LoadFar. If
// that Far job never started, publish this as the real Near
// landblock; if the base is already resident, merge only the
// Near layer so existing live projections retain identity.
_applyTerrain(promoted.Build, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
var promotedRenderIds =
promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
if (_state.IsLoaded(promoted.LandblockId))
{
_state.AddEntitiesToExistingLandblock(
promoted.LandblockId,
promoted.Entities,
promotedRenderIds);
}
else
{
_state.AddLandblock(
promoted.Landblock,
promotedRenderIds,
LandblockStreamTier.Near);
}
EnsureEnvCellMeshesAfterPin(promoted.Build);
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
case LandblockStreamResult.Unloaded unloaded:
@ -479,6 +697,31 @@ public sealed class StreamingController
}
}
private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
{
var completed = completedBuild.Landblock;
var farLandblock = new LoadedLandblock(
completed.LandblockId,
completed.Heightmap,
Array.Empty<WorldEntity>(),
PhysicsDatBundle.Empty);
var farBuild = new LandblockBuild(farLandblock);
_applyTerrain(farBuild, meshData);
_state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far);
_onLandblockLoaded?.Invoke(farLandblock.LandblockId);
}
private void EnsureEnvCellMeshesAfterPin(LandblockBuild build)
{
if (build.EnvCells is { Shells.Length: > 0 } envCells)
_ensureEnvCellMeshes?.Invoke(envCells);
}
private bool IsStaleGeneration(LandblockStreamResult result) =>
result is not LandblockStreamResult.WorkerCrashed
&& result.Generation != _generation;
/// <summary>
/// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by