refactor(streaming): capture landblock build origin

This commit is contained in:
Erik 2026-07-21 20:12:56 +02:00
parent 2216a02de5
commit 090b0354ff
8 changed files with 522 additions and 54 deletions

View file

@ -2491,8 +2491,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
// it can call LandblockMesh.Build without a dat read — _heightTable and
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
_streamer = new AcDream.App.Streaming.LandblockStreamer(
loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind),
_streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests(
loadLandblock: BuildLandblockForStreaming,
buildMeshOrNull: (id, lb) =>
{
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
@ -2508,7 +2508,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest(
id,
kind,
generation,
new AcDream.App.Streaming.LandblockBuildOrigin(
_liveCenterX,
_liveCenterY))),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
@ -3792,8 +3799,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// early-out at the source.
/// </summary>
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
AcDream.App.Streaming.LandblockBuildRequest request)
{
if (_dats is null) return null;
@ -3816,31 +3822,31 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{
long waitedMs = sw.ElapsedMilliseconds;
sw.Restart();
var built = BuildLandblockForStreamingLocked(landblockId, kind);
var built = BuildLandblockForStreamingLocked(request);
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"BUILD", landblockId,
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={kind}");
"BUILD", request.LandblockId,
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
return built;
}
}
lock (_datLock)
{
return BuildLandblockForStreamingLocked(landblockId, kind);
return BuildLandblockForStreamingLocked(request);
}
}
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
AcDream.App.Streaming.LandblockBuildRequest request)
{
if (_dats is null) return null;
uint landblockId = request.LandblockId;
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
// worker-thread cost per far-tier LB from ~tens of ms to a single
// dat read.
if (kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
{
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
if (heightmapOnly is null) return null;
@ -3849,7 +3855,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
landblockId,
heightmapOnly,
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities
AcDream.Core.World.PhysicsDatBundle.Empty),
Origin: request.Origin); // far tier: no cells/buildings/entities
}
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
@ -3858,8 +3865,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
int lbX = (int)((landblockId >> 24) & 0xFFu);
int lbY = (int)((landblockId >> 16) & 0xFFu);
var worldOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
(lbX - request.Origin.CenterX) * 192f,
(lbY - request.Origin.CenterY) * 192f,
0f);
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
@ -3926,9 +3933,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
merged.AddRange(BuildSceneryEntitiesForStreaming(
baseLoaded,
lbX,
lbY,
request.Origin));
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY, envCellBuild));
merged.AddRange(BuildInteriorEntitiesForStreaming(
landblockId,
lbX,
lbY,
request.Origin,
envCellBuild));
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
@ -3941,7 +3957,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
baseLoaded.Heightmap,
merged,
physicsDats),
completedEnvCells);
completedEnvCells,
request.Origin);
}
private void EnsureEnvCellMeshesAfterPin(
@ -4036,7 +4053,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// LoadedLandblock instead of iterating worldView.Landblocks.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY)
AcDream.Core.World.LoadedLandblock lb,
int lbX,
int lbY,
AcDream.App.Streaming.LandblockBuildOrigin origin)
{
var result = new List<AcDream.Core.World.WorldEntity>();
if (_dats is null || _heightTable is null) return result;
@ -4069,8 +4089,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (spawns.Count == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
(lbX - origin.CenterX) * 192f,
(lbY - origin.CenterY) * 192f,
0f);
// Per-landblock id namespace. The top nibble identifies procedural
@ -4265,6 +4285,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
uint landblockId,
int lbX,
int lbY,
AcDream.App.Streaming.LandblockBuildOrigin origin,
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
{
var result = new List<AcDream.Core.World.WorldEntity>();
@ -4274,8 +4295,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
if (lbInfo is null || lbInfo.NumCells == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
(lbX - origin.CenterX) * 192f,
(lbY - origin.CenterY) * 192f,
0f);
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
@ -4654,8 +4675,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
int lbX = (int)lbXu;
int lbY = (int)lbYu;
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
(lbX - build.Origin.CenterX) * 192f,
(lbY - build.Origin.CenterY) * 192f,
0f);
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
@ -4820,7 +4841,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{
// #146 (2026-06-24): clear this landblock's prior building cache so
// the loop re-bases each building's STREAMING-RELATIVE WorldTransform
// with the CURRENT _liveCenter (origin, recomputed above per apply).
// with this completion's captured origin (recomputed above per apply).
// CacheBuilding's per-cell first-wins then applies fresh within this
// pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock).
// Without it, CacheBuilding's idempotent guard locks the transform at

View file

@ -10,7 +10,8 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed record LandblockBuild(
LoadedLandblock Landblock,
EnvCellLandblockBuild? EnvCells = null)
EnvCellLandblockBuild? EnvCells = null,
LandblockBuildOrigin Origin = default)
{
public uint LandblockId => Landblock.LandblockId;
}

View file

@ -0,0 +1,36 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable world-origin center captured by the update thread when a
/// landblock load is admitted. Worker construction and render publication use
/// this same value, so a later recenter cannot combine two coordinate frames.
/// </summary>
public readonly record struct LandblockBuildOrigin
{
public LandblockBuildOrigin(int centerX, int centerY)
{
CenterX = centerX;
CenterY = centerY;
IsSpecified = true;
}
public int CenterX { get; }
public int CenterY { get; }
/// <summary>
/// Distinguishes a captured origin at the valid map coordinate (0,0)
/// from an old compatibility request that carries no origin at all.
/// </summary>
public bool IsSpecified { get; }
}
/// <summary>
/// Complete immutable input to one CPU-side landblock build.
/// <see cref="Generation"/> is carried for matching and diagnostics only;
/// residency policy remains owned by <see cref="StreamingController"/>.
/// </summary>
public readonly record struct LandblockBuildRequest(
uint LandblockId,
LandblockStreamJobKind Kind,
ulong Generation,
LandblockBuildOrigin Origin);

View file

@ -161,7 +161,9 @@ public sealed class LandblockPresentationPipeline
transaction = new PublicationTransaction
{
Kind = PublicationKind.Far,
Build = new LandblockBuild(farLandblock),
Build = new LandblockBuild(
farLandblock,
Origin: completedBuild.Origin),
MeshData = meshData,
LandblockId = farLandblock.LandblockId,
Tier = LandblockStreamTier.Far,

View file

@ -15,7 +15,12 @@ public abstract record LandblockStreamJob(uint LandblockId)
public sealed record Load(
uint LandblockId,
LandblockStreamJobKind Kind,
ulong Generation = 0) : LandblockStreamJob(LandblockId);
ulong Generation = 0,
LandblockBuildOrigin Origin = default) : LandblockStreamJob(LandblockId)
{
public LandblockBuildRequest Request =>
new(LandblockId, Kind, Generation, Origin);
}
public sealed record Unload(
uint LandblockId,
ulong Generation = 0) : LandblockStreamJob(LandblockId);

View file

@ -52,7 +52,8 @@ public sealed class LandblockStreamer : IDisposable
/// </summary>
public const int DefaultDrainBatchSize = 4;
private readonly Func<uint, LandblockStreamJobKind, LandblockBuild?> _loadLandblock;
private readonly Func<LandblockBuildRequest, LandblockBuild?> _loadLandblock;
private readonly bool _supportsRequestOrigin;
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox;
@ -65,17 +66,17 @@ public sealed class LandblockStreamer : IDisposable
private bool _disposeCompleted;
/// <summary>
/// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/>
/// so it can branch on far-tier vs near-tier and skip entity hydration on far-tier
/// loads (heightmap-only). See ISSUE #54: prior to this signature the worker always
/// called the full-load path and stripped entities at the output, wasting per-LB
/// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work.
/// Primary constructor. The factory receives the complete immutable
/// request, including job kind and enqueue-time world origin. ISSUE #54
/// uses the kind to skip entity hydration for heightmap-only far loads.
/// </summary>
public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
private LandblockStreamer(
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull,
bool supportsRequestOrigin)
{
_loadLandblock = loadLandblock;
_supportsRequestOrigin = supportsRequestOrigin;
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
@ -85,6 +86,32 @@ public sealed class LandblockStreamer : IDisposable
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
/// <summary>
/// Creates the request-aware production streamer. A named factory keeps
/// legacy one-parameter loader lambdas source-compatible without an
/// ambiguous constructor overload.
/// </summary>
public static LandblockStreamer CreateForRequests(
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null) =>
new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true);
/// <summary>
/// Compatibility constructor for build factories that predate the
/// immutable request/origin seam.
/// </summary>
public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
: this(
request => loadLandblock(request.LandblockId, request.Kind) is { } build
? build
: null,
buildMeshOrNull,
supportsRequestOrigin: false)
{
}
/// <summary>
/// Compatibility constructor for loaders that have no App-owned cell payload.
/// Production uses the <see cref="LandblockBuild"/> overload so render state
@ -94,10 +121,11 @@ public sealed class LandblockStreamer : IDisposable
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)
request => loadLandblock(request.LandblockId, request.Kind) is { } landblock
? new LandblockBuild(landblock, Origin: request.Origin)
: null,
buildMeshOrNull)
buildMeshOrNull,
supportsRequestOrigin: false)
{
}
@ -105,16 +133,17 @@ public sealed class LandblockStreamer : IDisposable
/// Back-compat overload — wraps a kind-agnostic factory so existing test code
/// that doesn't care about the JobKind branch keeps compiling. The wrapper
/// ignores the kind and calls the factory once per LB regardless of tier.
/// New production code should use the primary 2-arg ctor.
/// New production code should use <see cref="CreateForRequests"/>.
/// </summary>
public LandblockStreamer(
Func<uint, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
: this(
(id, _) => loadLandblock(id) is { } landblock
? new LandblockBuild(landblock)
request => loadLandblock(request.LandblockId) is { } landblock
? new LandblockBuild(landblock, Origin: request.Origin)
: null,
buildMeshOrNull)
buildMeshOrNull,
supportsRequestOrigin: false)
{
}
@ -152,11 +181,47 @@ public sealed class LandblockStreamer : IDisposable
uint landblockId,
LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear,
ulong generation = 0)
{
if (_supportsRequestOrigin)
{
throw new InvalidOperationException(
"Request-aware landblock loaders require an explicit captured origin.");
}
EnqueueLoad(new LandblockBuildRequest(
landblockId,
kind,
generation,
default));
}
/// <summary>
/// Non-blocking enqueue of the exact request captured by the update
/// thread. The worker and completion retain its origin unchanged.
/// </summary>
public void EnqueueLoad(LandblockBuildRequest request)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation));
if (_supportsRequestOrigin && !request.Origin.IsSpecified)
{
throw new InvalidOperationException(
"Request-aware landblock loaders require a specified captured origin.");
}
if (!_supportsRequestOrigin && request.Origin.IsSpecified)
{
throw new InvalidOperationException(
"This compatibility landblock loader cannot consume a non-default build origin. " +
"Use LandblockStreamer.CreateForRequests.");
}
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"ENQ",
request.LandblockId,
$"kind={request.Kind} origin=({request.Origin.CenterX:X2},{request.Origin.CenterY:X2})");
WriteJob(new LandblockStreamJob.Load(
request.LandblockId,
request.Kind,
request.Generation,
request.Origin));
}
/// <summary>
@ -358,13 +423,21 @@ public sealed class LandblockStreamer : IDisposable
// factory returns far-tier with entities anyway.
try
{
var build = _loadLandblock(load.LandblockId, load.Kind);
var build = _loadLandblock(load.Request);
if (build is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
break;
}
if (build.Origin != load.Origin)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId,
$"Landblock build origin {build.Origin} did not match request origin {load.Origin}",
load.Generation));
break;
}
var lb = build.Landblock;
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
{
@ -388,18 +461,29 @@ public sealed class LandblockStreamer : IDisposable
}
var tier = load.Kind == LandblockStreamJobKind.LoadFar
? LandblockStreamTier.Far : LandblockStreamTier.Near;
if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
if (tier == LandblockStreamTier.Far)
{
// Belt-and-suspenders: factory should have skipped
// entity hydration for LoadFar. If it didn't, fail
// loud in Debug builds and strip in Release.
System.Diagnostics.Debug.Assert(false,
$"Far-tier factory should skip entity hydration; got {lb.Entities.Count} entities for LB 0x{load.LandblockId:X8}");
bool hasNearPayload =
lb.Entities.Count > 0 ||
build.EnvCells is not null ||
lb.PhysicsDats is { } physicsDats &&
(physicsDats.Info is not null ||
physicsDats.EnvCells.Count > 0 ||
physicsDats.Environments.Count > 0 ||
physicsDats.Setups.Count > 0 ||
physicsDats.GfxObjs.Count > 0);
System.Diagnostics.Debug.Assert(
!hasNearPayload,
$"Far-tier factory returned Near payload for LB 0x{load.LandblockId:X8}");
lb = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<AcDream.Core.World.WorldEntity>());
build = new LandblockBuild(lb);
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
PhysicsDatBundle.Empty);
build = new LandblockBuild(lb, Origin: build.Origin);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, build, mesh, load.Generation));

