refactor(streaming): capture landblock build origin
This commit is contained in:
parent
2216a02de5
commit
090b0354ff
8 changed files with 522 additions and 54 deletions
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue