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
|
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
|
||||||
// it can call LandblockMesh.Build without a dat read — _heightTable and
|
// it can call LandblockMesh.Build without a dat read — _heightTable and
|
||||||
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
|
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
|
||||||
_streamer = new AcDream.App.Streaming.LandblockStreamer(
|
_streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests(
|
||||||
loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind),
|
loadLandblock: BuildLandblockForStreaming,
|
||||||
buildMeshOrNull: (id, lb) =>
|
buildMeshOrNull: (id, lb) =>
|
||||||
{
|
{
|
||||||
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
|
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();
|
_streamer.Start();
|
||||||
|
|
||||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
_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),
|
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
|
||||||
drainCompletions: _streamer.DrainCompletions,
|
drainCompletions: _streamer.DrainCompletions,
|
||||||
applyTerrain: ApplyLoadedTerrain,
|
applyTerrain: ApplyLoadedTerrain,
|
||||||
|
|
@ -3792,8 +3799,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
/// early-out at the source.
|
/// early-out at the source.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
|
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
|
||||||
uint landblockId,
|
AcDream.App.Streaming.LandblockBuildRequest request)
|
||||||
AcDream.App.Streaming.LandblockStreamJobKind kind)
|
|
||||||
{
|
{
|
||||||
if (_dats is null) return null;
|
if (_dats is null) return null;
|
||||||
|
|
||||||
|
|
@ -3816,31 +3822,31 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
{
|
{
|
||||||
long waitedMs = sw.ElapsedMilliseconds;
|
long waitedMs = sw.ElapsedMilliseconds;
|
||||||
sw.Restart();
|
sw.Restart();
|
||||||
var built = BuildLandblockForStreamingLocked(landblockId, kind);
|
var built = BuildLandblockForStreamingLocked(request);
|
||||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
"BUILD", landblockId,
|
"BUILD", request.LandblockId,
|
||||||
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={kind}");
|
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
|
||||||
return built;
|
return built;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lock (_datLock)
|
lock (_datLock)
|
||||||
{
|
{
|
||||||
return BuildLandblockForStreamingLocked(landblockId, kind);
|
return BuildLandblockForStreamingLocked(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
|
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
|
||||||
uint landblockId,
|
AcDream.App.Streaming.LandblockBuildRequest request)
|
||||||
AcDream.App.Streaming.LandblockStreamJobKind kind)
|
|
||||||
{
|
{
|
||||||
if (_dats is null) return null;
|
if (_dats is null) return null;
|
||||||
|
uint landblockId = request.LandblockId;
|
||||||
|
|
||||||
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
||||||
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
|
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
|
||||||
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
|
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
|
||||||
// worker-thread cost per far-tier LB from ~tens of ms to a single
|
// worker-thread cost per far-tier LB from ~tens of ms to a single
|
||||||
// dat read.
|
// 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);
|
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
|
||||||
if (heightmapOnly is null) return null;
|
if (heightmapOnly is null) return null;
|
||||||
|
|
@ -3849,7 +3855,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
landblockId,
|
landblockId,
|
||||||
heightmapOnly,
|
heightmapOnly,
|
||||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
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);
|
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 lbX = (int)((landblockId >> 24) & 0xFFu);
|
||||||
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
||||||
var worldOffset = new System.Numerics.Vector3(
|
var worldOffset = new System.Numerics.Vector3(
|
||||||
(lbX - _liveCenterX) * 192f,
|
(lbX - request.Origin.CenterX) * 192f,
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - request.Origin.CenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
// 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.
|
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
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);
|
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
|
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
|
||||||
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
|
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
|
||||||
|
|
@ -3941,7 +3957,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
baseLoaded.Heightmap,
|
baseLoaded.Heightmap,
|
||||||
merged,
|
merged,
|
||||||
physicsDats),
|
physicsDats),
|
||||||
completedEnvCells);
|
completedEnvCells,
|
||||||
|
request.Origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureEnvCellMeshesAfterPin(
|
private void EnsureEnvCellMeshesAfterPin(
|
||||||
|
|
@ -4036,7 +4053,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
/// LoadedLandblock instead of iterating worldView.Landblocks.
|
/// LoadedLandblock instead of iterating worldView.Landblocks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
|
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>();
|
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||||
if (_dats is null || _heightTable is null) return result;
|
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;
|
if (spawns.Count == 0) return result;
|
||||||
|
|
||||||
var lbOffset = new System.Numerics.Vector3(
|
var lbOffset = new System.Numerics.Vector3(
|
||||||
(lbX - _liveCenterX) * 192f,
|
(lbX - origin.CenterX) * 192f,
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - origin.CenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
// Per-landblock id namespace. The top nibble identifies procedural
|
// Per-landblock id namespace. The top nibble identifies procedural
|
||||||
|
|
@ -4265,6 +4285,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
int lbX,
|
int lbX,
|
||||||
int lbY,
|
int lbY,
|
||||||
|
AcDream.App.Streaming.LandblockBuildOrigin origin,
|
||||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
|
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
|
||||||
{
|
{
|
||||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
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;
|
if (lbInfo is null || lbInfo.NumCells == 0) return result;
|
||||||
|
|
||||||
var lbOffset = new System.Numerics.Vector3(
|
var lbOffset = new System.Numerics.Vector3(
|
||||||
(lbX - _liveCenterX) * 192f,
|
(lbX - origin.CenterX) * 192f,
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - origin.CenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
||||||
|
|
@ -4654,8 +4675,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
int lbX = (int)lbXu;
|
int lbX = (int)lbXu;
|
||||||
int lbY = (int)lbYu;
|
int lbY = (int)lbYu;
|
||||||
var origin = new System.Numerics.Vector3(
|
var origin = new System.Numerics.Vector3(
|
||||||
(lbX - _liveCenterX) * 192f,
|
(lbX - build.Origin.CenterX) * 192f,
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - build.Origin.CenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
|
// 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
|
// #146 (2026-06-24): clear this landblock's prior building cache so
|
||||||
// the loop re-bases each building's STREAMING-RELATIVE WorldTransform
|
// 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
|
// CacheBuilding's per-cell first-wins then applies fresh within this
|
||||||
// pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock).
|
// pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock).
|
||||||
// Without it, CacheBuilding's idempotent guard locks the transform at
|
// Without it, CacheBuilding's idempotent guard locks the transform at
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ namespace AcDream.App.Streaming;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record LandblockBuild(
|
public sealed record LandblockBuild(
|
||||||
LoadedLandblock Landblock,
|
LoadedLandblock Landblock,
|
||||||
EnvCellLandblockBuild? EnvCells = null)
|
EnvCellLandblockBuild? EnvCells = null,
|
||||||
|
LandblockBuildOrigin Origin = default)
|
||||||
{
|
{
|
||||||
public uint LandblockId => Landblock.LandblockId;
|
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
|
transaction = new PublicationTransaction
|
||||||
{
|
{
|
||||||
Kind = PublicationKind.Far,
|
Kind = PublicationKind.Far,
|
||||||
Build = new LandblockBuild(farLandblock),
|
Build = new LandblockBuild(
|
||||||
|
farLandblock,
|
||||||
|
Origin: completedBuild.Origin),
|
||||||
MeshData = meshData,
|
MeshData = meshData,
|
||||||
LandblockId = farLandblock.LandblockId,
|
LandblockId = farLandblock.LandblockId,
|
||||||
Tier = LandblockStreamTier.Far,
|
Tier = LandblockStreamTier.Far,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ public abstract record LandblockStreamJob(uint LandblockId)
|
||||||
public sealed record Load(
|
public sealed record Load(
|
||||||
uint LandblockId,
|
uint LandblockId,
|
||||||
LandblockStreamJobKind Kind,
|
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(
|
public sealed record Unload(
|
||||||
uint LandblockId,
|
uint LandblockId,
|
||||||
ulong Generation = 0) : LandblockStreamJob(LandblockId);
|
ulong Generation = 0) : LandblockStreamJob(LandblockId);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int DefaultDrainBatchSize = 4;
|
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 Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
|
||||||
private readonly Channel<LandblockStreamJob> _inbox;
|
private readonly Channel<LandblockStreamJob> _inbox;
|
||||||
private readonly Channel<LandblockStreamResult> _outbox;
|
private readonly Channel<LandblockStreamResult> _outbox;
|
||||||
|
|
@ -65,17 +66,17 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
private bool _disposeCompleted;
|
private bool _disposeCompleted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/>
|
/// Primary constructor. The factory receives the complete immutable
|
||||||
/// so it can branch on far-tier vs near-tier and skip entity hydration on far-tier
|
/// request, including job kind and enqueue-time world origin. ISSUE #54
|
||||||
/// loads (heightmap-only). See ISSUE #54: prior to this signature the worker always
|
/// uses the kind to skip entity hydration for heightmap-only far loads.
|
||||||
/// called the full-load path and stripped entities at the output, wasting per-LB
|
|
||||||
/// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LandblockStreamer(
|
private LandblockStreamer(
|
||||||
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
|
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull,
|
||||||
|
bool supportsRequestOrigin)
|
||||||
{
|
{
|
||||||
_loadLandblock = loadLandblock;
|
_loadLandblock = loadLandblock;
|
||||||
|
_supportsRequestOrigin = supportsRequestOrigin;
|
||||||
// Default: no mesh build (returns null → Failed result). Production
|
// Default: no mesh build (returns null → Failed result). Production
|
||||||
// wires in LandblockMesh.Build via the T12 construction site.
|
// wires in LandblockMesh.Build via the T12 construction site.
|
||||||
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
|
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
|
||||||
|
|
@ -85,6 +86,32 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
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>
|
/// <summary>
|
||||||
/// Compatibility constructor for loaders that have no App-owned cell payload.
|
/// Compatibility constructor for loaders that have no App-owned cell payload.
|
||||||
/// Production uses the <see cref="LandblockBuild"/> overload so render state
|
/// 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, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
||||||
: this(
|
: this(
|
||||||
(id, kind) => loadLandblock(id, kind) is { } landblock
|
request => loadLandblock(request.LandblockId, request.Kind) is { } landblock
|
||||||
? new LandblockBuild(landblock)
|
? new LandblockBuild(landblock, Origin: request.Origin)
|
||||||
: null,
|
: 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
|
/// Back-compat overload — wraps a kind-agnostic factory so existing test code
|
||||||
/// that doesn't care about the JobKind branch keeps compiling. The wrapper
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public LandblockStreamer(
|
public LandblockStreamer(
|
||||||
Func<uint, LoadedLandblock?> loadLandblock,
|
Func<uint, LoadedLandblock?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
||||||
: this(
|
: this(
|
||||||
(id, _) => loadLandblock(id) is { } landblock
|
request => loadLandblock(request.LandblockId) is { } landblock
|
||||||
? new LandblockBuild(landblock)
|
? new LandblockBuild(landblock, Origin: request.Origin)
|
||||||
: null,
|
: null,
|
||||||
buildMeshOrNull)
|
buildMeshOrNull,
|
||||||
|
supportsRequestOrigin: false)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,11 +181,47 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear,
|
LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear,
|
||||||
ulong generation = 0)
|
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)
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
|
if (_supportsRequestOrigin && !request.Origin.IsSpecified)
|
||||||
WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation));
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
|
|
@ -358,13 +423,21 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
// factory returns far-tier with entities anyway.
|
// factory returns far-tier with entities anyway.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var build = _loadLandblock(load.LandblockId, load.Kind);
|
var build = _loadLandblock(load.Request);
|
||||||
if (build is null)
|
if (build is null)
|
||||||
{
|
{
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||||
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
|
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
|
||||||
break;
|
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;
|
var lb = build.Landblock;
|
||||||
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
|
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
|
||||||
{
|
{
|
||||||
|
|
@ -388,18 +461,29 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
}
|
}
|
||||||
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
||||||
? LandblockStreamTier.Far : LandblockStreamTier.Near;
|
? LandblockStreamTier.Far : LandblockStreamTier.Near;
|
||||||
if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
|
if (tier == LandblockStreamTier.Far)
|
||||||
{
|
{
|
||||||
// Belt-and-suspenders: factory should have skipped
|
// Belt-and-suspenders: factory should have skipped
|
||||||
// entity hydration for LoadFar. If it didn't, fail
|
// entity hydration for LoadFar. If it didn't, fail
|
||||||
// loud in Debug builds and strip in Release.
|
// loud in Debug builds and strip in Release.
|
||||||
System.Diagnostics.Debug.Assert(false,
|
bool hasNearPayload =
|
||||||
$"Far-tier factory should skip entity hydration; got {lb.Entities.Count} entities for LB 0x{load.LandblockId:X8}");
|
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 = new LoadedLandblock(
|
||||||
lb.LandblockId,
|
lb.LandblockId,
|
||||||
lb.Heightmap,
|
lb.Heightmap,
|
||||||
System.Array.Empty<AcDream.Core.World.WorldEntity>());
|
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||||
build = new LandblockBuild(lb);
|
PhysicsDatBundle.Empty);
|
||||||
|
build = new LandblockBuild(lb, Origin: build.Origin);
|
||||||
}
|
}
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||||
load.LandblockId, tier, build, mesh, load.Generation));
|
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;
|
const uint landblockId = 0x4040FFFFu;
|
||||||
var state = new GpuWorldState();
|
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;
|
LandblockBuild? published = null;
|
||||||
int replayCount = 0;
|
int replayCount = 0;
|
||||||
int recoveryCount = 0;
|
int recoveryCount = 0;
|
||||||
|
|
@ -121,6 +125,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.Empty(published.Landblock.Entities);
|
Assert.Empty(published.Landblock.Entities);
|
||||||
Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats);
|
Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats);
|
||||||
Assert.Null(published.EnvCells);
|
Assert.Null(published.EnvCells);
|
||||||
|
Assert.Equal(origin, published.Origin);
|
||||||
Assert.True(state.IsLoaded(landblockId));
|
Assert.True(state.IsLoaded(landblockId));
|
||||||
Assert.False(state.IsNearTier(landblockId));
|
Assert.False(state.IsNearTier(landblockId));
|
||||||
Assert.Equal(0, replayCount);
|
Assert.Equal(0, replayCount);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue