refactor(streaming): own landblock publication transactions
This commit is contained in:
parent
4001251064
commit
4f965d0699
5 changed files with 1301 additions and 122 deletions
|
|
@ -4608,7 +4608,26 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
if (_frameDiag)
|
||||
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
ApplyLoadedTerrainLocked(build, meshData);
|
||||
try
|
||||
{
|
||||
ApplyLoadedTerrainLocked(build, meshData);
|
||||
}
|
||||
catch (AcDream.App.Streaming.StreamingMutationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// This legacy body spans multiple concrete owners and cannot yet
|
||||
// identify an exact failed suffix. Conservatively report that a
|
||||
// prefix may have committed so the transaction coordinator never
|
||||
// replays terrain/cells/collision/lights/plugin events. Slice-5
|
||||
// checkpoints C-E replace this wrapper with per-owner receipts.
|
||||
throw new AcDream.App.Streaming.StreamingMutationException(
|
||||
$"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.",
|
||||
mutationCommitted: true,
|
||||
error);
|
||||
}
|
||||
if (_frameDiag)
|
||||
{
|
||||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ using AcDream.Core.World;
|
|||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Exact result of one committed landblock bucket mutation. Fallible mesh-pin
|
||||
/// and static-script reconciliation consume this receipt separately, so a
|
||||
/// callback failure cannot force the spatial mutation to replay.
|
||||
/// </summary>
|
||||
internal sealed record GpuLandblockSpatialPublication(
|
||||
uint LandblockId,
|
||||
LoadedLandblock Landblock,
|
||||
IReadOnlyList<ulong> AdditionalRenderIds,
|
||||
IReadOnlyList<WorldEntity> StaticEntities,
|
||||
bool RequiresActivation = true);
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread-owned registry of currently-loaded landblocks and their
|
||||
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
|
||||
|
|
@ -619,12 +631,44 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
LandblockStreamTier tier = LandblockStreamTier.Near)
|
||||
{
|
||||
using MutationBatch mutation = BeginMutationBatch();
|
||||
GpuLandblockSpatialPublication publication =
|
||||
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
|
||||
ActivateLandblockPresentation(publication);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits only canonical buckets, indexes, pending-live merge, tier, and
|
||||
/// readiness-id ownership. The caller holds one outer
|
||||
/// <see cref="MutationBatch"/> through the subsequent
|
||||
/// <see cref="ActivateLandblockPresentation"/> call so live visibility
|
||||
/// observers retain the shipped commit boundary.
|
||||
/// </summary>
|
||||
internal GpuLandblockSpatialPublication CommitLandblockSpatial(
|
||||
LoadedLandblock landblock,
|
||||
IEnumerable<ulong>? additionalRenderIds,
|
||||
LandblockStreamTier tier) =>
|
||||
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
|
||||
|
||||
private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
|
||||
LoadedLandblock landblock,
|
||||
IEnumerable<ulong>? additionalRenderIds,
|
||||
LandblockStreamTier tier)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(landblock);
|
||||
|
||||
// A stale Far completion must never replace a newer Near entity layer.
|
||||
// StreamingController normally filters it before render-side apply;
|
||||
// keep the state boundary independently monotonic as well.
|
||||
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
|
||||
return;
|
||||
{
|
||||
LoadedLandblock retained = _loaded[landblock.LandblockId];
|
||||
return new GpuLandblockSpatialPublication(
|
||||
retained.LandblockId,
|
||||
retained,
|
||||
Array.Empty<ulong>(),
|
||||
Array.Empty<WorldEntity>(),
|
||||
RequiresActivation: false);
|
||||
}
|
||||
|
||||
// If pending live entities have been waiting for this landblock,
|
||||
// merge them into the render-thread-owned mutable entity bucket before
|
||||
|
|
@ -668,26 +712,53 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
_loaded[landblock.LandblockId] = landblock;
|
||||
AddLoadedLandblockToFlatView(landblock);
|
||||
_tierByLandblock[landblock.LandblockId] = tier;
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockLoaded(
|
||||
_loaded[landblock.LandblockId],
|
||||
mergedRenderIds);
|
||||
return CreateSpatialPublication(
|
||||
_loaded[landblock.LandblockId],
|
||||
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>());
|
||||
}
|
||||
|
||||
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
|
||||
// LiveEntityRuntime owns activation for live objects. This static-only
|
||||
// filter avoids replaying their defaults when a pending projection is
|
||||
// drained into a newly loaded landblock.
|
||||
if (_entityScriptActivator is not null)
|
||||
internal void ActivateLandblockPresentation(
|
||||
GpuLandblockSpatialPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!publication.RequiresActivation)
|
||||
return;
|
||||
if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current)
|
||||
|| !ReferenceEquals(current, publication.Landblock))
|
||||
{
|
||||
var loadedEntities = _loaded[landblock.LandblockId].Entities;
|
||||
for (int i = 0; i < loadedEntities.Count; i++)
|
||||
{
|
||||
var e = loadedEntities[i];
|
||||
if (e.ServerGuid == 0)
|
||||
_entityScriptActivator.OnCreate(e);
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"Landblock 0x{publication.LandblockId:X8} changed before presentation activation.");
|
||||
}
|
||||
|
||||
_wbSpawnAdapter?.OnLandblockLoaded(
|
||||
publication.Landblock,
|
||||
publication.AdditionalRenderIds);
|
||||
|
||||
// C.1.5b: fire DefaultScript for dat-hydrated entities only.
|
||||
// EntityScriptActivator owns a retryable per-static acquisition ledger,
|
||||
// so replaying this activation suffix after a later owner failure does
|
||||
// not replay already-started defaults.
|
||||
if (_entityScriptActivator is not null)
|
||||
{
|
||||
for (int i = 0; i < publication.StaticEntities.Count; i++)
|
||||
_entityScriptActivator.OnCreate(publication.StaticEntities[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static GpuLandblockSpatialPublication CreateSpatialPublication(
|
||||
LoadedLandblock landblock,
|
||||
IEnumerable<ulong> additionalRenderIds)
|
||||
{
|
||||
ulong[] renderIds = additionalRenderIds as ulong[]
|
||||
?? additionalRenderIds.ToArray();
|
||||
WorldEntity[] staticEntities = landblock.Entities
|
||||
.Where(static entity => entity.ServerGuid == 0)
|
||||
.ToArray();
|
||||
return new GpuLandblockSpatialPublication(
|
||||
landblock.LandblockId,
|
||||
landblock,
|
||||
renderIds,
|
||||
staticEntities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1228,11 +1299,50 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
IEnumerable<ulong>? additionalRenderIds = null)
|
||||
{
|
||||
using MutationBatch mutation = BeginMutationBatch();
|
||||
GpuLandblockSpatialPublication? publication =
|
||||
CommitEntitiesToExistingLandblockSpatialCore(
|
||||
landblockId,
|
||||
entities,
|
||||
additionalRenderIds,
|
||||
parkIfMissing: true);
|
||||
if (publication is null)
|
||||
return false;
|
||||
|
||||
ActivateLandblockPresentation(publication);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial(
|
||||
uint landblockId,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
IEnumerable<ulong>? additionalRenderIds)
|
||||
{
|
||||
GpuLandblockSpatialPublication? publication =
|
||||
CommitEntitiesToExistingLandblockSpatialCore(
|
||||
landblockId,
|
||||
entities,
|
||||
additionalRenderIds,
|
||||
parkIfMissing: false);
|
||||
return publication
|
||||
?? throw new InvalidOperationException(
|
||||
$"Landblock 0x{landblockId:X8} is not resident for an accepted promotion.");
|
||||
}
|
||||
|
||||
private GpuLandblockSpatialPublication? CommitEntitiesToExistingLandblockSpatialCore(
|
||||
uint landblockId,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
IEnumerable<ulong>? additionalRenderIds,
|
||||
bool parkIfMissing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
|
||||
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
|
||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
if (!_loaded.TryGetValue(canonical, out var lb))
|
||||
{
|
||||
if (!parkIfMissing)
|
||||
return null;
|
||||
|
||||
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
|
||||
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
|
||||
{
|
||||
|
|
@ -1263,7 +1373,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
}
|
||||
renderIds.UnionWith(additionalRenderIds);
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
List<WorldEntity> resident = MutableEntities(lb);
|
||||
for (int i = 0; i < entities.Count; i++)
|
||||
|
|
@ -1278,20 +1388,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
|||
}
|
||||
_animatedIndexByLandblock.Remove(canonical);
|
||||
_tierByLandblock[canonical] = LandblockStreamTier.Near;
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
|
||||
|
||||
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
|
||||
// All entities arriving via this path are atlas-tier by construction
|
||||
// (the promotion path streams in dat-static scenery + EnvCell statics
|
||||
// + stabs per the method's class doc-comment).
|
||||
if (_entityScriptActivator is not null)
|
||||
{
|
||||
for (int i = 0; i < entities.Count; i++)
|
||||
_entityScriptActivator.OnCreate(entities[i]);
|
||||
}
|
||||
|
||||
return true;
|
||||
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty<ulong>();
|
||||
WorldEntity[] staticEntities = entities
|
||||
.Where(static entity => entity.ServerGuid == 0)
|
||||
.ToArray();
|
||||
return new GpuLandblockSpatialPublication(
|
||||
canonical,
|
||||
_loaded[canonical],
|
||||
effectiveRenderIds,
|
||||
staticEntities);
|
||||
}
|
||||
|
||||
private void DrainVisibilityTransitions()
|
||||
|
|
|
|||
299
src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
Normal file
299
src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread transaction coordinator for one accepted landblock streaming
|
||||
/// result. Streaming policy (desired tier, generation, priority, and apply
|
||||
/// budget) remains in <see cref="StreamingController"/>; spatial buckets remain
|
||||
/// in <see cref="GpuWorldState"/>. This owner fixes the presentation ordering
|
||||
/// independently of those policies so the concrete render, physics, and static
|
||||
/// publishers can be extracted behind it without returning callbacks to the
|
||||
/// game window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail establishes cells/buildings before static objects in
|
||||
/// <c>LScape::grab_visible_cells @ 0x00504EC0</c>. Its release path separates
|
||||
/// <c>CLandBlock::release_all @ 0x0052FCF0</c> (objects and visible cells) from
|
||||
/// later destruction in <c>CLandBlock::Destroy @ 0x0052FAA0</c> (static objects
|
||||
/// and buildings). Acdream's detach-first retry ledger is the existing
|
||||
/// asynchronous lifetime adaptation. The injected publication operation
|
||||
/// currently contains the already-shipped production transaction; later
|
||||
/// Slice-5 checkpoints replace it with focused publishers.
|
||||
/// </remarks>
|
||||
public sealed class LandblockPresentationPipeline
|
||||
{
|
||||
private enum PublicationKind : byte
|
||||
{
|
||||
Loaded,
|
||||
PromoteExisting,
|
||||
PromoteSelfContained,
|
||||
Far,
|
||||
}
|
||||
|
||||
private sealed class PublicationTransaction
|
||||
{
|
||||
public required PublicationKind Kind;
|
||||
public required LandblockBuild Build;
|
||||
public required LandblockMeshData MeshData;
|
||||
public required uint LandblockId;
|
||||
public LandblockStreamTier Tier;
|
||||
public bool PresentationCommitted;
|
||||
public bool SpatialCommitted;
|
||||
public GpuLandblockSpatialPublication? SpatialPublication;
|
||||
public bool SpatialPresentationCommitted;
|
||||
public bool EnvCellReplayCommitted;
|
||||
public bool LiveRecoveryCommitted;
|
||||
}
|
||||
|
||||
private readonly Action<LandblockBuild, LandblockMeshData> _publishBeforeSpatialCommit;
|
||||
private readonly GpuWorldState _state;
|
||||
private readonly Action<uint>? _onLandblockLoaded;
|
||||
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
|
||||
private readonly LandblockRetirementCoordinator _retirements;
|
||||
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
|
||||
new(ReferenceEqualityComparer.Instance);
|
||||
|
||||
public LandblockPresentationPipeline(
|
||||
Action<LandblockBuild, LandblockMeshData> publishBeforeSpatialCommit,
|
||||
GpuWorldState state,
|
||||
Action<uint>? onLandblockLoaded = null,
|
||||
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
|
||||
LandblockRetirementCoordinator? retirementCoordinator = null,
|
||||
Action<uint>? removeTerrain = null,
|
||||
Action<uint>? demoteNearLayer = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publishBeforeSpatialCommit);
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
_publishBeforeSpatialCommit = publishBeforeSpatialCommit;
|
||||
_state = state;
|
||||
_onLandblockLoaded = onLandblockLoaded;
|
||||
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
||||
_retirements = retirementCoordinator
|
||||
?? LandblockRetirementCoordinator.CreateLegacy(
|
||||
state,
|
||||
removeTerrain,
|
||||
demoteNearLayer);
|
||||
}
|
||||
|
||||
public int PendingRetirementCount => _retirements.PendingCount;
|
||||
public int PendingPublicationCount => _publications.Count;
|
||||
|
||||
public bool IsRetirementPending(uint landblockId) =>
|
||||
_retirements.IsPending(landblockId);
|
||||
|
||||
public bool HasPendingPublication(LandblockStreamResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return _publications.ContainsKey(result);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LandblockStreamResult> GetPendingPublicationResults() =>
|
||||
_publications.Keys.ToArray();
|
||||
|
||||
public void ResumePublication(LandblockStreamResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!_publications.TryGetValue(result, out PublicationTransaction? transaction))
|
||||
return;
|
||||
Advance(result, transaction);
|
||||
}
|
||||
|
||||
public void AdvanceRetirements() => _retirements.Advance();
|
||||
|
||||
public void BeginFullRetirement(uint landblockId) =>
|
||||
_retirements.BeginFull(landblockId);
|
||||
|
||||
public void BeginNearLayerRetirement(uint landblockId) =>
|
||||
_retirements.BeginNearLayer(landblockId);
|
||||
|
||||
public void PublishLoaded(LandblockStreamResult.Loaded loaded)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loaded);
|
||||
PublicationTransaction transaction = GetOrCreate(
|
||||
loaded,
|
||||
PublicationKind.Loaded,
|
||||
loaded.Build,
|
||||
loaded.MeshData,
|
||||
loaded.LandblockId,
|
||||
loaded.Tier);
|
||||
Advance(loaded, transaction);
|
||||
}
|
||||
|
||||
public void PublishPromoted(
|
||||
LandblockStreamResult.Promoted promoted,
|
||||
bool mergeIntoExistingLandblock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(promoted);
|
||||
PublicationKind kind = mergeIntoExistingLandblock
|
||||
? PublicationKind.PromoteExisting
|
||||
: PublicationKind.PromoteSelfContained;
|
||||
PublicationTransaction transaction = GetOrCreate(
|
||||
promoted,
|
||||
kind,
|
||||
promoted.Build,
|
||||
promoted.MeshData,
|
||||
promoted.LandblockId,
|
||||
LandblockStreamTier.Near);
|
||||
Advance(promoted, transaction);
|
||||
}
|
||||
|
||||
public void PublishAsFar(
|
||||
LandblockStreamResult acceptedResult,
|
||||
LandblockBuild completedBuild,
|
||||
LandblockMeshData meshData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acceptedResult);
|
||||
ArgumentNullException.ThrowIfNull(completedBuild);
|
||||
ArgumentNullException.ThrowIfNull(meshData);
|
||||
|
||||
if (!_publications.TryGetValue(acceptedResult, out PublicationTransaction? transaction))
|
||||
{
|
||||
LoadedLandblock completed = completedBuild.Landblock;
|
||||
var farLandblock = new LoadedLandblock(
|
||||
completed.LandblockId,
|
||||
completed.Heightmap,
|
||||
Array.Empty<WorldEntity>(),
|
||||
PhysicsDatBundle.Empty);
|
||||
transaction = new PublicationTransaction
|
||||
{
|
||||
Kind = PublicationKind.Far,
|
||||
Build = new LandblockBuild(farLandblock),
|
||||
MeshData = meshData,
|
||||
LandblockId = farLandblock.LandblockId,
|
||||
Tier = LandblockStreamTier.Far,
|
||||
};
|
||||
_publications.Add(acceptedResult, transaction);
|
||||
}
|
||||
else if (transaction.Kind != PublicationKind.Far)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending.");
|
||||
}
|
||||
|
||||
Advance(acceptedResult, transaction);
|
||||
}
|
||||
|
||||
private PublicationTransaction GetOrCreate(
|
||||
LandblockStreamResult result,
|
||||
PublicationKind kind,
|
||||
LandblockBuild build,
|
||||
LandblockMeshData meshData,
|
||||
uint landblockId,
|
||||
LandblockStreamTier tier)
|
||||
{
|
||||
if (_publications.TryGetValue(result, out PublicationTransaction? existing))
|
||||
{
|
||||
if (existing.Kind != kind
|
||||
&& !(existing.Kind is PublicationKind.PromoteExisting
|
||||
or PublicationKind.PromoteSelfContained
|
||||
&& kind is PublicationKind.PromoteExisting
|
||||
or PublicationKind.PromoteSelfContained))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Landblock publication 0x{existing.LandblockId:X8} changed kind while pending.");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new PublicationTransaction
|
||||
{
|
||||
Kind = kind,
|
||||
Build = build,
|
||||
MeshData = meshData,
|
||||
LandblockId = landblockId,
|
||||
Tier = tier,
|
||||
};
|
||||
_publications.Add(result, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private void Advance(
|
||||
LandblockStreamResult result,
|
||||
PublicationTransaction transaction)
|
||||
{
|
||||
if (!transaction.PresentationCommitted)
|
||||
{
|
||||
try
|
||||
{
|
||||
_publishBeforeSpatialCommit(transaction.Build, transaction.MeshData);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// The legacy GameWindow callback still spans several concrete
|
||||
// owners and cannot report a committed prefix. Until Slice-5
|
||||
// checkpoints C-E replace it with individually ledgered
|
||||
// publishers, its production wrapper conservatively reports a
|
||||
// committed mutation. Test/compatibility callbacks retain the
|
||||
// established strong-exception contract unless they explicitly
|
||||
// report otherwise.
|
||||
if (error is StreamingMutationException { MutationCommitted: true })
|
||||
_publications.Remove(result);
|
||||
throw;
|
||||
}
|
||||
transaction.PresentationCommitted = true;
|
||||
}
|
||||
|
||||
if (!transaction.SpatialPresentationCommitted)
|
||||
{
|
||||
// Keep live-projection visibility publication at the shipped
|
||||
// boundary: bucket mutation, pin/script reconciliation, then the
|
||||
// outer spatial observer commit. If pinning throws, the receipt is
|
||||
// retained and only that suffix retries.
|
||||
using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch();
|
||||
if (!transaction.SpatialCommitted)
|
||||
{
|
||||
IEnumerable<ulong>? renderIds =
|
||||
transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId);
|
||||
transaction.SpatialPublication = transaction.Kind switch
|
||||
{
|
||||
PublicationKind.Loaded
|
||||
or PublicationKind.PromoteSelfContained
|
||||
or PublicationKind.Far =>
|
||||
_state.CommitLandblockSpatial(
|
||||
transaction.Build.Landblock,
|
||||
renderIds,
|
||||
transaction.Tier),
|
||||
PublicationKind.PromoteExisting =>
|
||||
_state.CommitEntitiesToExistingLandblockSpatial(
|
||||
transaction.LandblockId,
|
||||
transaction.Build.Landblock.Entities,
|
||||
renderIds),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown landblock publication kind {transaction.Kind}."),
|
||||
};
|
||||
transaction.SpatialCommitted = true;
|
||||
}
|
||||
|
||||
_state.ActivateLandblockPresentation(
|
||||
transaction.SpatialPublication
|
||||
?? throw new InvalidOperationException(
|
||||
"A committed spatial publication has no activation receipt."));
|
||||
transaction.SpatialPresentationCommitted = true;
|
||||
}
|
||||
|
||||
if (!transaction.EnvCellReplayCommitted)
|
||||
{
|
||||
if (transaction.Kind != PublicationKind.Far
|
||||
&& transaction.Build.EnvCells is { Shells.Length: > 0 } envCells)
|
||||
{
|
||||
_ensureEnvCellMeshes?.Invoke(envCells);
|
||||
}
|
||||
transaction.EnvCellReplayCommitted = true;
|
||||
}
|
||||
|
||||
if (!transaction.LiveRecoveryCommitted)
|
||||
{
|
||||
// Retained live projections can materialize only after the
|
||||
// canonical bucket exists. Landblock load never reconstructs their
|
||||
// identity or replays create-time resources.
|
||||
_onLandblockLoaded?.Invoke(transaction.LandblockId);
|
||||
transaction.LiveRecoveryCommitted = true;
|
||||
}
|
||||
|
||||
_publications.Remove(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,24 +21,9 @@ public sealed class StreamingController
|
|||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||
private readonly Action<uint, ulong> _enqueueUnload;
|
||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
private readonly LandblockRetirementCoordinator _retirements;
|
||||
private readonly LandblockPresentationPipeline _presentation;
|
||||
|
||||
/// <summary>
|
||||
/// #138: fired after a landblock's entity layer (re)loads — both the full
|
||||
/// <see cref="LandblockStreamResult.Loaded"/> path (initial / dungeon-exit
|
||||
/// expand) and the <see cref="LandblockStreamResult.Promoted"/> path
|
||||
/// (Far→Near). <c>GameWindow</c> wires it to re-hydrate server objects whose
|
||||
/// render entities were dropped by the collapse/demote but whose parsed
|
||||
/// spawns it still retains. Receives the canonical landblock id. Null in
|
||||
/// tests that don't exercise re-hydration.
|
||||
/// </summary>
|
||||
private readonly Action<uint>? _onLandblockLoaded;
|
||||
// Invoked only after LandblockSpawnAdapter has pinned every synthetic
|
||||
// EnvCell geometry id. This re-arms the schema-aware preparation path if
|
||||
// an unowned cached mesh was evicted between worker schedule and publish.
|
||||
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
|
||||
private readonly GpuWorldState _state;
|
||||
private StreamingRegion? _region;
|
||||
private RadiiReconfiguration? _pendingRadiiReconfiguration;
|
||||
|
|
@ -194,16 +179,16 @@ public sealed class StreamingController
|
|||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
_drainCompletions = drainCompletions;
|
||||
_applyTerrain = applyTerrain;
|
||||
_clearPendingLoads = clearPendingLoads;
|
||||
_onLandblockLoaded = onLandblockLoaded;
|
||||
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
||||
_state = state;
|
||||
_retirements = retirementCoordinator
|
||||
?? LandblockRetirementCoordinator.CreateLegacy(
|
||||
state,
|
||||
removeTerrain,
|
||||
demoteNearLayer);
|
||||
_presentation = new LandblockPresentationPipeline(
|
||||
applyTerrain,
|
||||
state,
|
||||
onLandblockLoaded,
|
||||
ensureEnvCellMeshes,
|
||||
retirementCoordinator,
|
||||
removeTerrain,
|
||||
demoteNearLayer);
|
||||
NearRadius = nearRadius;
|
||||
FarRadius = farRadius;
|
||||
}
|
||||
|
|
@ -249,6 +234,12 @@ public sealed class StreamingController
|
|||
if (nearRadius == NearRadius && farRadius == FarRadius)
|
||||
return;
|
||||
|
||||
// A staged old-window publication must either finish or fail before
|
||||
// the desired-tier snapshot below is computed. Otherwise a landblock
|
||||
// that becomes spatially resident during convergence is absent from
|
||||
// the reconfiguration mutation ledger.
|
||||
ConvergePendingPublications();
|
||||
|
||||
if (_collapsed || _region is null)
|
||||
{
|
||||
NearRadius = nearRadius;
|
||||
|
|
@ -348,14 +339,44 @@ public sealed class StreamingController
|
|||
private void DemoteLandblock(uint id)
|
||||
{
|
||||
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
||||
_retirements.BeginNearLayer(canonical);
|
||||
_presentation.BeginNearLayerRetirement(canonical);
|
||||
}
|
||||
|
||||
private void AdvanceGeneration()
|
||||
{
|
||||
ConvergePendingPublications();
|
||||
_generation = unchecked(_generation + 1);
|
||||
}
|
||||
|
||||
private void ConvergePendingPublications()
|
||||
{
|
||||
IReadOnlyList<LandblockStreamResult> pending =
|
||||
_presentation.GetPendingPublicationResults();
|
||||
if (pending.Count == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
_presentation.ResumePublication(pending[i]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A completed staged result can still be present in the deferred
|
||||
// FIFO that originally retained it. Remove only those exact
|
||||
// objects; unrelated accepted completions keep their order.
|
||||
_deferredApply.RemoveAll(result =>
|
||||
{
|
||||
for (int i = 0; i < pending.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(result, pending[i]))
|
||||
return !_presentation.HasPendingPublication(result);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
|
||||
/// are landblock coordinates (0..255) of the current viewer — the camera
|
||||
|
|
@ -382,7 +403,11 @@ public sealed class StreamingController
|
|||
|
||||
// Presentation-owner failures never roll back spatial state. Resume
|
||||
// unfinished owner ledgers before accepting replacement publications.
|
||||
_retirements.Advance();
|
||||
_presentation.AdvanceRetirements();
|
||||
// Complete an admitted publication before this frame can mutate its
|
||||
// desired tier or spatial residence. A later recenter may then demote
|
||||
// or retire the fully known owner set through the normal ledger.
|
||||
ConvergePendingPublications();
|
||||
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||
|
||||
|
|
@ -616,11 +641,12 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||||
{
|
||||
if (!_collapsed || _collapsedCenter != centerId)
|
||||
bool logTransition = !_collapsed || _collapsedCenter != centerId;
|
||||
if (logTransition)
|
||||
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||
AdvanceGeneration();
|
||||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
AdvanceGeneration();
|
||||
_clearPendingLoads?.Invoke();
|
||||
_deferredApply.Clear();
|
||||
_region = null;
|
||||
|
|
@ -685,8 +711,8 @@ public sealed class StreamingController
|
|||
Console.WriteLine(
|
||||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||
_collapsed = false;
|
||||
AdvanceGeneration();
|
||||
_collapsed = false;
|
||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
|
|
@ -715,19 +741,20 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
public void ForceReloadWindow()
|
||||
{
|
||||
_collapsed = false;
|
||||
AdvanceGeneration();
|
||||
_collapsed = false;
|
||||
_clearPendingLoads?.Invoke();
|
||||
_deferredApply.Clear();
|
||||
// Commit the old region boundary before any presentation owner is
|
||||
// advanced. New same-id publications are fenced by _retirements.
|
||||
// advanced. New same-id publications are fenced by the presentation
|
||||
// pipeline's retryable retirement ledger.
|
||||
_region = null;
|
||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
||||
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
|
||||
foreach (var id in ids)
|
||||
_retirements.BeginFull(id);
|
||||
_presentation.BeginFullRetirement(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -757,18 +784,41 @@ public sealed class StreamingController
|
|||
{
|
||||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||
if (chunk.Count == 0) break;
|
||||
foreach (var result in chunk)
|
||||
for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++)
|
||||
{
|
||||
LandblockStreamResult result = chunk[chunkIndex];
|
||||
// Reject obsolete hard-window work before it can occupy the
|
||||
// metered deferred queue. ApplyResult repeats this invariant
|
||||
// for direct callers and future drain paths.
|
||||
if (IsStaleGeneration(result))
|
||||
if (IsStaleGeneration(result)
|
||||
&& !_presentation.HasPendingPublication(result))
|
||||
continue;
|
||||
if (IsPublicationBlockedByRetirement(result))
|
||||
_deferredApply.Add(result);
|
||||
else if (result is LandblockStreamResult.Unloaded
|
||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The presentation pipeline retains its exact committed
|
||||
// stage after the coarse presentation callback commits.
|
||||
// Preserve it and every untouched completion from this
|
||||
// already-drained chunk in original FIFO order. A
|
||||
// presentation-stage failure is deliberately fail-fast
|
||||
// until the focused publishers replace that callback;
|
||||
// its result is not retryable, but the untouched suffix
|
||||
// must still survive.
|
||||
if (_presentation.HasPendingPublication(result))
|
||||
_deferredApply.Add(result);
|
||||
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
|
||||
_deferredApply.Add(chunk[suffix]);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else
|
||||
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
||||
}
|
||||
|
|
@ -798,9 +848,24 @@ public sealed class StreamingController
|
|||
// Advance read only after the apply commits. If it throws, the
|
||||
// finally block removes prior committed entries but retains this
|
||||
// exact result and the untouched tail for retry.
|
||||
ApplyResult(result);
|
||||
read++;
|
||||
applied++;
|
||||
try
|
||||
{
|
||||
ApplyResult(result);
|
||||
read++;
|
||||
applied++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A suffix-stage failure keeps its pipeline receipt and
|
||||
// must remain at this exact FIFO position. The still-
|
||||
// monolithic presentation callback deliberately removes
|
||||
// its receipt on failure because its internal committed
|
||||
// prefix is unknown; consume only that failed result so a
|
||||
// later frame cannot replay it automatically.
|
||||
if (!_presentation.HasPendingPublication(result))
|
||||
read++;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
|
@ -821,6 +886,13 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
private void ApplyResult(LandblockStreamResult result)
|
||||
{
|
||||
if (_presentation.HasPendingPublication(result))
|
||||
{
|
||||
_presentation.ResumePublication(result);
|
||||
ReconcileCompletedPendingPublication(result.LandblockId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsStaleGeneration(result))
|
||||
{
|
||||
// A worker can finish one old load/unload after a hard recenter.
|
||||
|
|
@ -853,7 +925,9 @@ public sealed class StreamingController
|
|||
|
||||
bool isNearCompletion = result is LandblockStreamResult.Promoted
|
||||
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
|
||||
if (isNearCompletion && _state.IsNearTier(result.LandblockId))
|
||||
if (isNearCompletion
|
||||
&& _state.IsNearTier(result.LandblockId)
|
||||
&& !_presentation.HasPendingPublication(result))
|
||||
{
|
||||
// Normal recenter can enqueue a second high-priority Near job
|
||||
// while an earlier one is already in flight. The streamer
|
||||
|
|
@ -878,10 +952,10 @@ public sealed class StreamingController
|
|||
switch (result)
|
||||
{
|
||||
case LandblockStreamResult.Loaded loaded:
|
||||
ApplyAsFar(loaded.Build, loaded.MeshData);
|
||||
_presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData);
|
||||
break;
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
ApplyAsFar(promoted.Build, promoted.MeshData);
|
||||
_presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -900,16 +974,7 @@ public sealed class StreamingController
|
|||
// layer that now owns the landblock.
|
||||
break;
|
||||
}
|
||||
_applyTerrain(loaded.Build, loaded.MeshData);
|
||||
_state.AddLandblock(
|
||||
loaded.Landblock,
|
||||
loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId),
|
||||
loaded.Tier);
|
||||
EnsureEnvCellMeshesAfterPin(loaded.Build);
|
||||
// #138: after the landblock is in _loaded (so live projection
|
||||
// hot-paths), restore any retained server objects ACE won't
|
||||
// re-send. Fired AFTER AddLandblock, never before.
|
||||
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
||||
_presentation.PublishLoaded(loaded);
|
||||
break;
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
// PromoteToNear carries a complete build and mesh because the
|
||||
|
|
@ -917,28 +982,12 @@ public sealed class StreamingController
|
|||
// that Far job never started, publish this as the real Near
|
||||
// landblock; if the base is already resident, merge only the
|
||||
// Near layer so existing live projections retain identity.
|
||||
_applyTerrain(promoted.Build, promoted.MeshData);
|
||||
var promotedRenderIds =
|
||||
promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
|
||||
if (_state.IsLoaded(promoted.LandblockId))
|
||||
{
|
||||
_state.AddEntitiesToExistingLandblock(
|
||||
promoted.LandblockId,
|
||||
promoted.Entities,
|
||||
promotedRenderIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.AddLandblock(
|
||||
promoted.Landblock,
|
||||
promotedRenderIds,
|
||||
LandblockStreamTier.Near);
|
||||
}
|
||||
EnsureEnvCellMeshesAfterPin(promoted.Build);
|
||||
_onLandblockLoaded?.Invoke(promoted.LandblockId);
|
||||
_presentation.PublishPromoted(
|
||||
promoted,
|
||||
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
|
||||
break;
|
||||
case LandblockStreamResult.Unloaded unloaded:
|
||||
_retirements.BeginFull(unloaded.LandblockId);
|
||||
_presentation.BeginFullRetirement(unloaded.LandblockId);
|
||||
break;
|
||||
case LandblockStreamResult.Failed failed:
|
||||
Console.WriteLine(
|
||||
|
|
@ -951,25 +1000,21 @@ public sealed class StreamingController
|
|||
}
|
||||
}
|
||||
|
||||
private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
|
||||
private void ReconcileCompletedPendingPublication(uint landblockId)
|
||||
{
|
||||
var completed = completedBuild.Landblock;
|
||||
var farLandblock = new LoadedLandblock(
|
||||
completed.LandblockId,
|
||||
completed.Heightmap,
|
||||
Array.Empty<WorldEntity>(),
|
||||
PhysicsDatBundle.Empty);
|
||||
var farBuild = new LandblockBuild(farLandblock);
|
||||
if (_region is null
|
||||
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
|
||||
{
|
||||
if (_state.IsLoaded(landblockId))
|
||||
_presentation.BeginFullRetirement(landblockId);
|
||||
return;
|
||||
}
|
||||
|
||||
_applyTerrain(farBuild, meshData);
|
||||
_state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far);
|
||||
_onLandblockLoaded?.Invoke(farLandblock.LandblockId);
|
||||
}
|
||||
|
||||
private void EnsureEnvCellMeshesAfterPin(LandblockBuild build)
|
||||
{
|
||||
if (build.EnvCells is { Shells.Length: > 0 } envCells)
|
||||
_ensureEnvCellMeshes?.Invoke(envCells);
|
||||
if (desiredTier == LandblockStreamTier.Far
|
||||
&& _state.IsNearTier(landblockId))
|
||||
{
|
||||
_presentation.BeginNearLayerRetirement(landblockId);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsStaleGeneration(LandblockStreamResult result) =>
|
||||
|
|
@ -978,7 +1023,7 @@ public sealed class StreamingController
|
|||
|
||||
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
|
||||
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
|
||||
&& _retirements.IsPending(result.LandblockId);
|
||||
&& _presentation.IsRetirementPending(result.LandblockId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the landblock id associated with <paramref name="result"/>.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,711 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class LandblockPresentationPipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder()
|
||||
{
|
||||
const uint landblockId = 0x2020FFFFu;
|
||||
var calls = new List<string>();
|
||||
var meshes = new RecordingMeshAdapter(calls);
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
LandblockBuild build = BuildWithShell(landblockId);
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (accepted, _) =>
|
||||
{
|
||||
Assert.Same(build, accepted);
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
calls.Add("presentation");
|
||||
},
|
||||
state,
|
||||
onLandblockLoaded: id =>
|
||||
{
|
||||
Assert.Equal(landblockId, id);
|
||||
Assert.True(state.IsLoaded(id));
|
||||
calls.Add("live-recovery");
|
||||
},
|
||||
ensureEnvCellMeshes: envCells =>
|
||||
{
|
||||
Assert.Same(build.EnvCells, envCells);
|
||||
Assert.True(state.IsLoaded(landblockId));
|
||||
calls.Add("mesh-replay-after-pin");
|
||||
});
|
||||
|
||||
pipeline.PublishLoaded(new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
build,
|
||||
EmptyMesh()));
|
||||
|
||||
Assert.Equal(
|
||||
["presentation", "pin", "mesh-replay-after-pin", "live-recovery"],
|
||||
calls);
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Promoted_ExistingFar_MergesNearLayerBeforeReplayAndRecovery()
|
||||
{
|
||||
const uint landblockId = 0x3030FFFFu;
|
||||
var calls = new List<string>();
|
||||
var meshes = new RecordingMeshAdapter(calls);
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>(),
|
||||
PhysicsDatBundle.Empty),
|
||||
tier: LandblockStreamTier.Far);
|
||||
LandblockBuild build = BuildWithShell(landblockId, Entity(7));
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"),
|
||||
state,
|
||||
onLandblockLoaded: _ =>
|
||||
{
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
Assert.Contains(state.Entities, entity => entity.Id == 7);
|
||||
calls.Add("live-recovery");
|
||||
},
|
||||
ensureEnvCellMeshes: _ =>
|
||||
{
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
Assert.Contains(state.Entities, entity => entity.Id == 7);
|
||||
calls.Add("mesh-replay-after-pin");
|
||||
});
|
||||
|
||||
pipeline.PublishPromoted(new LandblockStreamResult.Promoted(
|
||||
landblockId,
|
||||
build,
|
||||
EmptyMesh()),
|
||||
mergeIntoExistingLandblock: true);
|
||||
|
||||
Assert.Equal(
|
||||
["presentation", "pin", "mesh-replay-after-pin", "live-recovery"],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishAsFar_StripsNearPayloadAndSkipsEnvCellReplay()
|
||||
{
|
||||
const uint landblockId = 0x4040FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
LandblockBuild source = BuildWithShell(landblockId, Entity(9));
|
||||
LandblockBuild? published = null;
|
||||
int replayCount = 0;
|
||||
int recoveryCount = 0;
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (build, _) => published = build,
|
||||
state,
|
||||
onLandblockLoaded: _ => recoveryCount++,
|
||||
ensureEnvCellMeshes: _ => replayCount++);
|
||||
|
||||
var accepted = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
source,
|
||||
EmptyMesh());
|
||||
pipeline.PublishAsFar(accepted, source, accepted.MeshData);
|
||||
|
||||
Assert.NotNull(published);
|
||||
Assert.Empty(published.Landblock.Entities);
|
||||
Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats);
|
||||
Assert.Null(published.EnvCells);
|
||||
Assert.True(state.IsLoaded(landblockId));
|
||||
Assert.False(state.IsNearTier(landblockId));
|
||||
Assert.Equal(0, replayCount);
|
||||
Assert.Equal(1, recoveryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvCellReplayFailure_ResumesSuffixWithoutReplayingCommittedPrefix()
|
||||
{
|
||||
const uint landblockId = 0x5151FFFFu;
|
||||
var calls = new List<string>();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(
|
||||
new RecordingMeshAdapter(calls)));
|
||||
bool failReplayOnce = true;
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"),
|
||||
state,
|
||||
onLandblockLoaded: _ => calls.Add("live-recovery"),
|
||||
ensureEnvCellMeshes: _ =>
|
||||
{
|
||||
calls.Add("mesh-replay");
|
||||
if (failReplayOnce)
|
||||
{
|
||||
failReplayOnce = false;
|
||||
throw new InvalidOperationException("injected replay failure");
|
||||
}
|
||||
});
|
||||
LandblockBuild build = BuildWithShell(landblockId);
|
||||
var result = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
build,
|
||||
EmptyMesh());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pipeline.PublishLoaded(result));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
Assert.True(pipeline.HasPendingPublication(result));
|
||||
|
||||
pipeline.PublishLoaded(result);
|
||||
|
||||
Assert.Equal(
|
||||
["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"],
|
||||
calls);
|
||||
Assert.False(pipeline.HasPendingPublication(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromotionReplayFailure_RetainsOriginalSelfContainedModeAcrossRetry()
|
||||
{
|
||||
const uint landblockId = 0x5252FFFFu;
|
||||
var calls = new List<string>();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(
|
||||
new RecordingMeshAdapter(calls)));
|
||||
bool failReplayOnce = true;
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"),
|
||||
state,
|
||||
onLandblockLoaded: _ => calls.Add("live-recovery"),
|
||||
ensureEnvCellMeshes: _ =>
|
||||
{
|
||||
calls.Add("mesh-replay");
|
||||
if (failReplayOnce)
|
||||
{
|
||||
failReplayOnce = false;
|
||||
throw new InvalidOperationException("injected replay failure");
|
||||
}
|
||||
});
|
||||
LandblockBuild build = BuildWithShell(landblockId, Entity(17));
|
||||
var result = new LandblockStreamResult.Promoted(
|
||||
landblockId,
|
||||
build,
|
||||
EmptyMesh());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pipeline.PublishPromoted(
|
||||
result,
|
||||
mergeIntoExistingLandblock: false));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
|
||||
// Current residency changed after the first attempt. The transaction
|
||||
// must retain the original self-contained publication command rather
|
||||
// than switching policy mid-retry.
|
||||
pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true);
|
||||
|
||||
Assert.Equal(
|
||||
["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"],
|
||||
calls);
|
||||
Assert.Single(state.Entities, entity => entity.Id == 17);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpatialPinFailure_ControllerRetriesExactCompletionWithoutReplayingPresentation()
|
||||
{
|
||||
const uint landblockId = 0x6060FFFFu;
|
||||
var meshes = new FailFirstPreparedPinAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
LandblockBuild build = BuildWithShell(landblockId);
|
||||
outbox.Enqueue(new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
build,
|
||||
EmptyMesh()));
|
||||
int presentationCalls = 0;
|
||||
int replayCalls = 0;
|
||||
int liveRecoveryCalls = 0;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (_, _) => { },
|
||||
enqueueUnload: _ => { },
|
||||
drainCompletions: max => Drain(outbox, max),
|
||||
applyTerrain: (_, _) => presentationCalls++,
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
onLandblockLoaded: _ => liveRecoveryCalls++,
|
||||
ensureEnvCellMeshes: _ => replayCalls++);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x60, 0x60));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
Assert.Equal(1, presentationCalls);
|
||||
Assert.Equal(0, replayCalls);
|
||||
Assert.Equal(0, liveRecoveryCalls);
|
||||
|
||||
controller.Tick(0x60, 0x60);
|
||||
|
||||
Assert.Equal(1, presentationCalls);
|
||||
Assert.Equal(2, meshes.PinAttempts);
|
||||
Assert.Equal(1, replayCalls);
|
||||
Assert.Equal(1, liveRecoveryCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromotionPinFailure_RetriesActivationWithoutAppendingEntitiesAgain()
|
||||
{
|
||||
const uint landblockId = 0x6161FFFFu;
|
||||
var meshes = new FailFirstPreparedPinAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()),
|
||||
tier: LandblockStreamTier.Far);
|
||||
int presentationCalls = 0;
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) => presentationCalls++,
|
||||
state);
|
||||
WorldEntity promotedEntity = Entity(31);
|
||||
LandblockBuild build = BuildWithShell(landblockId, promotedEntity);
|
||||
var result = new LandblockStreamResult.Promoted(
|
||||
landblockId,
|
||||
build,
|
||||
EmptyMesh());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pipeline.PublishPromoted(
|
||||
result,
|
||||
mergeIntoExistingLandblock: true));
|
||||
Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity));
|
||||
|
||||
pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true);
|
||||
|
||||
Assert.Equal(1, presentationCalls);
|
||||
Assert.Equal(2, meshes.PinAttempts);
|
||||
Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity));
|
||||
Assert.False(pipeline.HasPendingPublication(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedPinFailure_RetryPreservesPendingLiveProjectionMergedAtSpatialCommit()
|
||||
{
|
||||
const uint landblockId = 0x6262FFFFu;
|
||||
var meshes = new FailFirstPreparedPinAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var pendingLive = new WorldEntity
|
||||
{
|
||||
Id = 77,
|
||||
ServerGuid = 0x80000077u,
|
||||
SourceGfxObjOrSetupId = 0x01000077u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(0x01000077u, Matrix4x4.Identity)],
|
||||
};
|
||||
state.PlaceLiveEntityProjection(0x62620100u, pendingLive);
|
||||
LandblockBuild build = BuildWithShell(landblockId, Entity(32));
|
||||
var result = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
build,
|
||||
EmptyMesh());
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
static (_, _) => { },
|
||||
state);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pipeline.PublishLoaded(result));
|
||||
Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive));
|
||||
Assert.Equal(0, state.PendingLiveEntityCount);
|
||||
|
||||
pipeline.PublishLoaded(result);
|
||||
|
||||
Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive));
|
||||
Assert.Single(state.Entities, entity => ReferenceEquals(entity, pendingLive));
|
||||
Assert.Equal(0, state.PendingLiveEntityCount);
|
||||
Assert.Equal(2, meshes.PinAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityFailure_PreservesFailingResultAndUntouchedDrainedSuffix()
|
||||
{
|
||||
const uint priorityId = 0x7070FFFFu;
|
||||
const uint laterId = 0x7071FFFFu;
|
||||
const uint unloadId = 0x1010FFFFu;
|
||||
var meshes = new FailFirstPreparedPinAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
unloadId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var outbox = new Queue<LandblockStreamResult>(
|
||||
[
|
||||
new LandblockStreamResult.Loaded(
|
||||
priorityId,
|
||||
LandblockStreamTier.Near,
|
||||
BuildWithShell(priorityId),
|
||||
EmptyMesh()),
|
||||
new LandblockStreamResult.Loaded(
|
||||
laterId,
|
||||
LandblockStreamTier.Near,
|
||||
BuildWithShell(laterId),
|
||||
EmptyMesh()),
|
||||
new LandblockStreamResult.Unloaded(unloadId),
|
||||
]);
|
||||
var calls = new List<string>();
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (_, _) => { },
|
||||
enqueueUnload: _ => { },
|
||||
drainCompletions: max => Drain(outbox, max),
|
||||
applyTerrain: (build, _) => calls.Add($"publish:{build.LandblockId:X8}"),
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 2,
|
||||
removeTerrain: id => calls.Add($"unload:{id:X8}"));
|
||||
controller.PriorityLandblockId = priorityId;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x70, 0x70));
|
||||
Assert.Empty(outbox);
|
||||
Assert.False(state.IsLoaded(laterId));
|
||||
Assert.True(state.IsLoaded(unloadId));
|
||||
|
||||
controller.PriorityLandblockId = 0;
|
||||
controller.Tick(0x70, 0x70);
|
||||
|
||||
Assert.True(state.IsLoaded(priorityId));
|
||||
Assert.True(state.IsLoaded(laterId));
|
||||
Assert.False(state.IsLoaded(unloadId));
|
||||
Assert.Equal(
|
||||
[
|
||||
$"publish:{priorityId:X8}",
|
||||
$"publish:{laterId:X8}",
|
||||
$"unload:{unloadId:X8}",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardRecenter_ConvergesPendingSpatialPublicationBeforeRetiringIt()
|
||||
{
|
||||
const uint landblockId = 0x8080FFFFu;
|
||||
var meshes = new FailFirstPreparedPinAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
var result = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
BuildWithShell(landblockId),
|
||||
EmptyMesh());
|
||||
outbox.Enqueue(result);
|
||||
int presentationCalls = 0;
|
||||
int replayCalls = 0;
|
||||
int recoveryCalls = 0;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (_, _) => { },
|
||||
enqueueUnload: _ => { },
|
||||
drainCompletions: max => Drain(outbox, max),
|
||||
applyTerrain: (_, _) => presentationCalls++,
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
onLandblockLoaded: _ => recoveryCalls++,
|
||||
ensureEnvCellMeshes: _ => replayCalls++);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x80, 0x80));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
|
||||
controller.ForceReloadWindow();
|
||||
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
Assert.Equal(1, presentationCalls);
|
||||
Assert.Equal(1, replayCalls);
|
||||
Assert.Equal(1, recoveryCalls);
|
||||
Assert.Equal(2, meshes.PinAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesiredNearToFarChurn_ResumesOriginalPublicationThenDemotes()
|
||||
{
|
||||
const uint landblockId = 0x9090FFFFu;
|
||||
var calls = new List<string>();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(
|
||||
new RecordingMeshAdapter(calls)));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
var result = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
BuildWithShell(landblockId, Entity(23)),
|
||||
EmptyMesh());
|
||||
outbox.Enqueue(result);
|
||||
bool failReplayOnce = true;
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (_, _) => { },
|
||||
enqueueUnload: _ => { },
|
||||
drainCompletions: max => Drain(outbox, max),
|
||||
applyTerrain: (_, _) => calls.Add("presentation"),
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 2,
|
||||
demoteNearLayer: _ => calls.Add("demote"),
|
||||
ensureEnvCellMeshes: _ =>
|
||||
{
|
||||
calls.Add("mesh-replay");
|
||||
if (failReplayOnce)
|
||||
{
|
||||
failReplayOnce = false;
|
||||
throw new InvalidOperationException("injected replay failure");
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x90, 0x90));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
|
||||
controller.Tick(0x90, 0x93);
|
||||
|
||||
Assert.True(state.IsLoaded(landblockId));
|
||||
Assert.False(state.IsNearTier(landblockId));
|
||||
Assert.DoesNotContain(state.Entities, entity => entity.Id == 23);
|
||||
Assert.Equal(1, calls.Count(call => call == "presentation"));
|
||||
Assert.Equal(2, calls.Count(call => call == "mesh-replay"));
|
||||
Assert.Contains("demote", calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoarsePresentationFailure_IsFailFastAndNotRetainedForAutomaticReplay()
|
||||
{
|
||||
const uint landblockId = 0xA0A0FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
int presentationCalls = 0;
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) =>
|
||||
{
|
||||
presentationCalls++;
|
||||
throw new StreamingMutationException(
|
||||
"injected committed coarse failure",
|
||||
mutationCommitted: true);
|
||||
},
|
||||
state);
|
||||
var result = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
BuildWithShell(landblockId),
|
||||
EmptyMesh());
|
||||
|
||||
Assert.Throws<StreamingMutationException>(() => pipeline.PublishLoaded(result));
|
||||
|
||||
Assert.Equal(1, presentationCalls);
|
||||
Assert.False(pipeline.HasPendingPublication(result));
|
||||
Assert.Equal(0, pipeline.PendingPublicationCount);
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredCommittedPresentationFailure_DropsOnlyFailedResultAndPreservesTail()
|
||||
{
|
||||
const uint failedId = 0xB0B0FFFFu;
|
||||
const uint tailId = 0xB0B1FFFFu;
|
||||
var outbox = new Queue<LandblockStreamResult>(
|
||||
[
|
||||
new LandblockStreamResult.Loaded(
|
||||
failedId,
|
||||
LandblockStreamTier.Near,
|
||||
new LandblockBuild(new LoadedLandblock(
|
||||
failedId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>())),
|
||||
EmptyMesh()),
|
||||
new LandblockStreamResult.Loaded(
|
||||
tailId,
|
||||
LandblockStreamTier.Near,
|
||||
new LandblockBuild(new LoadedLandblock(
|
||||
tailId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>())),
|
||||
EmptyMesh()),
|
||||
]);
|
||||
int failedCalls = 0;
|
||||
int tailCalls = 0;
|
||||
var state = new GpuWorldState();
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: (_, _) => { },
|
||||
enqueueUnload: _ => { },
|
||||
drainCompletions: max => Drain(outbox, max),
|
||||
applyTerrain: (build, _) =>
|
||||
{
|
||||
if (build.LandblockId == failedId)
|
||||
{
|
||||
failedCalls++;
|
||||
throw new StreamingMutationException(
|
||||
"injected committed presentation failure",
|
||||
mutationCommitted: true);
|
||||
}
|
||||
tailCalls++;
|
||||
},
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 2);
|
||||
|
||||
Assert.Throws<StreamingMutationException>(() => controller.Tick(0xB0, 0xB0));
|
||||
Assert.Equal(1, failedCalls);
|
||||
Assert.Equal(0, tailCalls);
|
||||
Assert.Equal(1, controller.DeferredApplyBacklog);
|
||||
|
||||
controller.Tick(0xB0, 0xB0);
|
||||
|
||||
Assert.Equal(1, failedCalls);
|
||||
Assert.Equal(1, tailCalls);
|
||||
Assert.False(state.IsLoaded(failedId));
|
||||
Assert.True(state.IsLoaded(tailId));
|
||||
Assert.Equal(0, controller.DeferredApplyBacklog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetirementFacade_DetachesFirstAndReportsPendingLedger()
|
||||
{
|
||||
const uint landblockId = 0x5050FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
bool failOnce = true;
|
||||
var retirements = new LandblockRetirementCoordinator(
|
||||
state,
|
||||
ticket =>
|
||||
{
|
||||
ticket.RunOnce(
|
||||
LandblockRetirementStage.EntityLighting,
|
||||
() =>
|
||||
{
|
||||
if (failOnce)
|
||||
{
|
||||
failOnce = false;
|
||||
throw new InvalidOperationException("injected failure");
|
||||
}
|
||||
});
|
||||
},
|
||||
_ => LandblockRetirementStage.EntityLighting);
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
static (_, _) => { },
|
||||
state,
|
||||
retirementCoordinator: retirements);
|
||||
|
||||
pipeline.BeginFullRetirement(landblockId);
|
||||
|
||||
Assert.False(state.IsLoaded(landblockId));
|
||||
Assert.True(pipeline.IsRetirementPending(landblockId));
|
||||
Assert.Equal(1, pipeline.PendingRetirementCount);
|
||||
|
||||
pipeline.AdvanceRetirements();
|
||||
|
||||
Assert.False(pipeline.IsRetirementPending(landblockId));
|
||||
Assert.Equal(0, pipeline.PendingRetirementCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_DoesNotOwnStreamingPolicyOrLiveIdentityMaps()
|
||||
{
|
||||
string[] forbiddenFragments =
|
||||
[
|
||||
"generation",
|
||||
"desiredtier",
|
||||
"streamingregion",
|
||||
"serverguid",
|
||||
"liveentity",
|
||||
];
|
||||
|
||||
string[] fields = typeof(LandblockPresentationPipeline)
|
||||
.GetFields(System.Reflection.BindingFlags.Instance
|
||||
| System.Reflection.BindingFlags.NonPublic)
|
||||
.Select(field => $"{field.Name}:{field.FieldType.FullName}")
|
||||
.ToArray();
|
||||
|
||||
foreach (string field in fields)
|
||||
foreach (string forbidden in forbiddenFragments)
|
||||
Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static LandblockBuild BuildWithShell(
|
||||
uint landblockId,
|
||||
params WorldEntity[] entities)
|
||||
{
|
||||
uint cellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
var shell = new EnvCellShellPlacement(
|
||||
cellId,
|
||||
0x2_0000_0001UL,
|
||||
0x0D000001u,
|
||||
1,
|
||||
ImmutableArray<ushort>.Empty,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
Matrix4x4.Identity,
|
||||
new WbBoundingBox(Vector3.Zero, Vector3.One),
|
||||
new WbBoundingBox(Vector3.Zero, Vector3.One));
|
||||
var envCells = new EnvCellLandblockBuild(
|
||||
landblockId,
|
||||
Array.Empty<LoadedCell>(),
|
||||
[shell]);
|
||||
return new LandblockBuild(
|
||||
new LoadedLandblock(
|
||||
landblockId,
|
||||
new LandBlock(),
|
||||
entities,
|
||||
PhysicsDatBundle.Empty),
|
||||
envCells);
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint id) => new()
|
||||
{
|
||||
Id = id,
|
||||
SourceGfxObjOrSetupId = 0x01000000u + id,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static LandblockMeshData EmptyMesh() => new(
|
||||
Array.Empty<TerrainVertex>(),
|
||||
Array.Empty<uint>());
|
||||
|
||||
private static IReadOnlyList<LandblockStreamResult> Drain(
|
||||
Queue<LandblockStreamResult> queue,
|
||||
int max)
|
||||
{
|
||||
var drained = new List<LandblockStreamResult>(max);
|
||||
while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result))
|
||||
drained.Add(result);
|
||||
return drained;
|
||||
}
|
||||
|
||||
private sealed class RecordingMeshAdapter(List<string> calls) : IWbMeshAdapter
|
||||
{
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
}
|
||||
|
||||
public void PinPreparedRenderData(ulong id) => calls.Add("pin");
|
||||
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailFirstPreparedPinAdapter : IWbMeshAdapter
|
||||
{
|
||||
public int PinAttempts { get; private set; }
|
||||
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
}
|
||||
|
||||
public void PinPreparedRenderData(ulong id)
|
||||
{
|
||||
PinAttempts++;
|
||||
if (PinAttempts == 1)
|
||||
throw new InvalidOperationException("injected pin failure");
|
||||
}
|
||||
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue