fix(streaming): preserve portal destination ownership
Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
This commit is contained in:
parent
2c848d4167
commit
823936ec31
57 changed files with 2551 additions and 324 deletions
|
|
@ -264,6 +264,29 @@ public sealed class CompositeTextureArrayCachePolicyTests
|
|||
Assert.Equal(2, backend.Uploads.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestinationReveal_RaisesOnlyItemCountAndRestoresNormalProfile()
|
||||
{
|
||||
var backend = new FakeBackend(maximumLayers: 64);
|
||||
using var cache = new CompositeTextureArrayCache(
|
||||
backend,
|
||||
new DeferredRetirementQueue(),
|
||||
unownedBudgetBytes: long.MaxValue,
|
||||
physicalBudgetBytes: long.MaxValue,
|
||||
maximumUploadsPerFrame: 2,
|
||||
maximumUploadBytesPerFrame: long.MaxValue);
|
||||
|
||||
cache.BeginFrame(destinationRevealUploadPriority: true);
|
||||
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(1, 1), out _));
|
||||
Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(1, 1), out _));
|
||||
Assert.True(cache.TryAddAndAcquire(1, Key(3), Texture(1, 1), out _));
|
||||
|
||||
cache.BeginFrame();
|
||||
Assert.True(cache.TryAddAndAcquire(1, Key(4), Texture(1, 1), out _));
|
||||
Assert.True(cache.TryAddAndAcquire(1, Key(5), Texture(1, 1), out _));
|
||||
Assert.False(cache.TryAddAndAcquire(1, Key(6), Texture(1, 1), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedTextureIsAllowedAsOnlyUploadToGuaranteeProgress()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,37 +59,100 @@ public sealed class PaperdollFramePresenterTests
|
|||
presenter.Render();
|
||||
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
Assert.Equal(2, renderer.Dolls.Count);
|
||||
Assert.Single(renderer.Dolls);
|
||||
Assert.Equal(3, renderer.RenderCount);
|
||||
Assert.False(presenter.IsDirty);
|
||||
}
|
||||
|
||||
private static WorldEntity CreateDoll() => new()
|
||||
[Fact]
|
||||
public void EquivalentPortalRefresh_KeepsPrivateDollAndTextureOwner()
|
||||
{
|
||||
WorldEntity first = CreateDoll();
|
||||
WorldEntity repeated = CreateDoll();
|
||||
var renderer = new RecordingRenderer();
|
||||
var view = new RecordingView();
|
||||
var factory = new RecordingFactory { Doll = first };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
factory.Doll = repeated;
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
Assert.Equal([first], renderer.Dolls);
|
||||
Assert.Equal(2, renderer.RenderCount);
|
||||
Assert.False(presenter.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedScale_ReplacesPrivateDoll()
|
||||
{
|
||||
WorldEntity first = CreateDoll();
|
||||
WorldEntity changed = CreateDoll(scale: 1.25f);
|
||||
var renderer = new RecordingRenderer();
|
||||
var view = new RecordingView();
|
||||
var factory = new RecordingFactory { Doll = first };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
factory.Doll = changed;
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
|
||||
Assert.Equal([first, changed], renderer.Dolls);
|
||||
}
|
||||
|
||||
private static WorldEntity CreateDoll(float scale = 1f) => new()
|
||||
{
|
||||
Id = 42u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
Scale = scale,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void MissingPlayer_ClearsOldDollPublishesEmptyTextureAndRetries()
|
||||
public void TemporaryMissingPlayer_KeepsSuccessfulDollAndRetriesRedress()
|
||||
{
|
||||
var renderer = new RecordingRenderer { TextureHandle = 0u };
|
||||
var firstDoll = CreateDoll();
|
||||
var renderer = new RecordingRenderer { TextureHandle = 81u };
|
||||
var view = new RecordingView();
|
||||
var factory = new RecordingFactory { CanBuild = false };
|
||||
var factory = new RecordingFactory { Doll = firstDoll };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
factory.CanBuild = false;
|
||||
presenter.MarkDirty();
|
||||
presenter.Render();
|
||||
presenter.Render();
|
||||
|
||||
Assert.True(presenter.IsDirty);
|
||||
Assert.Equal(3, factory.BuildCount);
|
||||
Assert.Equal([firstDoll], renderer.Dolls);
|
||||
Assert.Equal(3, renderer.RenderCount);
|
||||
Assert.Equal([81u, 81u, 81u], view.TextureHandles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_ClearsPrivateDollOnceAndArmsNextCharacterBuild()
|
||||
{
|
||||
var firstDoll = CreateDoll();
|
||||
var secondDoll = CreateDoll();
|
||||
var renderer = new RecordingRenderer();
|
||||
var view = new RecordingView();
|
||||
var factory = new RecordingFactory { Doll = firstDoll };
|
||||
var presenter = new PaperdollFramePresenter(renderer, view, factory);
|
||||
|
||||
presenter.Render();
|
||||
presenter.ResetSession();
|
||||
factory.Doll = secondDoll;
|
||||
presenter.Render();
|
||||
|
||||
Assert.False(presenter.IsDirty);
|
||||
Assert.Equal(2, factory.BuildCount);
|
||||
Assert.Equal(2, renderer.Dolls.Count);
|
||||
Assert.All(renderer.Dolls, Assert.Null);
|
||||
Assert.Equal(2, renderer.RenderCount);
|
||||
Assert.Equal([0u, 0u], view.TextureHandles);
|
||||
Assert.Equal([firstDoll, null, secondDoll], renderer.Dolls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -222,8 +285,8 @@ public sealed class PaperdollFramePresenterTests
|
|||
|
||||
private sealed class RecordingFactory : IPaperdollDollFactory
|
||||
{
|
||||
public bool CanBuild { get; init; } = true;
|
||||
public WorldEntity? Doll { get; init; }
|
||||
public bool CanBuild { get; set; } = true;
|
||||
public WorldEntity? Doll { get; set; }
|
||||
public int BuildCount { get; private set; }
|
||||
|
||||
public bool TryBuild(out WorldEntity? doll)
|
||||
|
|
|
|||
|
|
@ -3,14 +3,31 @@ using AcDream.App.Rendering.Wb;
|
|||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class PortalMeshReferenceOwnerTests
|
||||
public sealed class SyntheticEntityMeshReferenceOwnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void PrivateOwnerSurvivesIndependentWorldPresentationRelease()
|
||||
{
|
||||
const ulong mesh = 0x01000001u;
|
||||
var adapter = new RecordingMeshAdapter();
|
||||
adapter.IncrementRefCount(mesh);
|
||||
var privateOwner =
|
||||
new SyntheticEntityMeshReferenceOwner(adapter, [mesh]);
|
||||
privateOwner.Acquire();
|
||||
|
||||
adapter.DecrementRefCount(mesh);
|
||||
|
||||
Assert.Equal(1, adapter.ReferenceCount(mesh));
|
||||
privateOwner.Dispose();
|
||||
Assert.Equal(0, adapter.TotalReferences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialAcquireRollsBackEveryCommittedSibling()
|
||||
{
|
||||
var adapter = new RecordingMeshAdapter();
|
||||
adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1);
|
||||
var owner = new PortalMeshReferenceOwner(
|
||||
var owner = new SyntheticEntityMeshReferenceOwner(
|
||||
adapter,
|
||||
[0x01000001u, 0x01000002u]);
|
||||
|
||||
|
|
@ -30,7 +47,7 @@ public sealed class PortalMeshReferenceOwnerTests
|
|||
{
|
||||
var adapter = new RecordingMeshAdapter();
|
||||
adapter.FailIncrementAfterCommit(0x01000001u, attempts: 1);
|
||||
var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]);
|
||||
var owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]);
|
||||
|
||||
Assert.Throws<AggregateException>(owner.Acquire);
|
||||
Assert.Equal(0, adapter.TotalReferences);
|
||||
|
|
@ -48,7 +65,7 @@ public sealed class PortalMeshReferenceOwnerTests
|
|||
var adapter = new RecordingMeshAdapter();
|
||||
adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1);
|
||||
adapter.FailDecrementBeforeCommit(0x01000002u, attempts: 1);
|
||||
var owner = new PortalMeshReferenceOwner(
|
||||
var owner = new SyntheticEntityMeshReferenceOwner(
|
||||
adapter,
|
||||
[0x01000001u, 0x01000002u]);
|
||||
|
||||
|
|
@ -69,7 +86,7 @@ public sealed class PortalMeshReferenceOwnerTests
|
|||
public void DisposeAttemptsEveryReferenceAndRetriesOnlyPendingRelease()
|
||||
{
|
||||
var adapter = new RecordingMeshAdapter();
|
||||
var owner = new PortalMeshReferenceOwner(
|
||||
var owner = new SyntheticEntityMeshReferenceOwner(
|
||||
adapter,
|
||||
[0x01000001u, 0x01000002u]);
|
||||
owner.Acquire();
|
||||
|
|
@ -92,7 +109,7 @@ public sealed class PortalMeshReferenceOwnerTests
|
|||
public void CommittedReleaseFailureCompletesOwnershipWithoutDoubleDecrement()
|
||||
{
|
||||
var adapter = new RecordingMeshAdapter();
|
||||
var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]);
|
||||
var owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]);
|
||||
owner.Acquire();
|
||||
adapter.FailDecrementAfterCommit(0x01000001u, attempts: 1);
|
||||
|
||||
|
|
@ -107,12 +124,12 @@ public sealed class PortalMeshReferenceOwnerTests
|
|||
[Fact]
|
||||
public void DisposeReentryDuringAcquireReconcilesBeforeReturning()
|
||||
{
|
||||
PortalMeshReferenceOwner? owner = null;
|
||||
SyntheticEntityMeshReferenceOwner? owner = null;
|
||||
var adapter = new RecordingMeshAdapter
|
||||
{
|
||||
AfterIncrement = _ => owner!.Dispose(),
|
||||
};
|
||||
owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]);
|
||||
owner = new SyntheticEntityMeshReferenceOwner(adapter, [0x01000001u]);
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(owner.Acquire);
|
||||
owner.Dispose();
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class WorldRevealRenderResourceSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReplacementGeneration_KeepsPriorityUntilExactGenerationEnds()
|
||||
{
|
||||
var first = new List<bool>();
|
||||
var second = new List<bool>();
|
||||
var scheduler = new WorldRevealRenderResourceScheduler(
|
||||
first.Add,
|
||||
second.Add);
|
||||
|
||||
scheduler.BeginDestinationReveal(3);
|
||||
scheduler.BeginDestinationReveal(4);
|
||||
scheduler.EndDestinationReveal(3);
|
||||
scheduler.EndDestinationReveal(4);
|
||||
scheduler.EndDestinationReveal(4);
|
||||
|
||||
Assert.Equal([true, true, false], first);
|
||||
Assert.Equal([true, true, false], second);
|
||||
}
|
||||
}
|
||||
|
|
@ -254,6 +254,119 @@ public sealed class GpuWorldStateVisibilityTests
|
|||
Assert.Empty(reloaded.Entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenterSpatialSwap_RetainsLiveIdentityAndReturnsExactReceipts()
|
||||
{
|
||||
const uint firstLandblock = 0x1010FFFFu;
|
||||
const uint secondLandblock = 0x1011FFFFu;
|
||||
const uint pendingLandblock = 0x2020FFFFu;
|
||||
const uint playerGuid = 0x50000001u;
|
||||
var firstStatic = Entity(100u, 0u);
|
||||
var secondStatic = Entity(101u, 0u);
|
||||
var remote = Entity(1u, 0x70000001u);
|
||||
var pending = Entity(2u, 0x70000002u);
|
||||
var player = Entity(3u, playerGuid);
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
firstLandblock,
|
||||
new LandBlock(),
|
||||
[firstStatic]));
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
secondLandblock,
|
||||
new LandBlock(),
|
||||
[secondStatic]));
|
||||
state.PlaceLiveEntityProjection(firstLandblock, remote);
|
||||
state.MarkPersistent(playerGuid);
|
||||
state.PlaceLiveEntityProjection(secondLandblock, player);
|
||||
state.PlaceLiveEntityProjection(pendingLandblock, pending);
|
||||
var edges = new List<(uint Guid, bool Visible)>();
|
||||
state.LiveProjectionVisibilityChanged += (guid, visible) =>
|
||||
edges.Add((guid, visible));
|
||||
|
||||
GpuWorldRecenterRetirement result =
|
||||
state.DetachAllForOriginRecenter();
|
||||
|
||||
Assert.Null(result.ObserverFailure);
|
||||
Assert.Equal(
|
||||
[firstLandblock, secondLandblock, pendingLandblock],
|
||||
result.Landblocks.Select(retirement => retirement.LandblockId));
|
||||
Assert.Contains(
|
||||
result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == firstLandblock).Entities,
|
||||
entity => ReferenceEquals(entity, firstStatic));
|
||||
Assert.Contains(
|
||||
result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == firstLandblock).Entities,
|
||||
entity => ReferenceEquals(entity, remote));
|
||||
Assert.Contains(
|
||||
result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == secondLandblock).Entities,
|
||||
entity => ReferenceEquals(entity, secondStatic));
|
||||
Assert.Contains(
|
||||
result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == secondLandblock).Entities,
|
||||
entity => ReferenceEquals(entity, player));
|
||||
Assert.Same(
|
||||
pending,
|
||||
Assert.Single(result.Landblocks.Single(
|
||||
retirement => retirement.LandblockId == pendingLandblock).Entities));
|
||||
|
||||
Assert.Empty(state.LoadedLandblockIds);
|
||||
Assert.Empty(state.Entities);
|
||||
Assert.Equal(2, state.PendingLiveEntityCount);
|
||||
Assert.Same(player, Assert.Single(state.DrainRescued()));
|
||||
Assert.Equal(
|
||||
[(remote.ServerGuid, false), (player.ServerGuid, false)],
|
||||
edges);
|
||||
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
firstLandblock,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Assert.Same(remote, Assert.Single(state.Entities));
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
pendingLandblock,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenterSpatialSwap_PreservesCurrentLiveBucketOrder()
|
||||
{
|
||||
const uint landblock = 0x3030FFFFu;
|
||||
var staticEntity = Entity(100u, 0u);
|
||||
var removed = Entity(1u, 0x70000001u);
|
||||
var middle = Entity(2u, 0x70000002u);
|
||||
var movedTail = Entity(3u, 0x70000003u);
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblock,
|
||||
new LandBlock(),
|
||||
[staticEntity]));
|
||||
state.PlaceLiveEntityProjection(landblock, removed);
|
||||
state.PlaceLiveEntityProjection(landblock, middle);
|
||||
state.PlaceLiveEntityProjection(landblock, movedTail);
|
||||
|
||||
// Removing the first live projection swap-moves the tail into its
|
||||
// bucket slot. Dictionary insertion order is now intentionally
|
||||
// different from the canonical bucket order.
|
||||
state.RemoveLiveEntityProjection(removed);
|
||||
Assert.Equal(
|
||||
[staticEntity, movedTail, middle],
|
||||
state.Entities);
|
||||
|
||||
state.DetachAllForOriginRecenter();
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
landblock,
|
||||
new LandBlock(),
|
||||
[staticEntity]));
|
||||
|
||||
Assert.Equal(
|
||||
[staticEntity, movedTail, middle],
|
||||
state.Entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FullRetirement_DetachesFirstAndBalancesEveryConcreteOwner()
|
||||
public void FullRetirement_DetachesAndCleansStaticsWithoutMutatingLiveOwners()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
ConcreteFixture fixture = Fixture(calls);
|
||||
|
|
@ -360,12 +360,13 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
Assert.Equal(0, pipeline.PendingRetirementCount);
|
||||
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
|
||||
Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _));
|
||||
Assert.Equal(0, fixture.Lights.RegisteredCount);
|
||||
Assert.Single(fixture.Lighting.GetOwnedLights(liveEntity.Id)!);
|
||||
Assert.Null(fixture.Lighting.GetOwnedLights(staticEntity.Id));
|
||||
Assert.False(fixture.Translucency.TryGetCurrentValue(
|
||||
staticEntity.Id,
|
||||
0u,
|
||||
out _));
|
||||
Assert.False(fixture.Translucency.TryGetCurrentValue(
|
||||
Assert.True(fixture.Translucency.TryGetCurrentValue(
|
||||
liveEntity.Id,
|
||||
0u,
|
||||
out _));
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ public sealed class LandblockPresentationPipelineTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_BlocksOriginAndBootstrapUntilEveryOldOwnerConverges()
|
||||
public void OriginRecenter_CommitsAfterDetachWhileOldOwnerCleanupContinues()
|
||||
{
|
||||
const uint centerId = 0x1919FFFFu;
|
||||
const uint neighborId = 0x191AFFFFu;
|
||||
|
|
@ -154,11 +154,63 @@ public sealed class LandblockPresentationPipelineTests
|
|||
Assert.True(Converge(recenter, controller, 0x19, 0x19));
|
||||
|
||||
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
|
||||
Assert.Equal(0, controller.PendingRetirementCount);
|
||||
Assert.Equal(1, controller.PendingRetirementCount);
|
||||
controller.Tick(0x22, 0x23);
|
||||
Assert.Equal(0, controller.PendingRetirementCount);
|
||||
Assert.Equal([0x2223FFFFu], enqueued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_DetachesFullTwentyFiveByTwentyFiveWindowAtomically()
|
||||
{
|
||||
const int radius = 12;
|
||||
const int side = radius * 2 + 1;
|
||||
const int residentCount = side * side;
|
||||
var state = new GpuWorldState();
|
||||
for (int x = 0x40 - radius; x <= 0x40 + radius; x++)
|
||||
for (int y = 0x40 - radius; y <= 0x40 + radius; y++)
|
||||
{
|
||||
uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
|
||||
state.AddLandblock(new LoadedLandblock(
|
||||
id,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
}
|
||||
|
||||
var origin = new LiveWorldOriginState();
|
||||
Assert.True(origin.TryInitialize(0x40, 0x40));
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: static (_, _) => { },
|
||||
enqueueUnload: static _ => { },
|
||||
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: static (_, _) => { },
|
||||
state,
|
||||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
workBudgetOptions: new StreamingWorkBudgetOptions(
|
||||
MaxUpdateMilliseconds: 100,
|
||||
MaxCompletionAdmissions: 1,
|
||||
MaxAdoptedCpuBytes: 1,
|
||||
MaxEntityOperations: 1,
|
||||
MaxGpuUploadBytes: 1,
|
||||
MaxGlRetireOperations: 1,
|
||||
DestinationReserveFraction: 0.5f));
|
||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||
|
||||
Assert.False(recenter.Begin(0x50, 0x50, isSealedDungeon: false));
|
||||
int frames = 0;
|
||||
while (!recenter.Advance() && frames < 8)
|
||||
{
|
||||
controller.Tick(0x40, 0x40);
|
||||
frames++;
|
||||
}
|
||||
|
||||
Assert.InRange(frames, 1, 4);
|
||||
Assert.Empty(state.LoadedLandblockIds);
|
||||
Assert.Equal(residentCount, controller.LastFullWindowRetirementLandblockCount);
|
||||
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -463,6 +463,146 @@ public sealed class LandblockRetirementCoordinatorTests
|
|||
Assert.Equal(0, coordinator.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenterAdoption_DuplicateReceiptFailsBeforeLedgerMutation()
|
||||
{
|
||||
const uint landblockId = 0x4647FFFFu;
|
||||
var state = new GpuWorldState();
|
||||
int detachedCallbacks = 0;
|
||||
LandblockRetirementCoordinator coordinator =
|
||||
LandblockRetirementCoordinator.CreateBudgeted(
|
||||
state,
|
||||
ticket => AdvancePresentationStep(ticket),
|
||||
ticket => CompletePresentation(ticket),
|
||||
_ => detachedCallbacks++);
|
||||
GpuLandblockRetirement[] receipts =
|
||||
[
|
||||
new(
|
||||
landblockId,
|
||||
LandblockRetirementKind.Full,
|
||||
Array.Empty<WorldEntity>()),
|
||||
new(
|
||||
landblockId & 0xFFFF0000u,
|
||||
LandblockRetirementKind.Full,
|
||||
Array.Empty<WorldEntity>()),
|
||||
];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
coordinator.AdoptDetachedFull(receipts));
|
||||
|
||||
Assert.Equal(0, coordinator.PendingCount);
|
||||
Assert.False(coordinator.IsPending(landblockId));
|
||||
Assert.Equal(0, detachedCallbacks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginRecenterAdoption_ObserverFailureRetainsEveryCleanupReceipt()
|
||||
{
|
||||
uint[] landblockIds = [0x4648FFFFu, 0x4649FFFFu];
|
||||
var state = new GpuWorldState();
|
||||
int detachedCallbacks = 0;
|
||||
LandblockRetirementCoordinator coordinator =
|
||||
LandblockRetirementCoordinator.CreateBudgeted(
|
||||
state,
|
||||
ticket => AdvancePresentationStep(ticket),
|
||||
ticket => CompletePresentation(ticket),
|
||||
retirement =>
|
||||
{
|
||||
detachedCallbacks++;
|
||||
if (retirement.LandblockId == landblockIds[0])
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"injected detached observer failure");
|
||||
}
|
||||
});
|
||||
GpuLandblockRetirement[] receipts = landblockIds
|
||||
.Select(id => new GpuLandblockRetirement(
|
||||
id,
|
||||
LandblockRetirementKind.Full,
|
||||
Array.Empty<WorldEntity>()))
|
||||
.ToArray();
|
||||
|
||||
Exception? failure = coordinator.AdoptDetachedFull(receipts);
|
||||
|
||||
Assert.IsType<InvalidOperationException>(failure);
|
||||
Assert.Equal(2, detachedCallbacks);
|
||||
Assert.Equal(2, coordinator.PendingCount);
|
||||
Assert.All(landblockIds, id => Assert.True(coordinator.IsPending(id)));
|
||||
|
||||
int frames = 0;
|
||||
while (coordinator.PendingCount != 0 && frames++ < 64)
|
||||
{
|
||||
var meter = new StreamingWorkMeter(Budget(maxEntityOperations: 64));
|
||||
coordinator.Advance(meter);
|
||||
meter.FinishFrame();
|
||||
}
|
||||
|
||||
Assert.Equal(0, coordinator.PendingCount);
|
||||
Assert.All(landblockIds, id => Assert.False(coordinator.IsPending(id)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestinationDependency_CompletesOutOfOrder_WithoutReorderingCleanupFifo()
|
||||
{
|
||||
uint[] landblockIds =
|
||||
[
|
||||
0x5151FFFFu,
|
||||
0x5252FFFFu,
|
||||
0x5353FFFFu,
|
||||
];
|
||||
uint destinationId = landblockIds[2];
|
||||
var terrainOrder = new List<uint>();
|
||||
var state = new GpuWorldState();
|
||||
LandblockRetirementCoordinator coordinator =
|
||||
LandblockRetirementCoordinator.CreateBudgeted(
|
||||
state,
|
||||
ticket =>
|
||||
{
|
||||
if (ticket.NextIncompleteStage
|
||||
== LandblockRetirementStage.Terrain)
|
||||
{
|
||||
return ticket.RunOnceStep(
|
||||
LandblockRetirementStage.Terrain,
|
||||
() => terrainOrder.Add(ticket.LandblockId));
|
||||
}
|
||||
|
||||
return AdvancePresentationStep(ticket);
|
||||
},
|
||||
ticket => CompletePresentation(
|
||||
ticket,
|
||||
terrain: () => terrainOrder.Add(ticket.LandblockId)));
|
||||
GpuLandblockRetirement[] receipts = landblockIds
|
||||
.Select(id => new GpuLandblockRetirement(
|
||||
id,
|
||||
LandblockRetirementKind.Full,
|
||||
Array.Empty<WorldEntity>()))
|
||||
.ToArray();
|
||||
Assert.Null(coordinator.AdoptDetachedFull(receipts));
|
||||
|
||||
var destinationMeter = new StreamingWorkMeter(
|
||||
Budget(maxEntityOperations: 64),
|
||||
destinationReservationActive: true);
|
||||
using (destinationMeter.EnterLane(StreamingWorkLane.Destination))
|
||||
coordinator.AdvancePriority(destinationId, destinationMeter);
|
||||
destinationMeter.FinishFrame();
|
||||
|
||||
Assert.False(coordinator.IsPending(destinationId));
|
||||
Assert.True(coordinator.IsPending(landblockIds[0]));
|
||||
Assert.True(coordinator.IsPending(landblockIds[1]));
|
||||
Assert.Equal(2, coordinator.PendingCount);
|
||||
Assert.Equal([destinationId], terrainOrder);
|
||||
|
||||
var cleanupMeter = new StreamingWorkMeter(
|
||||
Budget(maxEntityOperations: 64));
|
||||
coordinator.Advance(cleanupMeter);
|
||||
cleanupMeter.FinishFrame();
|
||||
|
||||
Assert.Equal(0, coordinator.PendingCount);
|
||||
Assert.Equal(
|
||||
[destinationId, landblockIds[0], landblockIds[1]],
|
||||
terrainOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -255,6 +255,26 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.False(harness.Reveal.Snapshot.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(91);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 91, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Streaming.ReservationEnds.Clear();
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
Assert.Single(harness.Streaming.ReservationEnds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerStart_ReplacesOldDestinationWithoutReusingIt()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -110,6 +110,39 @@ public sealed class StreamingCompletionQueueTests
|
|||
Assert.Equal(0, snapshot.OldestAgeMilliseconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityCeilingLeavesNearAndFarWorkRetained()
|
||||
{
|
||||
var queue = new StreamingCompletionQueue();
|
||||
StreamingQueuedCompletion near = Completion(
|
||||
new LandblockStreamResult.Failed(20, "near"),
|
||||
StreamingCompletionPriority.Near,
|
||||
sequence: 1);
|
||||
StreamingQueuedCompletion unload = Completion(
|
||||
new LandblockStreamResult.Unloaded(21),
|
||||
StreamingCompletionPriority.Unload,
|
||||
sequence: 2);
|
||||
queue.Enqueue(near);
|
||||
queue.Enqueue(unload);
|
||||
|
||||
Assert.True(queue.TryPeekNext(
|
||||
static _ => false,
|
||||
out StreamingQueuedCompletion? selected,
|
||||
StreamingCompletionPriority.Unload));
|
||||
Assert.Equal(unload.Sequence, selected?.Sequence);
|
||||
queue.RemoveHead(unload);
|
||||
|
||||
Assert.False(queue.TryPeekNext(
|
||||
static _ => false,
|
||||
out selected,
|
||||
StreamingCompletionPriority.Unload));
|
||||
Assert.Equal(1, queue.Count);
|
||||
Assert.True(queue.TryPeekNext(
|
||||
static _ => false,
|
||||
out selected));
|
||||
Assert.Equal(near.Sequence, selected?.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactReferenceRemovalDoesNotConflateEqualRecords()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ namespace AcDream.App.Tests.Streaming;
|
|||
|
||||
public sealed class StreamingWorkBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultProfile_UsesElapsedTimeAsTheMicroOperationGuard()
|
||||
{
|
||||
Assert.Equal(
|
||||
4_096,
|
||||
StreamingWorkBudgetOptions.Default.MaxEntityOperations);
|
||||
Assert.Equal(
|
||||
2.0,
|
||||
StreamingWorkBudgetOptions.Default.MaxUpdateMilliseconds);
|
||||
}
|
||||
|
||||
private static StreamingWorkBudget Budget(
|
||||
double milliseconds = 2,
|
||||
int completions = 4,
|
||||
|
|
@ -563,15 +574,17 @@ public sealed class StreamingWorkBudgetTests
|
|||
|
||||
Assert.Equal([center], applied);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions);
|
||||
Assert.Equal(2, controller.WorkDiagnostics.LastFrame.YieldCount);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount);
|
||||
Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
|
||||
|
||||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal([center, ordinary], applied);
|
||||
Assert.Equal([center], applied);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
|
||||
|
||||
controller.EndDestinationReservation(1);
|
||||
for (int i = 0;
|
||||
i < 4
|
||||
&& (controller.WorkDiagnostics.DeferredCompletions != 0
|
||||
|
|
@ -586,13 +599,51 @@ public sealed class StreamingWorkBudgetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleGenerationResultsConsumeAdmissionButRetainNoPayload()
|
||||
public void IncompleteReveal_DoesNotStarveOrdinaryWorkBeforeDestinationArrives()
|
||||
{
|
||||
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
|
||||
var source = new QueueCompletionSource(
|
||||
Loaded(center, generation: 1),
|
||||
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 33), generation: 1),
|
||||
Loaded(center));
|
||||
uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33);
|
||||
var source = new QueueCompletionSource(Loaded(ordinary));
|
||||
var applied = new List<uint>();
|
||||
StreamingController controller = Controller(
|
||||
source,
|
||||
id => applied.Add(id),
|
||||
WorkOptions(admissions: 4, cpuBytes: 1_000));
|
||||
controller.BeginDestinationReservation(1, center, 0);
|
||||
|
||||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal([ordinary], applied);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.NearBacklog);
|
||||
|
||||
source.Enqueue(Loaded(center));
|
||||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal([ordinary, center], applied);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.NearBacklog);
|
||||
|
||||
controller.EndDestinationReservation(1);
|
||||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal([ordinary, center], applied);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleGenerationResultsDrainWithoutStarvingCurrentGeneration()
|
||||
{
|
||||
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
|
||||
LandblockStreamResult[] results =
|
||||
[
|
||||
.. Enumerable.Range(0, 625)
|
||||
.Select(index => Loaded(
|
||||
StreamingRegion.EncodeLandblockIdForTest(
|
||||
32 + index % 25,
|
||||
32 + index / 25),
|
||||
generation: 1)),
|
||||
Loaded(center),
|
||||
];
|
||||
var source = new QueueCompletionSource(results);
|
||||
int applied = 0;
|
||||
StreamingController controller = Controller(
|
||||
source,
|
||||
|
|
@ -601,15 +652,12 @@ public sealed class StreamingWorkBudgetTests
|
|||
|
||||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal(0, applied);
|
||||
Assert.Equal(1, source.BacklogCount);
|
||||
Assert.Equal(1, applied);
|
||||
Assert.Equal(0, source.BacklogCount);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.DeferredAdoptedCpuBytes);
|
||||
Assert.Equal(2, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes);
|
||||
|
||||
controller.Tick(32, 32);
|
||||
Assert.Equal(1, applied);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions);
|
||||
Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes);
|
||||
}
|
||||
|
||||
private static StreamingController Controller(
|
||||
|
|
@ -688,6 +736,9 @@ public sealed class StreamingWorkBudgetTests
|
|||
|
||||
public int BacklogCount => _results.Count;
|
||||
|
||||
public void Enqueue(LandblockStreamResult result) =>
|
||||
_results.Enqueue(result);
|
||||
|
||||
public bool TryPeek(out LandblockStreamResult? result)
|
||||
{
|
||||
if (_results.TryPeek(out LandblockStreamResult? peeked))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
|
||||
public WorldRevealCoordinator Build(
|
||||
List<string>? logs = null,
|
||||
IWorldRevealStreamingScheduler? streaming = null) => new(
|
||||
IWorldRevealStreamingScheduler? streaming = null,
|
||||
IWorldRevealRenderResourceScheduler? renderResources = null) => new(
|
||||
isRenderNeighborhoodReady: (_, _) => RenderReady,
|
||||
isSpawnCellReady: _ => CollisionReady,
|
||||
isTerrainNeighborhoodReady: (_, _) => CollisionReady,
|
||||
|
|
@ -28,7 +29,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
},
|
||||
isSpawnClaimUnhydratable: _ => false,
|
||||
log: logs is null ? null : new Action<string>(logs.Add),
|
||||
streaming: streaming);
|
||||
streaming: streaming,
|
||||
renderResources: renderResources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -152,6 +154,47 @@ public sealed class WorldRevealCoordinatorTests
|
|||
Assert.Equal(1, audio.ResumeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalViewportReveal_ReopensWorldWithoutCompletingProtocolTail()
|
||||
{
|
||||
var availability = new WorldGenerationAvailabilityState();
|
||||
var world = new GpuWorldState(availability: availability);
|
||||
var audio = new RecordingAudioQuiescence();
|
||||
var quiescence = new WorldGenerationQuiescence(
|
||||
availability,
|
||||
new SelectionState(),
|
||||
world,
|
||||
audio);
|
||||
var scheduler = new RecordingDestinationScheduler();
|
||||
var coordinator = new WorldRevealCoordinator(
|
||||
isRenderNeighborhoodReady: (_, _) => true,
|
||||
isSpawnCellReady: _ => true,
|
||||
isTerrainNeighborhoodReady: (_, _) => true,
|
||||
areCompositeTexturesReady: () => true,
|
||||
prepareCompositeTextures: (_, _) => { },
|
||||
invalidateCompositeTextures: () => { },
|
||||
isSpawnClaimUnhydratable: _ => false,
|
||||
quiescence: quiescence,
|
||||
streaming: scheduler);
|
||||
long generation = coordinator.Begin(
|
||||
WorldRevealKind.Portal,
|
||||
0x3032001Cu);
|
||||
|
||||
coordinator.RevealWorldViewport();
|
||||
coordinator.RevealWorldViewport();
|
||||
|
||||
Assert.True(availability.IsWorldAvailable);
|
||||
Assert.False(coordinator.Snapshot.Completed);
|
||||
Assert.Equal(1, audio.ResumeCalls);
|
||||
Assert.Equal([generation], scheduler.Ends);
|
||||
|
||||
coordinator.Complete();
|
||||
|
||||
Assert.True(coordinator.Snapshot.Completed);
|
||||
Assert.Equal(1, audio.ResumeCalls);
|
||||
Assert.Equal([generation], scheduler.Ends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion()
|
||||
{
|
||||
|
|
@ -176,6 +219,28 @@ public sealed class WorldRevealCoordinatorTests
|
|||
Assert.Equal([second], scheduler.Ends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevealGeneration_OwnsRenderUploadProfileUntilViewportRelease()
|
||||
{
|
||||
var state = new State();
|
||||
var resources = new RecordingRenderResourceScheduler();
|
||||
WorldRevealCoordinator coordinator =
|
||||
state.Build(renderResources: resources);
|
||||
|
||||
long first = coordinator.Begin(
|
||||
WorldRevealKind.Portal,
|
||||
0x11340021u);
|
||||
long second = coordinator.Begin(
|
||||
WorldRevealKind.Portal,
|
||||
0x20210123u);
|
||||
|
||||
Assert.Equal([first, second], resources.Begins);
|
||||
coordinator.RevealWorldViewport();
|
||||
coordinator.Complete();
|
||||
|
||||
Assert.Equal([second], resources.Ends);
|
||||
}
|
||||
|
||||
private sealed class RecordingAudioQuiescence : AcDream.App.Audio.IWorldAudioQuiescence
|
||||
{
|
||||
public int SuspendCalls { get; private set; }
|
||||
|
|
@ -201,4 +266,17 @@ public sealed class WorldRevealCoordinatorTests
|
|||
public void EndDestinationReservation(long revealGeneration) =>
|
||||
Ends.Add(revealGeneration);
|
||||
}
|
||||
|
||||
private sealed class RecordingRenderResourceScheduler
|
||||
: IWorldRevealRenderResourceScheduler
|
||||
{
|
||||
public List<long> Begins { get; } = [];
|
||||
public List<long> Ends { get; } = [];
|
||||
|
||||
public void BeginDestinationReveal(long revealGeneration) =>
|
||||
Begins.Add(revealGeneration);
|
||||
|
||||
public void EndDestinationReveal(long revealGeneration) =>
|
||||
Ends.Add(revealGeneration);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
tests/AcDream.App.Tests/World/DormantLiveEntityStoreTests.cs
Normal file
115
tests/AcDream.App.Tests/World/DormantLiveEntityStoreTests.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class DormantLiveEntityStoreTests
|
||||
{
|
||||
private const uint Guid = 0x7000_0042u;
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 10, (int)DormantCreateDisposition.ExistingGeneration)]
|
||||
[InlineData(10, 11, (int)DormantCreateDisposition.NewGeneration)]
|
||||
[InlineData(11, 10, (int)DormantCreateDisposition.StaleGeneration)]
|
||||
[InlineData(0xFFFF, 0, (int)DormantCreateDisposition.NewGeneration)]
|
||||
[InlineData(0, 0xFFFF, (int)DormantCreateDisposition.StaleGeneration)]
|
||||
public void CreateClassification_UsesRetailWrapSafeInstanceOrdering(
|
||||
ushort retained,
|
||||
ushort incoming,
|
||||
int expected)
|
||||
{
|
||||
var store = new DormantLiveEntityStore();
|
||||
store.Retain(Spawn(retained, 0x0101_0001u));
|
||||
|
||||
Assert.Equal((DormantCreateDisposition)expected, store.ClassifyCreate(
|
||||
Spawn(incoming, 0x0101_0001u)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockSnapshot_IsCanonicalAndDoesNotConsumeRecords()
|
||||
{
|
||||
var store = new DormantLiveEntityStore();
|
||||
WorldSession.EntitySpawn first = Spawn(1, 0x0101_0001u);
|
||||
WorldSession.EntitySpawn second = Spawn(2, 0x0202_0123u) with
|
||||
{
|
||||
Guid = Guid + 1,
|
||||
};
|
||||
store.Retain(first);
|
||||
store.Retain(second);
|
||||
|
||||
Assert.Equal(first, Assert.Single(store.SnapshotLandblock(0x0101_FFFFu)));
|
||||
Assert.Equal(first, Assert.Single(store.SnapshotLandblock(0x0101_0123u)));
|
||||
Assert.Equal(2, store.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactDelete_CannotRemoveReusedGuidGeneration()
|
||||
{
|
||||
var store = new DormantLiveEntityStore();
|
||||
store.Retain(Spawn(2, 0x0101_0001u));
|
||||
|
||||
Assert.False(store.RemoveExact(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
|
||||
Assert.True(store.Contains(Guid, generation: 2));
|
||||
Assert.True(store.RemoveExact(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 2)));
|
||||
Assert.Equal(0, store.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retain_CannotReplaceNewerGenerationWithStaleSnapshot()
|
||||
{
|
||||
var store = new DormantLiveEntityStore();
|
||||
WorldSession.EntitySpawn newer = Spawn(2, 0x0101_0001u) with
|
||||
{
|
||||
Name = "newer",
|
||||
};
|
||||
store.Retain(newer);
|
||||
|
||||
store.Retain(Spawn(1, 0x0202_0001u) with
|
||||
{
|
||||
Name = "stale",
|
||||
});
|
||||
|
||||
Assert.True(store.TryGetExact(Guid, generation: 2, out var retained));
|
||||
Assert.Equal(newer, retained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_DrainsSessionLifetime()
|
||||
{
|
||||
var store = new DormantLiveEntityStore();
|
||||
store.Retain(Spawn(1, 0x0101_0001u));
|
||||
|
||||
store.Clear();
|
||||
|
||||
Assert.Equal(0, store.Count);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
ushort generation,
|
||||
uint cell) =>
|
||||
new(
|
||||
Guid,
|
||||
new CreateObject.ServerPosition(
|
||||
cell,
|
||||
10f,
|
||||
20f,
|
||||
5f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
SetupTableId: 0x0200_0001u,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "fixture",
|
||||
ItemType: 1u,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: generation);
|
||||
}
|
||||
|
|
@ -636,19 +636,140 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_UsesTheSameExactDeleteTransaction()
|
||||
public void Prune_ReleasesActiveOwnersButRetainsColdAuthoritativeSnapshot()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||
ClientObject retainedObject = fixture.Objects.Get(Guid)!;
|
||||
|
||||
Assert.True(fixture.Controller.OnPrune(
|
||||
new LiveEntityPruneCandidate(Guid, Generation: 1)));
|
||||
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Null(fixture.Objects.Get(Guid));
|
||||
Assert.Same(retainedObject, fixture.Objects.Get(Guid));
|
||||
Assert.True(fixture.Dormant.Contains(Guid, generation: 1));
|
||||
Assert.Equal(1, fixture.Teardown.TearDownCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedLandblock_RestoresDormantSnapshotThroughCanonicalHydration()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||
Assert.True(fixture.Controller.OnPrune(
|
||||
new LiveEntityPruneCandidate(Guid, Generation: 1)));
|
||||
|
||||
fixture.Controller.OnLandblockLoaded(Cell);
|
||||
|
||||
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord restored));
|
||||
Assert.Equal((ushort)1, restored.Generation);
|
||||
Assert.NotNull(restored.WorldEntity);
|
||||
Assert.False(fixture.Dormant.Contains(Guid, generation: 1));
|
||||
Assert.Equal(2, fixture.Resources.RegisterCount);
|
||||
Assert.Equal(1, fixture.Resources.UnregisterCount);
|
||||
Assert.Equal(2, fixture.Ready.PublishCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactDelete_RemovesDormantSnapshotAndRetainedObject()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||
Assert.True(fixture.Controller.OnPrune(
|
||||
new LiveEntityPruneCandidate(Guid, Generation: 1)));
|
||||
|
||||
Assert.True(fixture.Controller.OnDelete(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
|
||||
|
||||
Assert.False(fixture.Dormant.Contains(Guid, generation: 1));
|
||||
Assert.Null(fixture.Objects.Get(Guid));
|
||||
fixture.Controller.OnLandblockLoaded(Cell);
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DormantNewerGeneration_RejectsStaleCreateAndAcceptsReplacement()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
|
||||
Assert.True(fixture.Controller.OnPrune(
|
||||
new LiveEntityPruneCandidate(Guid, Generation: 2)));
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(
|
||||
Generation: 1,
|
||||
PositionSequence: 2,
|
||||
Name: "stale"));
|
||||
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.True(fixture.Dormant.Contains(Guid, generation: 2));
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(
|
||||
Generation: 3,
|
||||
PositionSequence: 1,
|
||||
Name: "replacement"));
|
||||
|
||||
Assert.Equal((ushort)3, fixture.Record.Generation);
|
||||
Assert.Equal("replacement", fixture.Objects.Get(Guid)!.Name);
|
||||
Assert.False(fixture.Dormant.Contains(Guid, generation: 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DormantSameGenerationCreate_UsesRetainedTimestampGate()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
WorldSession.EntitySpawn accepted = Spawn(
|
||||
Generation: 1,
|
||||
PositionSequence: 5);
|
||||
fixture.Controller.OnCreate(accepted);
|
||||
Assert.True(fixture.Controller.OnPrune(
|
||||
new LiveEntityPruneCandidate(Guid, Generation: 1)));
|
||||
|
||||
CreateObject.ServerPosition stalePosition =
|
||||
accepted.Position!.Value with
|
||||
{
|
||||
LandblockId = 0x02020001u,
|
||||
PositionX = 99f,
|
||||
};
|
||||
PhysicsSpawnData stalePhysics = accepted.Physics!.Value with
|
||||
{
|
||||
Position = stalePosition,
|
||||
Timestamps = accepted.Physics.Value.Timestamps with
|
||||
{
|
||||
Position = 4,
|
||||
},
|
||||
};
|
||||
fixture.Controller.OnCreate(accepted with
|
||||
{
|
||||
Position = stalePosition,
|
||||
PositionSequence = 4,
|
||||
Physics = stalePhysics,
|
||||
});
|
||||
|
||||
Assert.Equal(accepted.Position, fixture.Record.Snapshot.Position);
|
||||
Assert.Equal((ushort)5, fixture.Record.Snapshot.PositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DormantReplacementCreate_ReplacesAbsentOldQualities()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(
|
||||
Spawn(Generation: 1, PositionSequence: 1) with
|
||||
{
|
||||
Value = 123,
|
||||
});
|
||||
Assert.True(fixture.Controller.OnPrune(
|
||||
new LiveEntityPruneCandidate(Guid, Generation: 1)));
|
||||
|
||||
fixture.Controller.OnCreate(
|
||||
Spawn(Generation: 2, PositionSequence: 1) with
|
||||
{
|
||||
Value = null,
|
||||
});
|
||||
|
||||
Assert.Equal(0, fixture.Objects.Get(Guid)!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval()
|
||||
{
|
||||
|
|
@ -1690,6 +1811,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
public readonly RecordingOrigin Origin;
|
||||
public readonly RecordingNetworkSink Network = new();
|
||||
public readonly RecordingTeardownCoordinator Teardown = new();
|
||||
public readonly DormantLiveEntityStore Dormant = new();
|
||||
public readonly RecordingTimestampPublisher Timestamps;
|
||||
public readonly LiveEntityHydrationController Controller;
|
||||
|
||||
|
|
@ -1741,7 +1863,8 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Runtime,
|
||||
Objects,
|
||||
Teardown,
|
||||
identity);
|
||||
identity,
|
||||
Dormant);
|
||||
Controller = new LiveEntityHydrationController(
|
||||
Runtime,
|
||||
Objects,
|
||||
|
|
@ -1753,7 +1876,8 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Network,
|
||||
Timestamps,
|
||||
identity,
|
||||
deletion);
|
||||
deletion,
|
||||
Dormant);
|
||||
}
|
||||
|
||||
public LiveEntityRecord Record
|
||||
|
|
|
|||
|
|
@ -70,6 +70,46 @@ public class StreamingControllerTests
|
|||
Assert.Empty(fake.Unloads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstTick_EnqueuesRevealNeighborhoodBeforeOtherNearWork()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
var fake = new FakeStreamer();
|
||||
var controller = new StreamingController(
|
||||
enqueueLoad: fake.EnqueueLoad,
|
||||
enqueueUnload: fake.EnqueueUnload,
|
||||
drainCompletions: fake.DrainCompletions,
|
||||
applyTerrain: (_, _) => { },
|
||||
state: state,
|
||||
nearRadius: 2,
|
||||
farRadius: 2);
|
||||
uint destination =
|
||||
StreamingRegion.EncodeLandblockIdForTest(50, 50);
|
||||
controller.BeginDestinationReservation(
|
||||
revealGeneration: 1,
|
||||
destinationCell: destination,
|
||||
requiredRenderRadius: 1);
|
||||
|
||||
controller.Tick(observerCx: 50, observerCy: 50);
|
||||
|
||||
Assert.Equal(25, fake.LoadJobs.Count);
|
||||
Assert.All(
|
||||
fake.LoadJobs.Take(9),
|
||||
job => Assert.InRange(
|
||||
Math.Max(
|
||||
Math.Abs((int)((job.Id >> 24) & 0xFFu) - 50),
|
||||
Math.Abs((int)((job.Id >> 16) & 0xFFu) - 50)),
|
||||
0,
|
||||
1));
|
||||
Assert.All(
|
||||
fake.LoadJobs.Skip(9),
|
||||
job => Assert.Equal(
|
||||
2,
|
||||
Math.Max(
|
||||
Math.Abs((int)((job.Id >> 24) & 0xFFu) - 50),
|
||||
Math.Abs((int)((job.Id >> 16) & 0xFFu) - 50))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconfigureRadii_SmallerWindowUnloadsOnlyOutsideResidents()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue