refactor(streaming): capture landblock build origin
This commit is contained in:
parent
2216a02de5
commit
090b0354ff
8 changed files with 522 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
36
src/AcDream.App/Streaming/LandblockBuildRequest.cs
Normal file
36
src/AcDream.App/Streaming/LandblockBuildRequest.cs
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
314
tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs
Normal file
314
tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue