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:
Erik 2026-07-25 08:35:12 +02:00
parent 2c848d4167
commit 823936ec31
57 changed files with 2551 additions and 324 deletions

View file

@ -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()
{

View file

@ -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)

View file

@ -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();

View file

@ -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);
}
}