View file

@ -0,0 +1,314 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockBuildOriginTests
{
private const int SpinTimeoutMs = 2000;
private const int SpinStepMs = 10;
[Fact]
public void ExplicitMapZeroOrigin_IsDistinctFromUnspecifiedCompatibilityOrigin()
{
var mapZero = new LandblockBuildOrigin(0, 0);
Assert.True(mapZero.IsSpecified);
Assert.False(default(LandblockBuildOrigin).IsSpecified);
Assert.NotEqual(default, mapZero);
}
[Fact]
public async Task RequestAwareLoad_CarriesExplicitMapZeroOriginThroughFactoryAndCompletion()
{
const uint landblockId = 0x0000FFFFu;
var capturedOrigin = new LandblockBuildOrigin(0, 0);
LandblockBuildRequest? observedRequest = null;
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request =>
{
observedRequest = request;
return EmptyBuild(request.LandblockId, request.Origin);
},
buildMeshOrNull: (_, _) => EmptyMesh());
streamer.EnqueueLoad(new LandblockBuildRequest(
landblockId,
LandblockStreamJobKind.LoadNear,
Generation: 77,
capturedOrigin));
streamer.Start();
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
await DrainFirstAsync(streamer));
Assert.Equal(
new LandblockBuildRequest(
landblockId,
LandblockStreamJobKind.LoadNear,
77,
capturedOrigin),
observedRequest);
Assert.Equal(capturedOrigin, loaded.Build.Origin);
Assert.Equal(77ul, loaded.Generation);
}
[Fact]
public async Task RequestAwareLoad_WhenFactoryReturnsUnspecifiedOrigin_FailsAtMapZero()
{
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request => EmptyBuild(request.LandblockId, default));
streamer.EnqueueLoad(new LandblockBuildRequest(
0x0000FFFFu,
LandblockStreamJobKind.LoadNear,
Generation: 78,
new LandblockBuildOrigin(0, 0)));
streamer.Start();
var failed = Assert.IsType<LandblockStreamResult.Failed>(
await DrainFirstAsync(streamer));
Assert.Contains("origin", failed.Error, StringComparison.OrdinalIgnoreCase);
Assert.Equal(78ul, failed.Generation);
}
[Fact]
public void CompatibilityLoader_RejectsEvenExplicitMapZeroOriginBeforeQueueing()
{
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
Assert.Throws<InvalidOperationException>(() => streamer.EnqueueLoad(
new LandblockBuildRequest(
0x0000FFFFu,
LandblockStreamJobKind.LoadNear,
Generation: 79,
new LandblockBuildOrigin(0, 0))));
}
[Fact]
public void RequestAwareLoader_RejectsOriginlessCompatibilityEnqueue()
{
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin));
Assert.Throws<InvalidOperationException>(() =>
streamer.EnqueueLoad(0xA9B4FFFFu));
}
[Fact]
public void RequestAwareLoader_RejectsDirectRequestWithUnspecifiedOrigin()
{
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin));
Assert.Throws<InvalidOperationException>(() => streamer.EnqueueLoad(
new LandblockBuildRequest(
0xA9B4FFFFu,
LandblockStreamJobKind.LoadNear,
Generation: 80,
Origin: default)));
}
[Fact]
public async Task TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration()
{
var observed = new List<LandblockBuildRequest>();
var firstOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
var secondOrigin = new LandblockBuildOrigin(0x71, 0xEC);
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request =>
{
observed.Add(request);
return EmptyBuild(request.LandblockId, request.Origin);
},
buildMeshOrNull: (_, _) => EmptyMesh());
streamer.EnqueueLoad(new LandblockBuildRequest(
0xA9B4FFFFu,
LandblockStreamJobKind.LoadNear,
Generation: 80,
firstOrigin));
streamer.EnqueueLoad(new LandblockBuildRequest(
0x71ECFFFFu,
LandblockStreamJobKind.LoadNear,
Generation: 81,
secondOrigin));
streamer.Start();
IReadOnlyList<LandblockStreamResult> completions =
await DrainCountAsync(streamer, 2);
Assert.Equal([firstOrigin, secondOrigin], observed.Select(request => request.Origin));
Assert.Equal([80ul, 81ul], completions.Select(result => result.Generation));
Assert.Equal(
[firstOrigin, secondOrigin],
completions
.Cast<LandblockStreamResult.Loaded>()
.Select(result => result.Build.Origin));
}
[Fact]
public async Task QueuedPromotion_SupersedesOldFarWithItsOwnOriginAndGeneration()
{
var observed = new List<LandblockBuildRequest>();
var oldOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
var newOrigin = new LandblockBuildOrigin(0x71, 0xEC);
const uint landblockId = 0x71ECFFFFu;
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request =>
{
observed.Add(request);
return EmptyBuild(request.LandblockId, request.Origin);
},
buildMeshOrNull: (_, _) => EmptyMesh());
streamer.EnqueueLoad(new LandblockBuildRequest(
landblockId,
LandblockStreamJobKind.LoadFar,
Generation: 82,
oldOrigin));
streamer.EnqueueLoad(new LandblockBuildRequest(
landblockId,
LandblockStreamJobKind.PromoteToNear,
Generation: 83,
newOrigin));
streamer.Start();
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(
await DrainFirstAsync(streamer));
Assert.Equal([new LandblockBuildRequest(
landblockId,
LandblockStreamJobKind.PromoteToNear,
83,
newOrigin)], observed);
Assert.Equal(newOrigin, promoted.Build.Origin);
Assert.Equal(83ul, promoted.Generation);
}
[Fact]
public async Task FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty()
{
const uint landblockId = 0xA9B4FFFFu;
var origin = new LandblockBuildOrigin(0xA9, 0xB4);
var physics = new PhysicsDatBundle(
new LandBlockInfo(),
new Dictionary<uint, EnvCell>(),
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
new Dictionary<uint, Setup>(),
new Dictionary<uint, GfxObj>());
var envCells = new EnvCellLandblockBuild(
landblockId,
Array.Empty<AcDream.App.Rendering.LoadedCell>(),
Array.Empty<EnvCellShellPlacement>());
using var streamer = LandblockStreamer.CreateForRequests(
loadLandblock: request => new LandblockBuild(
new LoadedLandblock(
request.LandblockId,
new LandBlock(),
Array.Empty<WorldEntity>(),
physics),
envCells,
request.Origin),
buildMeshOrNull: (_, _) => EmptyMesh());
streamer.EnqueueLoad(new LandblockBuildRequest(
landblockId,
LandblockStreamJobKind.LoadFar,
Generation: 84,
origin));
streamer.Start();
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
await DrainFirstAsync(streamer));
Assert.Equal(LandblockStreamTier.Far, loaded.Tier);
Assert.Empty(loaded.Landblock.Entities);
Assert.Same(PhysicsDatBundle.Empty, loaded.Landblock.PhysicsDats);
Assert.Null(loaded.Build.EnvCells);
Assert.Equal(origin, loaded.Build.Origin);
}
[Fact]
public void GameWindowBuildAndPublication_UseCapturedOriginInsteadOfLiveCenter()
{
string root = FindRepoRoot();
string source = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string buildSection = Slice(
source,
"private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(",
"private void AdvanceLandblockPresentationRetirement(");
string publicationSection = Slice(
source,
"private void ApplyLoadedTerrainLocked(",
"private void OnUpdate(double dt)");
Assert.Contains("request.Origin", buildSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", buildSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", buildSection, StringComparison.Ordinal);
Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal);
Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal);
}
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
new(
new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>()),
Origin: origin);
private static LandblockMeshData EmptyMesh() =>
new(Array.Empty<TerrainVertex>(), Array.Empty<uint>());
private static async Task<LandblockStreamResult> DrainFirstAsync(
LandblockStreamer streamer) =>
(await DrainCountAsync(streamer, 1))[0];
private static async Task<IReadOnlyList<LandblockStreamResult>> DrainCountAsync(
LandblockStreamer streamer,
int count)
{
var results = new List<LandblockStreamResult>(count);
for (int i = 0; i < SpinTimeoutMs / SpinStepMs && results.Count < count; i++)
{
results.AddRange(streamer.DrainCompletions(count - results.Count));
if (results.Count < count)
await Task.Delay(SpinStepMs);
}
Assert.Equal(count, results.Count);
return results;
}
private static string Slice(string source, string startMarker, string endMarker)
{
int start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.True(start >= 0, $"Missing source marker: {startMarker}");
int end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
Assert.True(end > start, $"Missing source marker: {endMarker}");
return source[start..end];
}
private static string FindRepoRoot()
{
string? dir = AppContext.BaseDirectory;
while (dir is not null)
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return dir;
dir = Directory.GetParent(dir)?.FullName;
}
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
}
}

View file

@ -100,7 +100,11 @@ public sealed class LandblockPresentationPipelineTests
{
const uint landblockId = 0x4040FFFFu;
var state = new GpuWorldState();
LandblockBuild source = BuildWithShell(landblockId, Entity(9));
var origin = new LandblockBuildOrigin(0x40, 0x41);
LandblockBuild source = BuildWithShell(landblockId, Entity(9)) with
{
Origin = origin,
};
LandblockBuild? published = null;
int replayCount = 0;
int recoveryCount = 0;
@ -121,6 +125,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.Empty(published.Landblock.Entities);
Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats);
Assert.Null(published.EnvCells);
Assert.Equal(origin, published.Origin);
Assert.True(state.IsLoaded(landblockId));
Assert.False(state.IsNearTier(landblockId));
Assert.Equal(0, replayCount);