fix(portal): synchronize destination presentation state
This commit is contained in:
parent
4b1bceefbb
commit
e95f55f25b
42 changed files with 2815 additions and 288 deletions
|
|
@ -0,0 +1,104 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class LandblockSpawnAdapterReadinessTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderReady_WaitsForStaticAndEnvCellMeshes()
|
||||
{
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = 0x01000010u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(0x01000010u, Matrix4x4.Identity)],
|
||||
};
|
||||
var landblock = new LoadedLandblock(
|
||||
0x1234FFFFu,
|
||||
new LandBlock(),
|
||||
new[] { entity });
|
||||
const ulong envCellGeometryId = 0x2_0000_1234ul;
|
||||
|
||||
adapter.OnLandblockLoaded(landblock, new[] { envCellGeometryId });
|
||||
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
meshes.ReadyIds.Add(0x01000010ul);
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderReady_EmptyPublishedLandblockIsReadyUntilUnload()
|
||||
{
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
var landblock = new LoadedLandblock(
|
||||
0x1234FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>());
|
||||
|
||||
adapter.OnLandblockLoaded(landblock);
|
||||
|
||||
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
adapter.OnLandblockUnloaded(landblock.LandblockId);
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedEnvCellGeometry_IsPinnedPerLandblockAndReleasedSymmetrically()
|
||||
{
|
||||
const ulong sharedGeometryId = 0x2_0000_1234ul;
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
var first = new LoadedLandblock(
|
||||
0x1234FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>());
|
||||
var second = new LoadedLandblock(
|
||||
0x1235FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>());
|
||||
|
||||
adapter.OnLandblockLoaded(first, new[] { sharedGeometryId, sharedGeometryId });
|
||||
adapter.OnLandblockLoaded(second, new[] { sharedGeometryId });
|
||||
|
||||
Assert.Equal(2, meshes.ReferenceCounts[sharedGeometryId]);
|
||||
Assert.Equal(2, meshes.PreparedPinCalls.Count);
|
||||
|
||||
adapter.OnLandblockUnloaded(first.LandblockId);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[sharedGeometryId]);
|
||||
|
||||
adapter.OnLandblockUnloaded(second.LandblockId);
|
||||
Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts);
|
||||
}
|
||||
|
||||
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
|
||||
{
|
||||
public HashSet<ulong> ReadyIds { get; } = new();
|
||||
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
|
||||
public List<ulong> PreparedPinCalls { get; } = new();
|
||||
public void IncrementRefCount(ulong id) =>
|
||||
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
|
||||
public void PinPreparedRenderData(ulong id)
|
||||
{
|
||||
PreparedPinCalls.Add(id);
|
||||
IncrementRefCount(id);
|
||||
}
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
int count = ReferenceCounts[id] - 1;
|
||||
if (count == 0) ReferenceCounts.Remove(id);
|
||||
else ReferenceCounts[id] = count;
|
||||
}
|
||||
public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Content;
|
||||
using Chorizite.Core.Render.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class MeshUploadCachesTests
|
||||
{
|
||||
[Fact]
|
||||
public void CpuCacheHit_AfterCompletedUpload_ReStagesExactlyOnceWithTexturePayload()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var cache = new CpuMeshUploadCache(capacity: 2);
|
||||
byte[] textureBytes = [1, 2, 3, 4];
|
||||
var data = new ObjectMeshData { ObjectId = 0x01000010ul, UploadAttempts = 2 };
|
||||
data.TextureBatches[(1, 1, TextureFormat.RGBA8)] =
|
||||
[
|
||||
new TextureBatchData { TextureData = textureBytes },
|
||||
];
|
||||
cache.Store(data);
|
||||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out var initial));
|
||||
Assert.Same(data, initial);
|
||||
staging.Complete(data.ObjectId); // GPU upload completed, then was later evicted.
|
||||
|
||||
data.UploadAttempts = 3;
|
||||
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached));
|
||||
Assert.Same(data, cached);
|
||||
Assert.Equal(0, data.UploadAttempts);
|
||||
Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData);
|
||||
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _));
|
||||
Assert.True(staging.TryDequeue(out var restaged));
|
||||
Assert.Same(data, restaged);
|
||||
Assert.False(staging.TryDequeue(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retry_RetainsQueueOwnershipUntilCompletion()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var data = new ObjectMeshData { ObjectId = 0x01000010ul };
|
||||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out var attempt));
|
||||
staging.Requeue(attempt!);
|
||||
Assert.False(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out var retry));
|
||||
Assert.Same(data, retry);
|
||||
|
||||
staging.Complete(data.ObjectId);
|
||||
Assert.True(staging.Stage(data));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UploadCompletion_NeverManufacturesLogicalOwnership()
|
||||
{
|
||||
const ulong id = 0x01000010ul;
|
||||
var ownership = new MeshOwnershipCounter();
|
||||
|
||||
Assert.Equal(1, ownership.Acquire(id));
|
||||
Assert.True(ownership.MarkUploadComplete(id));
|
||||
Assert.Equal(1, ownership.Count(id));
|
||||
|
||||
Assert.Equal(0, ownership.Release(id));
|
||||
Assert.False(ownership.MarkUploadComplete(id)); // late upload after unload
|
||||
Assert.Equal(0, ownership.Count(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class StreamingControllerReadinessTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_RequiresEveryPublishedLandblockInRing()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
{
|
||||
if (dx == 1 && dy == 1)
|
||||
continue;
|
||||
AddPublished(state, 0x12 + dx, 0x36 + dy);
|
||||
}
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1));
|
||||
|
||||
AddPublished(state, 0x13, 0x37);
|
||||
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_RadiusZeroAcceptsEnvCellId()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
AddPublished(state, 0x8C, 0x04);
|
||||
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_SkipsOffMapNeighborsLikePhysicsGate()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
AddPublished(state, 0, 0);
|
||||
AddPublished(state, 0, 1);
|
||||
AddPublished(state, 1, 0);
|
||||
AddPublished(state, 1, 1);
|
||||
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_RejectsNegativeRadius()
|
||||
{
|
||||
StreamingController controller = CreateController(new GpuWorldState());
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xFF0401ADu)]
|
||||
[InlineData(0x04FF01ADu)]
|
||||
[InlineData(0x12360000u)]
|
||||
[InlineData(0x12360041u)]
|
||||
[InlineData(0x1236FFFEu)]
|
||||
public void RenderNeighborhoodResident_RejectsInvalidMapEdgeDestination(uint cellId)
|
||||
{
|
||||
StreamingController controller = CreateController(new GpuWorldState());
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_WaitsForActualMeshUpload()
|
||||
{
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
StreamingController controller = CreateController(state);
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong envCellGeometryId = 0x2_0000_1234ul;
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = 0x01000010u,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = new[] { new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity) },
|
||||
};
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), new[] { entity }),
|
||||
new[] { envCellGeometryId });
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
meshes.ReadyIds.Add(0x01000010ul);
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_PreservesShellGateWhenPromotionPrecedesBaseLoad()
|
||||
{
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
StreamingController controller = CreateController(state);
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong envCellGeometryId = 0x2_0000_1234ul;
|
||||
|
||||
state.AddEntitiesToExistingLandblock(
|
||||
id,
|
||||
Array.Empty<WorldEntity>(),
|
||||
new[] { envCellGeometryId });
|
||||
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
|
||||
Assert.True(state.IsNearTier(id));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderNeighborhoodResident_FarTerrainDoesNotSatisfyPortalGate()
|
||||
{
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
StreamingController controller = CreateController(state);
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong envCellGeometryId = 0x2_0000_1234ul;
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
tier: LandblockStreamTier.Far);
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
|
||||
state.AddEntitiesToExistingLandblock(
|
||||
id,
|
||||
Array.Empty<WorldEntity>(),
|
||||
new[] { envCellGeometryId });
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleFarPublication_DoesNotReplaceNearEntitiesOrReadiness()
|
||||
{
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
StreamingController controller = CreateController(state);
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong envCellGeometryId = 0x2_0000_1234ul;
|
||||
var entity = 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)],
|
||||
};
|
||||
meshes.ReadyIds.UnionWith([0x01000010ul, envCellGeometryId]);
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), new[] { entity }),
|
||||
new[] { envCellGeometryId },
|
||||
LandblockStreamTier.Near);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
tier: LandblockStreamTier.Far);
|
||||
|
||||
Assert.True(state.IsNearTier(id));
|
||||
Assert.Contains(entity, state.Entities);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvCellPublication_RearmsSpecializedPreparationAfterPin()
|
||||
{
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong geometryId = 0x2_0000_1234ul;
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
int ensureCalls = 0;
|
||||
var shell = new EnvCellShellPlacement(
|
||||
0x12360001u,
|
||||
geometryId,
|
||||
0x0D000001u,
|
||||
2,
|
||||
ImmutableArray.Create<ushort>(3, 4),
|
||||
System.Numerics.Vector3.Zero,
|
||||
System.Numerics.Quaternion.Identity,
|
||||
System.Numerics.Matrix4x4.Identity,
|
||||
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One),
|
||||
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One));
|
||||
var envCells = new EnvCellLandblockBuild(
|
||||
id,
|
||||
Array.Empty<LoadedCell>(),
|
||||
new[] { shell });
|
||||
var build = new LandblockBuild(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
envCells);
|
||||
outbox.Enqueue(new LandblockStreamResult.Loaded(
|
||||
id,
|
||||
LandblockStreamTier.Near,
|
||||
build,
|
||||
new AcDream.Core.Terrain.LandblockMeshData(
|
||||
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
Array.Empty<uint>())));
|
||||
var controller = 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,
|
||||
ensureEnvCellMeshes: completed =>
|
||||
{
|
||||
// The specialized request is replayed only after the synthetic
|
||||
// id is pinned, closing existing-at-schedule -> evicted-before-
|
||||
// publication without falling through generic GfxObj decode.
|
||||
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
|
||||
Assert.Equal(shell, Assert.Single(completed.Shells));
|
||||
ensureCalls++;
|
||||
});
|
||||
|
||||
controller.Tick(0x12, 0x36);
|
||||
|
||||
Assert.Equal(1, ensureCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromotionWithoutBase_PublishesSelfContainedNearAndPinsBeforeReplay()
|
||||
{
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong geometryId = 0x2_0000_5678ul;
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
int ensureCalls = 0;
|
||||
var shell = new EnvCellShellPlacement(
|
||||
0x12360001u,
|
||||
geometryId,
|
||||
0x0D000001u,
|
||||
2,
|
||||
ImmutableArray.Create<ushort>(3, 4),
|
||||
System.Numerics.Vector3.Zero,
|
||||
System.Numerics.Quaternion.Identity,
|
||||
System.Numerics.Matrix4x4.Identity,
|
||||
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One),
|
||||
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One));
|
||||
var envCells = new EnvCellLandblockBuild(
|
||||
id,
|
||||
Array.Empty<LoadedCell>(),
|
||||
new[] { shell });
|
||||
var nearBuild = new LandblockBuild(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
envCells);
|
||||
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
Array.Empty<uint>());
|
||||
outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh));
|
||||
var controller = 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,
|
||||
ensureEnvCellMeshes: completed =>
|
||||
{
|
||||
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
|
||||
Assert.Equal(shell, Assert.Single(completed.Shells));
|
||||
ensureCalls++;
|
||||
});
|
||||
|
||||
controller.Tick(0x12, 0x36);
|
||||
|
||||
Assert.Equal(1, ensureCalls);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
|
||||
Assert.True(state.IsNearTier(id));
|
||||
|
||||
// A Far job that had already started may still finish after the
|
||||
// self-contained promotion. It must not replace or replay the Near tier.
|
||||
var farBase = new LandblockBuild(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
outbox.Enqueue(new LandblockStreamResult.Loaded(
|
||||
id,
|
||||
LandblockStreamTier.Far,
|
||||
farBase,
|
||||
mesh));
|
||||
|
||||
controller.Tick(0x12, 0x36);
|
||||
|
||||
Assert.Equal(1, ensureCalls);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
|
||||
Assert.True(state.IsNearTier(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromotionBeforeBase_DemotedBeforeBase_DropsPendingNearPresentation()
|
||||
{
|
||||
uint id = 0x1236FFFFu;
|
||||
const ulong geometryId = 0x2_0000_9ABCul;
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var outbox = new Queue<LandblockStreamResult>();
|
||||
int ensureCalls = 0;
|
||||
var appliedBuilds = new List<LandblockBuild>();
|
||||
var demotedWhileStaticPresent = new List<uint>();
|
||||
var shell = new EnvCellShellPlacement(
|
||||
0x12360001u,
|
||||
geometryId,
|
||||
0x0D000001u,
|
||||
2,
|
||||
ImmutableArray.Create<ushort>(3, 4),
|
||||
System.Numerics.Vector3.Zero,
|
||||
System.Numerics.Quaternion.Identity,
|
||||
System.Numerics.Matrix4x4.Identity,
|
||||
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One),
|
||||
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One));
|
||||
var envCells = new EnvCellLandblockBuild(
|
||||
id,
|
||||
Array.Empty<LoadedCell>(),
|
||||
new[] { shell });
|
||||
var staticEntity = 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 nearBuild = new LandblockBuild(
|
||||
new LoadedLandblock(id, new LandBlock(), new[] { staticEntity }),
|
||||
envCells);
|
||||
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
Array.Empty<uint>());
|
||||
outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh));
|
||||
var controller = 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, _) => appliedBuilds.Add(build),
|
||||
state: state,
|
||||
nearRadius: 0,
|
||||
farRadius: 3,
|
||||
demoteNearLayer: demotedId =>
|
||||
{
|
||||
Assert.Contains(staticEntity, state.Entities);
|
||||
demotedWhileStaticPresent.Add(demotedId);
|
||||
},
|
||||
ensureEnvCellMeshes: _ => ensureCalls++);
|
||||
|
||||
controller.Tick(0x12, 0x36); // self-contained promotion publishes Near.
|
||||
Assert.Single(appliedBuilds);
|
||||
Assert.NotNull(appliedBuilds[0].EnvCells);
|
||||
controller.Tick(0x12, 0x39); // normal Near->Far demotion retires presentation.
|
||||
|
||||
var farBase = new LandblockBuild(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
outbox.Enqueue(new LandblockStreamResult.Loaded(
|
||||
id,
|
||||
LandblockStreamTier.Far,
|
||||
farBase,
|
||||
mesh));
|
||||
controller.Tick(0x12, 0x39);
|
||||
|
||||
Assert.Equal(1, ensureCalls);
|
||||
Assert.DoesNotContain(geometryId, meshes.ReferenceCounts);
|
||||
Assert.DoesNotContain(staticEntity, state.Entities);
|
||||
Assert.Equal(new[] { id }, demotedWhileStaticPresent);
|
||||
Assert.True(state.IsLoaded(id));
|
||||
Assert.False(state.IsNearTier(id));
|
||||
Assert.Equal(2, appliedBuilds.Count);
|
||||
Assert.Null(appliedBuilds[1].EnvCells);
|
||||
Assert.Empty(appliedBuilds[1].Landblock.Entities);
|
||||
}
|
||||
|
||||
private static StreamingController CreateController(GpuWorldState state)
|
||||
=> new(
|
||||
(_, _) => { },
|
||||
_ => { },
|
||||
_ => Array.Empty<LandblockStreamResult>(),
|
||||
(_, _) => { },
|
||||
state,
|
||||
nearRadius: 1,
|
||||
farRadius: 2);
|
||||
|
||||
private static void AddPublished(GpuWorldState state, int x, int y)
|
||||
{
|
||||
uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
|
||||
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
}
|
||||
|
||||
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
|
||||
{
|
||||
public HashSet<ulong> ReadyIds { get; } = new();
|
||||
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
|
||||
public void IncrementRefCount(ulong id) =>
|
||||
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
int next = ReferenceCounts.GetValueOrDefault(id) - 1;
|
||||
if (next <= 0) ReferenceCounts.Remove(id);
|
||||
else ReferenceCounts[id] = next;
|
||||
}
|
||||
public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content.Vfx;
|
||||
|
|
@ -57,7 +58,7 @@ public sealed class RecallTeleportAnimationTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched()
|
||||
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
|
||||
{
|
||||
string datDir = @"C:\Turbine\Asheron's Call";
|
||||
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
|
||||
|
|
@ -81,38 +82,29 @@ public sealed class RecallTeleportAnimationTests
|
|||
aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate);
|
||||
}
|
||||
|
||||
const float updateStep = 1f / 60f;
|
||||
double elapsed = 0.0;
|
||||
bool hidden = false;
|
||||
bool hiddenPacketReady = false;
|
||||
var frame = new RetailLiveFrameCoordinator(
|
||||
dt =>
|
||||
{
|
||||
if (!hidden)
|
||||
sequencer.Advance(dt);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (hiddenPacketReady)
|
||||
hidden = true;
|
||||
},
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// The recall motion is dispatched after an object's UseTime phase.
|
||||
// On the first later frame whose timestamp reaches ACE's scheduled
|
||||
// teleport boundary, retail advances the PartArray and only then
|
||||
// consumes the Hidden SetState from the inbound queue.
|
||||
while (elapsed < aceDuration)
|
||||
{
|
||||
hiddenPacketReady = elapsed + updateStep >= aceDuration;
|
||||
frame.Tick(updateStep);
|
||||
elapsed += updateStep;
|
||||
}
|
||||
|
||||
Assert.True(hidden);
|
||||
Assert.True(
|
||||
// Advance returns a reusable scratch buffer; snapshot the authored tail
|
||||
// before HandleEnterWorld/SampleCurrentPose overwrites that buffer.
|
||||
PartTransform[] recallTail = sequencer.Advance((float)aceDuration).ToArray();
|
||||
Assert.False(
|
||||
sequencer.CurrentNodeDiag.IsLooping,
|
||||
"Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback.");
|
||||
"At ACE's exact teleport boundary the authored recall link is still the current node.");
|
||||
|
||||
// set_hidden -> CPartArray::HandleEnterWorld ->
|
||||
// MotionTableManager::HandleEnterWorld removes link animations and
|
||||
// selects the first cyclic (Ready) node. Hidden then suppresses time
|
||||
// advance, but set_frame/UpdateParts still samples this new pose.
|
||||
sequencer.Manager.HandleEnterWorld();
|
||||
IReadOnlyList<PartTransform> finishedPose = sequencer.SampleCurrentPose();
|
||||
|
||||
Assert.True(sequencer.CurrentNodeDiag.IsLooping);
|
||||
Assert.Equal(recallTail.Length, finishedPose.Count);
|
||||
Assert.Contains(
|
||||
Enumerable.Range(0, finishedPose.Count),
|
||||
index => Vector3.DistanceSquared(
|
||||
recallTail[index].Origin,
|
||||
finishedPose[index].Origin) > 1e-6f
|
||||
|| MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(recallTail[index].Orientation),
|
||||
Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,15 +66,56 @@ public class PhysicsBodyCellSyncTests
|
|||
Assert.Equal(0u, body.CellPosition.ObjCellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionDelta_InsideEnvCell_AdvancesCanonicalLocalFrame()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.SnapToCell(
|
||||
0x8C0401ADu,
|
||||
worldPos: new Vector3(12f, -30f, 4f),
|
||||
cellLocal: new Vector3(80f, 40f, 4f));
|
||||
|
||||
body.Position += new Vector3(3f, -2f, 0.5f);
|
||||
|
||||
Assert.Equal(0x8C0401ADu, body.CellPosition.ObjCellId);
|
||||
Assert.Equal(new Vector3(83f, 38f, 4.5f), body.CellPosition.Frame.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommitTransitionPosition_InsideDungeon_AdoptsResolvedEnvCellAndFrame()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.SnapToCell(
|
||||
0x8C0401ADu,
|
||||
worldPos: new Vector3(12f, -30f, 4f),
|
||||
cellLocal: new Vector3(80f, 40f, 4f));
|
||||
|
||||
body.CommitTransitionPosition(
|
||||
0x8C0401AEu,
|
||||
new Vector3(17f, -27f, 4f));
|
||||
|
||||
Assert.Equal(0x8C0401AEu, body.CellPosition.ObjCellId);
|
||||
Assert.Equal(new Vector3(85f, 43f, 4f), body.CellPosition.Frame.Origin);
|
||||
Assert.Equal(new Vector3(17f, -27f, 4f), body.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommitTransitionPosition_WithoutSeed_DoesNotInventCanonicalCell()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
|
||||
body.CommitTransitionPosition(0x8C0401ADu, new Vector3(1f, 2f, 3f));
|
||||
|
||||
Assert.Equal(0u, body.CellPosition.ObjCellId);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), body.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale()
|
||||
{
|
||||
// Refutes the "CellPosition goes stale" concern (incl. the indoor-transit case):
|
||||
// because the Position setter re-derives the cell via AdjustToOutside on EVERY
|
||||
// write, CellPosition continuously tracks the outdoor cell under the body's WORLD
|
||||
// position. The body is always world-space, so the world delta is frame-invariant —
|
||||
// there is no separate "interior" frame to desync. On any exit it is the correct
|
||||
// cell, never stale.
|
||||
// Refutes outdoor CellPosition drift: the Position setter re-derives the
|
||||
// cell via AdjustToOutside on every write, so the carried (cell, local)
|
||||
// pair continuously tracks the body across cells and landblocks.
|
||||
var body = new PhysicsBody();
|
||||
body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World.Cells;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
|
@ -31,4 +32,123 @@ public class PhysicsEngineResidencyTests
|
|||
Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB
|
||||
Assert.False(eng.IsLandblockTerrainResident(0xC6A90019u)); // different LB
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DemoteLandblockToTerrain_RemovesEnvCellsButPreservesTerrainResidency()
|
||||
{
|
||||
const uint landblockId = 0xA9B4FFFFu;
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
cache.CellGraph.Add(new EnvCell(
|
||||
0xA9B40174u,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Vector3.Zero,
|
||||
Vector3.One,
|
||||
Array.Empty<CellPortal>(),
|
||||
Array.Empty<uint>(),
|
||||
false,
|
||||
null));
|
||||
|
||||
engine.DemoteLandblockToTerrain(landblockId);
|
||||
|
||||
Assert.True(engine.IsLandblockTerrainResident(landblockId));
|
||||
Assert.Null(cache.CellGraph.GetVisible(0xA9B40174u));
|
||||
Assert.IsType<LandCell>(cache.CellGraph.GetVisible(0xA9B40001u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DemoteLandblockToTerrain_DeregistersCrossBoundaryStaticButRetainsDynamic()
|
||||
{
|
||||
const uint landblockId = 0xA9B4FFFFu;
|
||||
const uint adjacentCell = 0xAAB40001u;
|
||||
const uint staticId = 100u;
|
||||
const uint dynamicId = 200u;
|
||||
var engine = new PhysicsEngine();
|
||||
engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
var seamPosition = new Vector3(191.5f, 12f, 1f);
|
||||
engine.ShadowObjects.Register(
|
||||
staticId,
|
||||
0x01000001u,
|
||||
seamPosition,
|
||||
Quaternion.Identity,
|
||||
2f,
|
||||
0f,
|
||||
0f,
|
||||
landblockId,
|
||||
isStatic: true);
|
||||
engine.ShadowObjects.Register(
|
||||
dynamicId,
|
||||
0x01000002u,
|
||||
seamPosition,
|
||||
Quaternion.Identity,
|
||||
2f,
|
||||
0f,
|
||||
0f,
|
||||
landblockId,
|
||||
isStatic: false);
|
||||
Assert.Contains(
|
||||
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
|
||||
entry => entry.EntityId == staticId);
|
||||
Assert.Contains(
|
||||
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
|
||||
entry => entry.EntityId == dynamicId);
|
||||
|
||||
engine.DemoteLandblockToTerrain(landblockId);
|
||||
|
||||
Assert.DoesNotContain(
|
||||
engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == staticId);
|
||||
Assert.Contains(
|
||||
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
|
||||
entry => entry.EntityId == dynamicId);
|
||||
Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLandblock_DeregistersCrossBoundaryStaticButRetainsDynamic()
|
||||
{
|
||||
const uint landblockId = 0xA9B4FFFFu;
|
||||
const uint adjacentCell = 0xAAB40001u;
|
||||
const uint staticId = 300u;
|
||||
const uint dynamicId = 400u;
|
||||
var engine = new PhysicsEngine();
|
||||
engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
var seamPosition = new Vector3(191.5f, 12f, 1f);
|
||||
engine.ShadowObjects.Register(
|
||||
staticId, 0x01000003u, seamPosition, Quaternion.Identity, 2f,
|
||||
0f, 0f, landblockId, isStatic: true);
|
||||
engine.ShadowObjects.Register(
|
||||
dynamicId, 0x01000004u, seamPosition, Quaternion.Identity, 2f,
|
||||
0f, 0f, landblockId, isStatic: false);
|
||||
|
||||
engine.RemoveLandblock(landblockId);
|
||||
|
||||
Assert.DoesNotContain(
|
||||
engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == staticId);
|
||||
Assert.Contains(
|
||||
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
|
||||
entry => entry.EntityId == dynamicId);
|
||||
Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,132 @@ public class ShadowObjectRegistryTests
|
|||
entry => entry.EntityId == entityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload()
|
||||
{
|
||||
const uint adjacentOwnerLb = 0xAAB40000u;
|
||||
const uint ownerCell = adjacentOwnerLb | 1u;
|
||||
const uint touchedCell = LbId | 57u; // west block cell (7,0)
|
||||
const uint entityId = 32u;
|
||||
var reg = new ShadowObjectRegistry();
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.CellGraph.RegisterTerrain(
|
||||
adjacentOwnerLb,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
new Vector3(192f, 0f, 0f));
|
||||
reg.DataCache = cache;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000006u,
|
||||
new Vector3(192.5f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
2f,
|
||||
192f,
|
||||
0f,
|
||||
adjacentOwnerLb,
|
||||
seedCellId: ownerCell,
|
||||
isStatic: false);
|
||||
Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
|
||||
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
|
||||
|
||||
reg.RemoveLandblock(LbId);
|
||||
Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
|
||||
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
|
||||
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
|
||||
|
||||
reg.RefloodLandblock(LbId);
|
||||
|
||||
Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
|
||||
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
|
||||
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeMove_ClearsObsoleteWithdrawnPrefix()
|
||||
{
|
||||
const uint adjacentOwnerLb = 0xAAB40000u;
|
||||
const uint ownerCell = adjacentOwnerLb | 1u;
|
||||
const uint touchedCell = LbId | 57u;
|
||||
const uint entityId = 33u;
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.CellGraph.RegisterTerrain(
|
||||
adjacentOwnerLb,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
new Vector3(192f, 0f, 0f));
|
||||
var reg = new ShadowObjectRegistry { DataCache = cache };
|
||||
reg.Register(
|
||||
entityId, 0x01000007u, new Vector3(192.5f, 12f, 50f),
|
||||
Quaternion.Identity, 2f, 192f, 0f, adjacentOwnerLb,
|
||||
seedCellId: ownerCell, isStatic: false);
|
||||
reg.RemoveLandblock(LbId);
|
||||
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
|
||||
|
||||
reg.UpdatePosition(
|
||||
entityId,
|
||||
new Vector3(204f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
192f,
|
||||
0f,
|
||||
adjacentOwnerLb,
|
||||
seedCellId: ownerCell);
|
||||
|
||||
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
|
||||
reg.RefloodLandblock(LbId);
|
||||
Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefloodLandblock_RetiresOnlyCurrentMarker_WhenTwoPrefixesWereWithdrawn()
|
||||
{
|
||||
const uint ownerLb = 0xAAB50000u;
|
||||
const uint ownerCell = ownerLb | 1u;
|
||||
const uint westLb = 0xA9B50000u;
|
||||
const uint southLb = 0xAAB40000u;
|
||||
const uint westCell = westLb | 57u;
|
||||
const uint southCell = southLb | 8u;
|
||||
const uint entityId = 35u;
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.CellGraph.RegisterTerrain(
|
||||
ownerLb,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
new Vector3(192f, 192f, 0f));
|
||||
var reg = new ShadowObjectRegistry { DataCache = cache };
|
||||
reg.Register(
|
||||
entityId, 0x01000009u, new Vector3(192.5f, 192.5f, 50f),
|
||||
Quaternion.Identity, 2f, 192f, 192f, ownerLb,
|
||||
seedCellId: ownerCell, isStatic: false);
|
||||
Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId);
|
||||
Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId);
|
||||
|
||||
reg.RemoveLandblock(westLb);
|
||||
reg.RemoveLandblock(southLb);
|
||||
Assert.Equal(2, reg.WithdrawnPrefixMarkerCount);
|
||||
|
||||
reg.RefloodLandblock(westLb);
|
||||
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
|
||||
|
||||
reg.RefloodLandblock(southLb);
|
||||
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
|
||||
Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId);
|
||||
Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLandblock_DirectStaticRetirement_ClearsWithdrawnMarker()
|
||||
{
|
||||
const uint entityId = 34u;
|
||||
var reg = new ShadowObjectRegistry();
|
||||
reg.Register(
|
||||
entityId, 0x01000008u, new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity, 1f, OffX, OffY, LbId,
|
||||
isStatic: true);
|
||||
|
||||
reg.RemoveLandblock(LbId);
|
||||
|
||||
Assert.Equal(0, reg.RetainedRegistrationCount);
|
||||
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
|
||||
// the query — retail CObjCell::find_obj_collisions iterates only the
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -56,6 +56,19 @@ public class CellGraphTests
|
|||
Assert.Null(g.GetVisible(0xA9B40014u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveEnvCellsForLandblock_PreservesTerrain()
|
||||
{
|
||||
var g = new CellGraph();
|
||||
g.Add(Env(0xA9B40174u));
|
||||
g.RegisterTerrain(0xA9B40000u, FlatTerrain(), Vector3.Zero);
|
||||
|
||||
g.RemoveEnvCellsForLandblock(0xA9B4FFFFu);
|
||||
|
||||
Assert.Null(g.GetVisible(0xA9B40174u));
|
||||
Assert.IsType<LandCell>(g.GetVisible(0xA9B40014u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Neighbor_ResolvesPortalOtherCellId()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
public sealed class ProceduralSceneryIdAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Base_AlwaysSetsSceneryBitAndAvoidsStabPrefix()
|
||||
{
|
||||
for (uint y = 0; y <= 255; y += 51)
|
||||
for (uint x = 0; x <= 255; x += 51)
|
||||
{
|
||||
uint value = ProceduralSceneryIdAllocator.Base(x, y);
|
||||
Assert.NotEqual(0u, value & 0x80000000u);
|
||||
Assert.NotEqual(0xC0000000u, value & 0xC0000000u);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoreThan256SceneryEntriesRemainInOwningLandblockRange()
|
||||
{
|
||||
uint id = ProceduralSceneryIdAllocator.Base(0x12u, 0x36u) + 300u;
|
||||
|
||||
Assert.True(id < ProceduralSceneryIdAllocator.Base(0x12u, 0x37u));
|
||||
Assert.NotEqual(0u, id & 0x80000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaximumCounterDoesNotAliasAdjacentLandblock()
|
||||
{
|
||||
for (uint y = 0; y < 255; y++)
|
||||
{
|
||||
uint current = ProceduralSceneryIdAllocator.Base(0, y);
|
||||
uint next = ProceduralSceneryIdAllocator.Base(0, y + 1);
|
||||
Assert.True(current + ProceduralSceneryIdAllocator.MaxCounter < next);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap()
|
||||
{
|
||||
uint counter = ProceduralSceneryIdAllocator.MaxCounter;
|
||||
|
||||
uint last = ProceduralSceneryIdAllocator.Allocate(0x12u, 0x36u, ref counter);
|
||||
|
||||
Assert.Equal(
|
||||
ProceduralSceneryIdAllocator.Base(0x12u, 0x36u)
|
||||
+ ProceduralSceneryIdAllocator.MaxCounter,
|
||||
last);
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(
|
||||
() => ProceduralSceneryIdAllocator.Allocate(0x12u, 0x36u, ref counter));
|
||||
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue