diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 50b6db03..ac7aca12 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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. /// 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(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.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(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. /// private List 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(); 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(); @@ -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 diff --git a/src/AcDream.App/Streaming/LandblockBuild.cs b/src/AcDream.App/Streaming/LandblockBuild.cs index 0f4485de..cc17ff99 100644 --- a/src/AcDream.App/Streaming/LandblockBuild.cs +++ b/src/AcDream.App/Streaming/LandblockBuild.cs @@ -10,7 +10,8 @@ namespace AcDream.App.Streaming; /// public sealed record LandblockBuild( LoadedLandblock Landblock, - EnvCellLandblockBuild? EnvCells = null) + EnvCellLandblockBuild? EnvCells = null, + LandblockBuildOrigin Origin = default) { public uint LandblockId => Landblock.LandblockId; } diff --git a/src/AcDream.App/Streaming/LandblockBuildRequest.cs b/src/AcDream.App/Streaming/LandblockBuildRequest.cs new file mode 100644 index 00000000..315753f4 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockBuildRequest.cs @@ -0,0 +1,36 @@ +namespace AcDream.App.Streaming; + +/// +/// 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. +/// +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; } + + /// + /// Distinguishes a captured origin at the valid map coordinate (0,0) + /// from an old compatibility request that carries no origin at all. + /// + public bool IsSpecified { get; } +} + +/// +/// Complete immutable input to one CPU-side landblock build. +/// is carried for matching and diagnostics only; +/// residency policy remains owned by . +/// +public readonly record struct LandblockBuildRequest( + uint LandblockId, + LandblockStreamJobKind Kind, + ulong Generation, + LandblockBuildOrigin Origin); diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 24fb3888..5f815cb2 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -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, diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index 51bcfc9d..17017cd2 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -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); diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index f06fac01..84f2e9c8 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -52,7 +52,8 @@ public sealed class LandblockStreamer : IDisposable /// public const int DefaultDrainBatchSize = 4; - private readonly Func _loadLandblock; + private readonly Func _loadLandblock; + private readonly bool _supportsRequestOrigin; private readonly Func _buildMeshOrNull; private readonly Channel _inbox; private readonly Channel _outbox; @@ -65,17 +66,17 @@ public sealed class LandblockStreamer : IDisposable private bool _disposeCompleted; /// - /// Primary ctor — the factory takes the job's - /// 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 - /// LandBlockInfo + SceneryGenerator 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. /// - public LandblockStreamer( - Func loadLandblock, - Func? buildMeshOrNull = null) + private LandblockStreamer( + Func loadLandblock, + Func? 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 }); } + /// + /// Creates the request-aware production streamer. A named factory keeps + /// legacy one-parameter loader lambdas source-compatible without an + /// ambiguous constructor overload. + /// + public static LandblockStreamer CreateForRequests( + Func loadLandblock, + Func? buildMeshOrNull = null) => + new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true); + + /// + /// Compatibility constructor for build factories that predate the + /// immutable request/origin seam. + /// + public LandblockStreamer( + Func loadLandblock, + Func? buildMeshOrNull = null) + : this( + request => loadLandblock(request.LandblockId, request.Kind) is { } build + ? build + : null, + buildMeshOrNull, + supportsRequestOrigin: false) + { + } + /// /// Compatibility constructor for loaders that have no App-owned cell payload. /// Production uses the overload so render state @@ -94,10 +121,11 @@ public sealed class LandblockStreamer : IDisposable Func loadLandblock, Func? 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 . /// public LandblockStreamer( Func loadLandblock, Func? 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)); + } + + /// + /// Non-blocking enqueue of the exact request captured by the update + /// thread. The worker and completion retain its origin unchanged. + /// + 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)); } /// @@ -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()); - build = new LandblockBuild(lb); + System.Array.Empty(), + PhysicsDatBundle.Empty); + build = new LandblockBuild(lb, Origin: build.Origin); } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( load.LandblockId, tier, build, mesh, load.Generation)); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs new file mode 100644 index 00000000..63cc5a67 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -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( + 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( + 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(() => 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(() => + streamer.EnqueueLoad(0xA9B4FFFFu)); + } + + [Fact] + public void RequestAwareLoader_RejectsDirectRequestWithUnspecifiedOrigin() + { + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin)); + + Assert.Throws(() => streamer.EnqueueLoad( + new LandblockBuildRequest( + 0xA9B4FFFFu, + LandblockStreamJobKind.LoadNear, + Generation: 80, + Origin: default))); + } + + [Fact] + public async Task TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration() + { + var observed = new List(); + 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 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() + .Select(result => result.Build.Origin)); + } + + [Fact] + public async Task QueuedPromotion_SupersedesOldFarWithItsOwnOriginAndGeneration() + { + var observed = new List(); + 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( + 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(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + var envCells = new EnvCellLandblockBuild( + landblockId, + Array.Empty(), + Array.Empty()); + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => new LandblockBuild( + new LoadedLandblock( + request.LandblockId, + new LandBlock(), + Array.Empty(), + physics), + envCells, + request.Origin), + buildMeshOrNull: (_, _) => EmptyMesh()); + streamer.EnqueueLoad(new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.LoadFar, + Generation: 84, + origin)); + streamer.Start(); + + var loaded = Assert.IsType( + 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()), + Origin: origin); + + private static LandblockMeshData EmptyMesh() => + new(Array.Empty(), Array.Empty()); + + private static async Task DrainFirstAsync( + LandblockStreamer streamer) => + (await DrainCountAsync(streamer, 1))[0]; + + private static async Task> DrainCountAsync( + LandblockStreamer streamer, + int count) + { + var results = new List(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."); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index fc89089f..0357c1b2 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -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);