fix(streaming): commit EnvCell landblocks atomically (#214)

Carry one complete cell payload with each streaming completion and publish visibility, physics, and render state on the render thread. Remove the obsolete post-readiness login reload that triggered a duplicate dungeon build.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 18:45:24 +02:00
parent 85980fe13f
commit b0c175afc0
26 changed files with 973 additions and 617 deletions

View file

@ -52,7 +52,7 @@ public sealed class LandblockStreamer : IDisposable
/// </summary>
public const int DefaultDrainBatchSize = 4;
private readonly Func<uint, LandblockStreamJobKind, LoadedLandblock?> _loadLandblock;
private readonly Func<uint, LandblockStreamJobKind, LandblockBuild?> _loadLandblock;
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox;
@ -68,7 +68,7 @@ public sealed class LandblockStreamer : IDisposable
/// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work.
/// </summary>
public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
{
_loadLandblock = loadLandblock;
@ -81,6 +81,22 @@ public sealed class LandblockStreamer : IDisposable
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
/// <summary>
/// Compatibility constructor for loaders that have no App-owned cell payload.
/// Production uses the <see cref="LandblockBuild"/> overload so render state
/// remains attached to the exact streaming completion that produced it.
/// </summary>
public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
: this(
(id, kind) => loadLandblock(id, kind) is { } landblock
? new LandblockBuild(landblock)
: null,
buildMeshOrNull)
{
}
/// <summary>
/// Back-compat overload — wraps a kind-agnostic factory so existing test code
/// that doesn't care about the JobKind branch keeps compiling. The wrapper
@ -90,7 +106,11 @@ public sealed class LandblockStreamer : IDisposable
public LandblockStreamer(
Func<uint, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
: this((id, _) => loadLandblock(id), buildMeshOrNull)
: this(
(id, _) => loadLandblock(id) is { } landblock
? new LandblockBuild(landblock)
: null,
buildMeshOrNull)
{
}
@ -311,13 +331,14 @@ public sealed class LandblockStreamer : IDisposable
// factory returns far-tier with entities anyway.
try
{
var lb = _loadLandblock(load.LandblockId, load.Kind);
if (lb is null)
var build = _loadLandblock(load.LandblockId, load.Kind);
if (build is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
break;
}
var lb = build.Landblock;
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
{
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
@ -328,7 +349,7 @@ public sealed class LandblockStreamer : IDisposable
break;
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
load.LandblockId, lb, promotedMesh));
load.LandblockId, build, promotedMesh));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
@ -351,9 +372,10 @@ public sealed class LandblockStreamer : IDisposable
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<AcDream.Core.World.WorldEntity>());
build = new LandblockBuild(lb);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, lb, mesh));
load.LandblockId, tier, build, mesh));
}
catch (Exception ex)
{