fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -29,7 +29,7 @@ public class LandblockStreamerTests
buildMeshOrNull: (_, _) => stubMesh);
streamer.Start();
streamer.EnqueueLoad(0xA9B4FFFEu);
streamer.EnqueueLoad(0xA9B4FFFEu, generation: 42);
// Spin until the worker produces a completion, with a 2s timeout.
LandblockStreamResult? result = null;
@ -43,6 +43,7 @@ public class LandblockStreamerTests
Assert.NotNull(result);
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
Assert.Equal(42ul, loaded.Generation);
Assert.Same(stubLandblock, loaded.Landblock);
}
@ -247,7 +248,7 @@ public class LandblockStreamerTests
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
streamer.Start();
streamer.EnqueueUnload(0xABCD0000u);
streamer.EnqueueUnload(0xABCD0000u, generation: 43);
LandblockStreamResult? result = null;
for (int i = 0; i < SpinMaxIterations && result is null; i++)
@ -259,6 +260,7 @@ public class LandblockStreamerTests
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
Assert.Equal(0xABCD0000u, unloaded.LandblockId);
Assert.Equal(43ul, unloaded.Generation);
}
[Fact]

View file

@ -163,6 +163,22 @@ public class StreamingControllerDungeonGateTests
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
[Fact]
public void PreCollapse_CenterResidentOnlyAsFarTier_EnqueuesPromotion()
{
var h = Make();
uint center = Encode(0, 7);
h.State.AddLandblock(MakeLb(0, 7), tier: LandblockStreamTier.Far);
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.False(h.State.IsNearTier(center));
Assert.Contains(h.Loads, load =>
load.Id == center && load.Kind == LandblockStreamJobKind.PromoteToNear);
Assert.DoesNotContain(h.Loads, load =>
load.Id == center && load.Kind == LandblockStreamJobKind.LoadNear);
}
[Fact]
public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn()
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -10,10 +11,11 @@ namespace AcDream.Core.Tests.Streaming;
public class StreamingControllerPriorityApplyTests
{
private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId)
private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId, ulong generation = 0)
=> new(canonicalId, LandblockStreamTier.Near,
new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty<WorldEntity>()),
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()));
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()),
generation);
[Fact]
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap()
@ -21,10 +23,10 @@ public class StreamingControllerPriorityApplyTests
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)),
LoadedOf(priority),
});
@ -58,11 +60,11 @@ public class StreamingControllerPriorityApplyTests
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 185)),
LoadedOf(priority),
});
var applied = new List<uint>();
@ -101,9 +103,9 @@ public class StreamingControllerPriorityApplyTests
// as the normal throttle, just relocated — until the caller clears
// PriorityLandblockId (the TAS MaxContinue safety net does this on timeout).
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(1, 0);
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(1, 1);
uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(1, 2);
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(169, 181);
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(169, 182);
uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(169, 183);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
@ -146,4 +148,417 @@ public class StreamingControllerPriorityApplyTests
// (c) no double-apply: applied count matches completions enqueued
Assert.Equal(3, applied.Count);
}
[Fact]
public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow()
{
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(
Enumerable.Range(0, 6)
.Select(i => (LandblockStreamResult)LoadedOf(
StreamingRegion.EncodeLandblockIdForTest(169, 181 + i))));
var applied = new List<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => applied.Add(build.LandblockId),
state: state,
nearRadius: 4,
farRadius: 12)
{ MaxCompletionsPerFrame = 4, PriorityLandblockId = priority };
ctrl.Tick(169, 180);
Assert.Equal(4, applied.Count);
applied.Clear();
ctrl.ForceReloadWindow();
ctrl.Tick(169, 180);
Assert.Empty(applied);
}
[Fact]
public void ForceReloadWindow_RejectsLateInFlightCompletionFromOldRegion()
{
uint oldId = StreamingRegion.EncodeLandblockIdForTest(10, 10);
var outbox = new Queue<LandblockStreamResult>();
var applied = new List<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => applied.Add(build.LandblockId),
state: state,
nearRadius: 1,
farRadius: 2);
ctrl.Tick(10, 10);
ctrl.ForceReloadWindow();
outbox.Enqueue(LoadedOf(oldId));
ctrl.Tick(100, 100);
Assert.Empty(applied);
Assert.False(state.IsLoaded(oldId));
}
[Fact]
public void DungeonCollapseBeforePromotionBase_RetiresProvisionalTerrainAndPendingStatics()
{
uint outdoorId = StreamingRegion.EncodeLandblockIdForTest(10, 10);
uint dungeonId = StreamingRegion.EncodeLandblockIdForTest(20, 20);
var staticEntity = new WorldEntity
{
Id = 77,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000077u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000077u, System.Numerics.Matrix4x4.Identity)],
};
var mesh = new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>());
var promotedBuild = new LandblockBuild(
new LoadedLandblock(outdoorId, new LandBlock(), new[] { staticEntity }));
var outbox = new Queue<LandblockStreamResult>();
outbox.Enqueue(new LandblockStreamResult.Promoted(
outdoorId,
promotedBuild,
mesh,
Generation: 0));
var terrainApplied = new List<uint>();
var terrainRemoved = new List<uint>();
var state = new GpuWorldState();
var unloads = new List<(uint Id, ulong Generation)>();
var ctrl = new StreamingController(
enqueueLoad: (_, _, _) => { },
enqueueUnload: (id, generation) => unloads.Add((id, generation)),
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => terrainApplied.Add(build.LandblockId),
state: state,
nearRadius: 0,
farRadius: 2,
removeTerrain: id => terrainRemoved.Add(id));
ctrl.Tick(10, 10); // generation 0 promotion supersedes its queued Far base.
Assert.Equal(new[] { outdoorId }, terrainApplied);
Assert.True(state.IsLoaded(outdoorId));
Assert.True(state.IsNearTier(outdoorId));
ctrl.Tick(20, 20, insideDungeon: true); // hard generation 1 boundary.
var outdoorUnload = Assert.Single(unloads, unload => unload.Id == outdoorId);
outbox.Enqueue(new LandblockStreamResult.Unloaded(
outdoorId,
outdoorUnload.Generation));
ctrl.Tick(20, 20, insideDungeon: true);
Assert.Equal(new[] { outdoorId }, terrainRemoved);
// Return to the old location in generation 2 and publish a clean base.
// No static presentation from the abandoned generation may merge into it.
ctrl.Tick(10, 10, insideDungeon: false);
outbox.Enqueue(new LandblockStreamResult.Loaded(
outdoorId,
LandblockStreamTier.Near,
new LoadedLandblock(outdoorId, new LandBlock(), Array.Empty<WorldEntity>()),
mesh,
generation: 2));
ctrl.Tick(10, 10);
Assert.True(state.IsLoaded(outdoorId));
Assert.DoesNotContain(staticEntity, state.Entities);
Assert.Equal(2, terrainApplied.Count(id => id == outdoorId));
}
[Fact]
public void LatePromotion_IsRejectedAfterRegionDemotesTargetToFar()
{
uint target = StreamingRegion.EncodeLandblockIdForTest(10, 13);
var outbox = new Queue<LandblockStreamResult>();
var loads = new List<(uint Id, LandblockStreamJobKind Kind)>();
var applied = new List<uint>();
var state = new GpuWorldState();
state.AddLandblock(
new LoadedLandblock(target, new LandBlock(), Array.Empty<WorldEntity>()),
tier: LandblockStreamTier.Far);
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => applied.Add(build.LandblockId),
state: state,
nearRadius: 0,
farRadius: 3);
ctrl.Tick(10, 10); // target starts Far.
ctrl.Tick(10, 13); // target becomes Near; promotion is now in flight.
Assert.Contains(loads, load =>
load.Id == target && load.Kind == LandblockStreamJobKind.PromoteToNear);
ctrl.Tick(10, 10); // target is demoted back to Far before completion.
var promotedEntity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000010u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)],
};
var promotedLandblock = new LoadedLandblock(
target,
new LandBlock(),
new[] { promotedEntity });
outbox.Enqueue(new LandblockStreamResult.Promoted(
target,
promotedLandblock,
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>())));
ctrl.Tick(10, 10);
Assert.True(state.IsLoaded(target));
Assert.False(state.IsNearTier(target));
Assert.DoesNotContain(promotedEntity, state.Entities);
Assert.Empty(applied);
}
[Fact]
public void DuplicateNearCompletions_PublishExactlyOnce()
{
uint id = StreamingRegion.EncodeLandblockIdForTest(10, 10);
var entity = new WorldEntity
{
Id = 91,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000091u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000091u, System.Numerics.Matrix4x4.Identity)],
};
var build = new LandblockBuild(
new LoadedLandblock(id, new LandBlock(), new[] { entity }));
var mesh = new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>());
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
new LandblockStreamResult.Promoted(id, build, mesh),
new LandblockStreamResult.Promoted(id, build, mesh),
});
var applied = new List<uint>();
var loadedCallbacks = new List<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (completed, _) => applied.Add(completed.LandblockId),
state: state,
nearRadius: 0,
farRadius: 0,
onLandblockLoaded: loadedCallbacks.Add);
ctrl.Tick(10, 10);
Assert.Equal(new[] { id }, applied);
Assert.Equal(new[] { id }, loadedCallbacks);
Assert.Single(state.Entities, existing => ReferenceEquals(existing, entity));
}
[Fact]
public void InFlightNearLoad_DemotedBeforeFirstCompletion_PublishesTerrainAsFar()
{
uint target = StreamingRegion.EncodeLandblockIdForTest(10, 10);
var outbox = new Queue<LandblockStreamResult>();
var applied = new List<LandblockBuild>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => applied.Add(build),
state: state,
nearRadius: 0,
farRadius: 3);
ctrl.Tick(10, 10); // target's initial LoadNear is now in flight.
ctrl.Tick(10, 13); // target becomes desired Far before it completes.
var nearEntity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000010u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)],
};
var nearLandblock = new LoadedLandblock(
target,
new LandBlock(),
new[] { nearEntity });
outbox.Enqueue(new LandblockStreamResult.Loaded(
target,
LandblockStreamTier.Near,
nearLandblock,
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>())));
ctrl.Tick(10, 13);
Assert.Single(applied);
Assert.Equal(target, applied[0].LandblockId);
Assert.Empty(applied[0].Landblock.Entities);
Assert.Null(applied[0].EnvCells);
Assert.True(state.IsLoaded(target));
Assert.False(state.IsNearTier(target));
Assert.True(state.TryGetLandblock(target, out var resident));
Assert.Empty(resident!.Entities);
}
[Fact]
public void HardRecenter_RejectsOldOverlappingLoadAndUnloadGenerations()
{
uint overlap = StreamingRegion.EncodeLandblockIdForTest(10, 10);
var outbox = new Queue<LandblockStreamResult>();
var loads = new List<(uint Id, LandblockStreamJobKind Kind, ulong Generation)>();
var applied = new List<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (id, kind, generation) => loads.Add((id, kind, generation)),
enqueueUnload: (_, _) => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => applied.Add(build.LandblockId),
state: state,
nearRadius: 1,
farRadius: 2);
ctrl.Tick(10, 10);
ulong oldGeneration = loads.Single(load => load.Id == overlap).Generation;
ctrl.ForceReloadWindow();
loads.Clear();
ctrl.Tick(11, 10); // overlap remains desired in the replacement window.
ulong newGeneration = loads.Single(load => load.Id == overlap).Generation;
Assert.NotEqual(oldGeneration, newGeneration);
outbox.Enqueue(LoadedOf(overlap, oldGeneration));
outbox.Enqueue(LoadedOf(overlap, newGeneration));
outbox.Enqueue(new LandblockStreamResult.Unloaded(overlap, oldGeneration));
ctrl.Tick(11, 10);
Assert.Equal(new[] { overlap }, applied);
Assert.True(state.IsLoaded(overlap));
Assert.True(state.IsNearTier(overlap));
}
[Fact]
public void HardRecenter_DropsStaleOutboxBeforeDeferredApplyBudget()
{
uint current = StreamingRegion.EncodeLandblockIdForTest(11, 10);
var outbox = new Queue<LandblockStreamResult>();
var loads = new List<(uint Id, LandblockStreamJobKind Kind, ulong Generation)>();
var applied = new List<uint>();
var ctrl = new StreamingController(
enqueueLoad: (id, kind, generation) => loads.Add((id, kind, generation)),
enqueueUnload: (_, _) => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => applied.Add(build.LandblockId),
state: new GpuWorldState(),
nearRadius: 1,
farRadius: 2)
{ MaxCompletionsPerFrame = 1 };
ctrl.Tick(10, 10);
ulong oldGeneration = loads[0].Generation;
ctrl.ForceReloadWindow();
loads.Clear();
ctrl.Tick(11, 10);
ulong newGeneration = loads.Single(load => load.Id == current).Generation;
for (int i = 0; i < 12; i++)
outbox.Enqueue(LoadedOf(
StreamingRegion.EncodeLandblockIdForTest(10, 10 + i),
oldGeneration));
outbox.Enqueue(LoadedOf(current, newGeneration));
ctrl.Tick(11, 10);
Assert.Equal(new[] { current }, applied);
Assert.Equal(0, ctrl.DeferredApplyBacklog);
}
[Fact]
public void NormalRecenter_AwayThenBack_DiscardsCompletedUnloadForReownedId()
{
uint target = StreamingRegion.EncodeLandblockIdForTest(10, 10);
var outbox = new Queue<LandblockStreamResult>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (_, _) => { },
state: state,
nearRadius: 0,
farRadius: 0);
ctrl.Tick(10, 10);
state.AddLandblock(
new LoadedLandblock(target, new LandBlock(), Array.Empty<WorldEntity>()),
tier: LandblockStreamTier.Near);
ctrl.Tick(10, 13); // outside hysteresis: unload is now in flight.
ctrl.Tick(10, 10); // target is re-owned before unload completion drains.
outbox.Enqueue(new LandblockStreamResult.Unloaded(target));
ctrl.Tick(10, 10);
Assert.True(state.IsLoaded(target));
}
}

View file

@ -95,7 +95,7 @@ public class StreamingControllerTests
}
[Fact]
public void DrainingUnloadedResult_RemovesFromState()
public void DrainingUnloadedResult_RemovesUnownedLandblockFromState()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
@ -103,7 +103,10 @@ public class StreamingControllerTests
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
const uint landblockId = 0x3232FFFFu;
// The current region is centered on 0x3232. A completed unload for a
// landblock it no longer owns must remove state; an unload for 0x3232
// itself is now correctly rejected by the away->back lifecycle gate.
const uint landblockId = 0x2020FFFFu;
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));

View file

@ -96,7 +96,7 @@ public class StreamingControllerTwoTierTests
// AddEntitiesToExistingLandblock canonicalizes incoming ids.
uint lbId = 0x3232FFFFu;
var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
state.AddLandblock(lb, tier: LandblockStreamTier.Far);
Assert.Empty(state.Entities);
// Streamer pushes a Promoted result carrying the full near landblock.