Merge branch 'claude/hopeful-darwin-ae8b87' — Phase A.5 SHIP + Quality Preset system
Phase A.5 — Two-tier Streaming + Horizon LOD shipped. Headline: 2.3 km terrain horizon (radius=4 near + 12 far) with off-thread mesh build, fog blend at N₁, mipmaps + 16x AF, MSAA 4x + A2C foliage, depth-write audit, BUDGET_OVER diag, Quality Preset system (Low/Medium/ High/Ultra) with env-var overrides + F11 mid-session re-apply. ~999 tests pass, 8 pre-existing physics/input failures unchanged. Two structural-to-A.5 bug fixes shipped post-T26: - Bug A (9217fd9): far-tier worker strips entities (T13/T16 had only wired the controller side; far-tier was loading full entity layers, ~71K entities instead of ~10K, 5x perf regression). - Bug B (0ad8c99): WalkEntities scratch list reused across frames (was 480 KB / frame allocation). Tier 1 entity-classification cache attempted as polish (3639a6f), reverted (9b49009) — broke animation by caching mutable per-frame state. Retry deferred to post-A.5 polish phase (ISSUE #53). Deferred to post-A.5 polish: - Tier 1 retry with animation-mutation audit (ISSUE #53) - Lifestone missing visual (ISSUE #52) - JobKind plumbing through BuildLandblockForStreaming (ISSUE #54) - Tier 2 (static/dynamic split) + Tier 3 (GPU compute cull) — separate multi-week phases. Roadmap at docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md. SHIP commit:9245db5.
This commit is contained in:
commit
d3d78fa14f
37 changed files with 6001 additions and 281 deletions
|
|
@ -0,0 +1,39 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// A.5 T21: lock in the depth-write attribution per translucency kind.
|
||||
/// <para>
|
||||
/// <c>WbDrawDispatcher.Draw</c> uses a two-pass structure:
|
||||
/// <list type="bullet">
|
||||
/// <item>Opaque pass — <c>DepthMask(true)</c>: writes depth so that
|
||||
/// later transparent geometry sorts correctly against solid surfaces.</item>
|
||||
/// <item>Transparent pass — <c>DepthMask(false)</c>: reads depth but
|
||||
/// does NOT write it, so alpha-blended surfaces don't occlude each
|
||||
/// other by Z-fighting.</item>
|
||||
/// </list>
|
||||
/// The partition that decides which pass a batch enters is
|
||||
/// <see cref="WbDrawDispatcher.IsOpaquePublic"/>:
|
||||
/// <c>Opaque</c> and <c>ClipMap</c> go to the opaque pass (depth write);
|
||||
/// <c>AlphaBlend</c>, <c>Additive</c>, <c>InvAlpha</c> go to the
|
||||
/// transparent pass (no depth write).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WbDispatcherDepthMaskTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TranslucencyKind.Opaque, true)] // opaque pass — depth write
|
||||
[InlineData(TranslucencyKind.ClipMap, true)] // foliage — depth write (binary alpha / A2C)
|
||||
[InlineData(TranslucencyKind.AlphaBlend, false)] // transparent — no depth write
|
||||
[InlineData(TranslucencyKind.Additive, false)]
|
||||
[InlineData(TranslucencyKind.InvAlpha, false)]
|
||||
public void IsOpaquePartition_ImpliesDepthWriteAttribution(
|
||||
TranslucencyKind kind, bool expectsDepthWrite)
|
||||
{
|
||||
bool isOpaque = WbDrawDispatcher.IsOpaquePublic(kind);
|
||||
Assert.Equal(expectsDepthWrite, isOpaque);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="WbDrawDispatcher.WalkEntities"/> — the pure-CPU
|
||||
/// visibility filter extracted in A.5 T17. These tests exercise the two
|
||||
/// key perf changes from Phase A.5 spec §4.6:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Change #1 (T17): invisible LB + animated set → iterate
|
||||
/// <c>animatedEntityIds</c> directly, not the full entity list.</item>
|
||||
/// <item>Change #2 (T18): per-entity AABB cull reads the cached AABB
|
||||
/// (<see cref="WorldEntity.AabbMin"/>/<c>AabbMax</c>) rather than
|
||||
/// recomputing Position±5 per frame.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class WbDrawDispatcherBucketingTests
|
||||
{
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static WorldEntity MakeEntity(uint id, Vector3 position)
|
||||
=> new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldEntity MakeEntityWithMesh(uint id, Vector3 position)
|
||||
=> new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
// Single dummy MeshRef so it passes the MeshRefs.Count == 0 guard.
|
||||
MeshRefs = new[] { new MeshRef { GfxObjId = 0x01000001u } },
|
||||
};
|
||||
|
||||
private static Dictionary<uint, WorldEntity> BuildById(IEnumerable<WorldEntity> entities)
|
||||
{
|
||||
var d = new Dictionary<uint, WorldEntity>();
|
||||
foreach (var e in entities) d[e.Id] = e;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A frustum positioned at (1e6+1, 1e6+1, 1e6+1) looking toward (1e6, 1e6, 1e6)
|
||||
/// with a very narrow near/far. Any AABB near the origin (0..20000) is
|
||||
/// far behind the near plane and fails all six planes.
|
||||
/// </summary>
|
||||
private static FrustumPlanes MakeFarAwayFrustum()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(
|
||||
new Vector3(1e6f + 1f, 1e6f + 1f, 1e6f + 1f),
|
||||
new Vector3(1e6f, 1e6f, 1e6f),
|
||||
Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 4f, 1f, 0.1f, 1f);
|
||||
return FrustumPlanes.FromViewProjection(view * proj);
|
||||
}
|
||||
|
||||
// ── T17 Change #1 tests ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_InvisibleLb_NoAnimated_SkipsEntireBlock()
|
||||
{
|
||||
// When LB is invisible AND animatedEntityIds is empty/null,
|
||||
// WalkEntities should not walk any entities at all.
|
||||
var entities = new List<WorldEntity>();
|
||||
for (int i = 0; i < 500; i++)
|
||||
entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0)));
|
||||
|
||||
var byId = BuildById(entities);
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xAAAA_FFFFu,
|
||||
new Vector3(10000, 10000, 10000),
|
||||
new Vector3(20000, 20000, 20000),
|
||||
entities,
|
||||
byId),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: MakeFarAwayFrustum(),
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: null);
|
||||
|
||||
Assert.Equal(0, result.EntitiesWalked);
|
||||
Assert.Empty(result.ToDraw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
|
||||
{
|
||||
// 1000 entities in an LB whose AABB is far outside the frustum.
|
||||
// Only entity Id=42 is in animatedEntityIds.
|
||||
// Pre-T17 behavior: walk all 1000 entities just to find #42.
|
||||
// Post-T17: walk only the 1 animated entity (EntitiesWalked == 1).
|
||||
const int Total = 1000;
|
||||
var entities = new List<WorldEntity>(Total);
|
||||
for (int i = 0; i < Total; i++)
|
||||
entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0)));
|
||||
|
||||
var byId = BuildById(entities);
|
||||
var animatedSet = new HashSet<uint> { 42 };
|
||||
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xAAAA_FFFFu,
|
||||
new Vector3(10000, 10000, 10000),
|
||||
new Vector3(20000, 20000, 20000),
|
||||
entities,
|
||||
byId),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: MakeFarAwayFrustum(),
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: animatedSet);
|
||||
|
||||
// Only the 1 animated entity should be walked — not 1000.
|
||||
Assert.Equal(1, result.EntitiesWalked);
|
||||
Assert.Single(result.ToDraw);
|
||||
Assert.Equal(42u, result.ToDraw[0].Entity.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_InvisibleLb_AnimatedIdAbsent_ZeroWalked()
|
||||
{
|
||||
// Animated entity ids 200 and 300 are NOT in this LB (which only
|
||||
// has ids 0..99). Should produce zero walks.
|
||||
var entities = new List<WorldEntity>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero));
|
||||
|
||||
var byId = BuildById(entities);
|
||||
var animatedSet = new HashSet<uint> { 200, 300 }; // not in this LB
|
||||
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xBBBB_FFFFu,
|
||||
new Vector3(10000, 10000, 10000),
|
||||
new Vector3(20000, 20000, 20000),
|
||||
entities,
|
||||
byId),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: MakeFarAwayFrustum(),
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: animatedSet);
|
||||
|
||||
Assert.Equal(0, result.EntitiesWalked);
|
||||
Assert.Empty(result.ToDraw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_NeverCullLb_WalksAllEntitiesRegardlessOfFrustum()
|
||||
{
|
||||
// neverCullLandblockId bypasses the LB AABB check entirely.
|
||||
// All entities with at least one MeshRef should be walked.
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
MakeEntityWithMesh(1, Vector3.Zero),
|
||||
MakeEntityWithMesh(2, Vector3.Zero),
|
||||
MakeEntityWithMesh(3, Vector3.Zero),
|
||||
};
|
||||
|
||||
var byId = BuildById(entities);
|
||||
const uint lbId = 0xCCCC_FFFFu;
|
||||
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
lbId,
|
||||
new Vector3(10000, 10000, 10000), // AABB would fail frustum
|
||||
new Vector3(20000, 20000, 20000),
|
||||
entities,
|
||||
byId),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: MakeFarAwayFrustum(),
|
||||
neverCullLandblockId: lbId, // exempt from LB cull
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: null);
|
||||
|
||||
Assert.Equal(3, result.EntitiesWalked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_NullFrustum_WalksEntitiesWithMeshRefs()
|
||||
{
|
||||
// Null frustum means no culling — all entities with MeshRefs pass.
|
||||
// Entities without MeshRefs are still filtered out.
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
MakeEntityWithMesh(1, Vector3.Zero),
|
||||
MakeEntity(2, Vector3.Zero), // no MeshRefs — must be skipped
|
||||
MakeEntityWithMesh(3, Vector3.Zero),
|
||||
};
|
||||
|
||||
var byId = BuildById(entities);
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xDDDD_FFFFu, Vector3.Zero, Vector3.Zero,
|
||||
entities, byId),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: null,
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: null);
|
||||
|
||||
Assert.Equal(2, result.EntitiesWalked);
|
||||
Assert.Equal(2, result.ToDraw.Count);
|
||||
}
|
||||
|
||||
// ── T18 Change #2 tests ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_VisibleLb_EntityFarAway_CulledViaCachedAabb()
|
||||
{
|
||||
// LB passes the LB-level cull; entity AABB is far from the frustum.
|
||||
// After RefreshAabb the entity should be culled by the per-entity check.
|
||||
var entity = MakeEntityWithMesh(1, new Vector3(50000, 50000, 50000));
|
||||
entity.RefreshAabb(); // populate cached AABB at (50000±5)
|
||||
|
||||
var byId = BuildById(new[] { entity });
|
||||
var entries = new[]
|
||||
{
|
||||
// LB AABB near origin so it passes the LB cull; entity is far away.
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xEEEE_FFFFu,
|
||||
new Vector3(-10, -10, -10),
|
||||
new Vector3(10, 10, 10),
|
||||
new List<WorldEntity> { entity },
|
||||
byId),
|
||||
};
|
||||
|
||||
// Frustum centered at origin, range ±100.
|
||||
var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f);
|
||||
var tightFrustum = FrustumPlanes.FromViewProjection(view * proj);
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: tightFrustum,
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: null);
|
||||
|
||||
// Entity at (50000,50000,50000) is outside the frustum — should be culled.
|
||||
Assert.Equal(0, result.EntitiesWalked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_AnimatedEntity_BypassesPerEntityAabbCull()
|
||||
{
|
||||
// Animated entities must always pass even if their AABB would be culled.
|
||||
var entity = MakeEntityWithMesh(7, new Vector3(50000, 50000, 50000));
|
||||
entity.RefreshAabb();
|
||||
|
||||
var byId = BuildById(new[] { entity });
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xEEEF_FFFFu,
|
||||
new Vector3(-10, -10, -10),
|
||||
new Vector3(10, 10, 10),
|
||||
new List<WorldEntity> { entity },
|
||||
byId),
|
||||
};
|
||||
|
||||
var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f);
|
||||
var tightFrustum = FrustumPlanes.FromViewProjection(view * proj);
|
||||
|
||||
var animatedSet = new HashSet<uint> { 7 };
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: tightFrustum,
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: animatedSet);
|
||||
|
||||
// Animated entity bypasses per-entity cull.
|
||||
Assert.Equal(1, result.EntitiesWalked);
|
||||
Assert.Single(result.ToDraw);
|
||||
Assert.Equal(7u, result.ToDraw[0].Entity.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntities_AabbDirty_RefreshedLazilyBeforeCull()
|
||||
{
|
||||
// An entity with AabbDirty=true (initial state) should get its AABB
|
||||
// refreshed lazily by WalkEntities before the cull check.
|
||||
var entity = MakeEntityWithMesh(5, new Vector3(0, 0, 0));
|
||||
// AabbDirty starts true by default — do NOT call RefreshAabb manually.
|
||||
Assert.True(entity.AabbDirty);
|
||||
|
||||
var byId = BuildById(new[] { entity });
|
||||
var entries = new[]
|
||||
{
|
||||
new WbDrawDispatcher.LandblockEntry(
|
||||
0xF0F0_FFFFu,
|
||||
new Vector3(-10, -10, -10),
|
||||
new Vector3(10, 10, 10),
|
||||
new List<WorldEntity> { entity },
|
||||
byId),
|
||||
};
|
||||
|
||||
// A frustum that accepts things near origin.
|
||||
var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.1f, 200f);
|
||||
var nearOriginFrustum = FrustumPlanes.FromViewProjection(view * proj);
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntities(
|
||||
entries,
|
||||
frustum: nearOriginFrustum,
|
||||
neverCullLandblockId: null,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: null);
|
||||
|
||||
// Entity at origin is inside the frustum after lazy RefreshAabb.
|
||||
Assert.Equal(1, result.EntitiesWalked);
|
||||
// AabbDirty should have been cleared by the lazy refresh.
|
||||
Assert.False(entity.AabbDirty);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using System.Linq;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
|
||||
public class GpuWorldStateTwoTierTests
|
||||
{
|
||||
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
|
||||
=> new(canonicalId, new LandBlock(), entities);
|
||||
|
||||
private static WorldEntity MakeStubEntity(uint id)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
SourceGfxObjOrSetupId = 0x01000001u,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
var lb = MakeStubLandblock(0xAAAAFFFFu,
|
||||
MakeStubEntity(1),
|
||||
MakeStubEntity(2));
|
||||
state.AddLandblock(lb);
|
||||
Assert.Equal(2, state.Entities.Count);
|
||||
|
||||
state.RemoveEntitiesFromLandblock(0xAAAAFFFFu);
|
||||
|
||||
Assert.Empty(state.Entities);
|
||||
Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
var lb = MakeStubLandblock(0xAAAAFFFFu, MakeStubEntity(1));
|
||||
state.AddLandblock(lb);
|
||||
|
||||
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
|
||||
{
|
||||
MakeStubEntity(2),
|
||||
MakeStubEntity(3),
|
||||
});
|
||||
|
||||
Assert.Equal(3, state.Entities.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEntitiesToExistingLandblock_LandblockNotYetLoaded_ParksInPending()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
|
||||
// Landblock not loaded yet.
|
||||
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
|
||||
{
|
||||
MakeStubEntity(1),
|
||||
MakeStubEntity(2),
|
||||
});
|
||||
|
||||
// Nothing in the flat view yet.
|
||||
Assert.Empty(state.Entities);
|
||||
Assert.Equal(2, state.PendingLiveEntityCount);
|
||||
|
||||
// Now load the landblock — pending entities should merge in.
|
||||
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu));
|
||||
Assert.Equal(2, state.Entities.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,9 +19,13 @@ public class LandblockStreamerTests
|
|||
0xA9B4FFFEu,
|
||||
new LandBlock(),
|
||||
System.Array.Empty<WorldEntity>());
|
||||
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null);
|
||||
loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null,
|
||||
buildMeshOrNull: (_, _) => stubMesh);
|
||||
|
||||
streamer.Start();
|
||||
streamer.EnqueueLoad(0xA9B4FFFEu);
|
||||
|
|
@ -62,6 +66,39 @@ public class LandblockStreamerTests
|
|||
Assert.IsType<LandblockStreamResult.Failed>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_WhenBuildMeshReturnsNull_ReportsFailed()
|
||||
{
|
||||
// Phase A.5 T10-T12 follow-up: the mesh-build factory may return
|
||||
// null (e.g., LandBlock dat missing or corrupt). The worker must
|
||||
// emit Failed in that case instead of constructing Loaded with a
|
||||
// null MeshData (which would NRE downstream).
|
||||
var stubLandblock = new LoadedLandblock(
|
||||
0xABCDFFFEu,
|
||||
new LandBlock(),
|
||||
System.Array.Empty<WorldEntity>());
|
||||
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: _ => stubLandblock,
|
||||
buildMeshOrNull: (_, _) => null); // mesh-build returns null
|
||||
|
||||
streamer.Start();
|
||||
streamer.EnqueueLoad(0xABCDFFFEu);
|
||||
|
||||
LandblockStreamResult? result = null;
|
||||
for (int i = 0; i < SpinMaxIterations && result is null; i++)
|
||||
{
|
||||
var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
|
||||
if (drained.Count > 0) result = drained[0];
|
||||
else await Task.Delay(SpinStepMs);
|
||||
}
|
||||
|
||||
Assert.NotNull(result);
|
||||
var failed = Assert.IsType<LandblockStreamResult.Failed>(result);
|
||||
Assert.Equal(0xABCDFFFEu, failed.LandblockId);
|
||||
Assert.Contains("mesh", failed.Error, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage()
|
||||
{
|
||||
|
|
@ -104,37 +141,46 @@ public class LandblockStreamerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ExecutesLoaderSynchronously_OnCallingThread()
|
||||
public async Task Load_ExecutesLoaderOnWorkerThread()
|
||||
{
|
||||
// Streamer was made synchronous after Phase A.1 visual verification
|
||||
// exposed concurrent dat reads as the cause of "ball of spikes"
|
||||
// terrain corruption — DatReaderWriter's DatCollection isn't
|
||||
// thread-safe and locking around every dat read on every render-
|
||||
// thread code path was too invasive. Until Phase A.3 introduces a
|
||||
// thread-safe dat wrapper, the load delegate runs on the calling
|
||||
// thread and the result is in the outbox by the time EnqueueLoad
|
||||
// returns. This test pins that contract.
|
||||
// Phase A.5 T11: the load delegate now runs on the dedicated worker
|
||||
// thread (not the calling/render thread). This test verifies the
|
||||
// async hand-off: EnqueueLoad returns immediately and the result
|
||||
// appears in the outbox only after the worker processes the inbox.
|
||||
int testThreadId = System.Environment.CurrentManagedThreadId;
|
||||
int? loaderThreadId = null;
|
||||
var stubLandblock = new LoadedLandblock(
|
||||
0x77770FFEu,
|
||||
new LandBlock(),
|
||||
System.Array.Empty<WorldEntity>());
|
||||
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
|
||||
using var streamer = new LandblockStreamer(loadLandblock: id =>
|
||||
{
|
||||
loaderThreadId = System.Environment.CurrentManagedThreadId;
|
||||
return stubLandblock;
|
||||
});
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: id =>
|
||||
{
|
||||
loaderThreadId = System.Environment.CurrentManagedThreadId;
|
||||
return stubLandblock;
|
||||
},
|
||||
buildMeshOrNull: (_, _) => stubMesh);
|
||||
|
||||
streamer.Start();
|
||||
streamer.EnqueueLoad(0x77770FFEu);
|
||||
|
||||
// Result is already in the outbox — no spinning needed.
|
||||
var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
|
||||
// Spin until the worker produces a completion.
|
||||
LandblockStreamResult? result = null;
|
||||
for (int i = 0; i < SpinMaxIterations && result is null; i++)
|
||||
{
|
||||
var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
|
||||
if (drained.Count > 0) result = drained[0];
|
||||
else await Task.Delay(SpinStepMs);
|
||||
}
|
||||
|
||||
Assert.Single(drained);
|
||||
Assert.IsType<LandblockStreamResult.Loaded>(drained[0]);
|
||||
Assert.Equal(testThreadId, loaderThreadId);
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<LandblockStreamResult.Loaded>(result);
|
||||
// The loader MUST have run on a different thread than the test thread.
|
||||
Assert.NotNull(loaderThreadId);
|
||||
Assert.NotEqual(testThreadId, loaderThreadId.Value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ public class StreamingControllerTests
|
|||
public List<uint> Unloads { get; } = new();
|
||||
public Queue<LandblockStreamResult> Pending { get; } = new();
|
||||
|
||||
public void EnqueueLoad(uint id) => Loads.Add(id);
|
||||
public void EnqueueLoad(uint id, LandblockStreamJobKind _) => Loads.Add(id);
|
||||
public void EnqueueUnload(uint id) => Unloads.Add(id);
|
||||
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int max)
|
||||
{
|
||||
|
|
@ -34,14 +34,15 @@ public class StreamingControllerTests
|
|||
enqueueLoad: fake.EnqueueLoad,
|
||||
enqueueUnload: fake.EnqueueUnload,
|
||||
drainCompletions: fake.DrainCompletions,
|
||||
applyTerrain: _ => { },
|
||||
applyTerrain: (_, _) => { },
|
||||
state: state,
|
||||
radius: 2);
|
||||
nearRadius: 2,
|
||||
farRadius: 2);
|
||||
|
||||
// Center at (50, 50); no landblocks loaded yet.
|
||||
controller.Tick(observerCx: 50, observerCy: 50);
|
||||
|
||||
// 5×5 window = 25 loads enqueued, 0 unloads.
|
||||
// 5×5 window = 25 loads enqueued (nearRadius==farRadius so all go to ToLoadNear), 0 unloads.
|
||||
Assert.Equal(25, fake.Loads.Count);
|
||||
Assert.Empty(fake.Unloads);
|
||||
}
|
||||
|
|
@ -53,7 +54,7 @@ public class StreamingControllerTests
|
|||
var fake = new FakeStreamer();
|
||||
var controller = new StreamingController(
|
||||
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
|
||||
_ => { }, state, radius: 2);
|
||||
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
|
||||
|
||||
controller.Tick(50, 50);
|
||||
fake.Loads.Clear();
|
||||
|
|
@ -72,13 +73,19 @@ public class StreamingControllerTests
|
|||
var applied = new List<LoadedLandblock>();
|
||||
var controller = new StreamingController(
|
||||
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
|
||||
applied.Add, state, radius: 2);
|
||||
(lb, _) => applied.Add(lb), state, nearRadius: 2, farRadius: 2);
|
||||
|
||||
// Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
|
||||
// Entities (positional record). Adjust if the first positional arg
|
||||
// name differs.
|
||||
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
|
||||
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, lb));
|
||||
// A.5 T10-T12 follow-up: use a real empty mesh instance instead of
|
||||
// default! so any future test that flows MeshData through the apply
|
||||
// callback gets a non-null reference to inspect rather than an NRE.
|
||||
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, stubMesh));
|
||||
|
||||
controller.Tick(50, 50);
|
||||
|
||||
|
|
@ -93,7 +100,7 @@ public class StreamingControllerTests
|
|||
var fake = new FakeStreamer();
|
||||
var controller = new StreamingController(
|
||||
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
|
||||
_ => { }, state, radius: 2);
|
||||
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
|
||||
|
||||
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
|
||||
state.AddLandblock(lb);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
|
||||
public class StreamingControllerTwoTierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier()
|
||||
{
|
||||
var loads = new List<(uint Id, LandblockStreamJobKind Kind)>();
|
||||
var unloads = new List<uint>();
|
||||
var state = new GpuWorldState();
|
||||
|
||||
var ctrl = new StreamingController(
|
||||
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||||
enqueueUnload: unloads.Add,
|
||||
drainCompletions: _ => System.Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: (_, _) => { },
|
||||
state: state,
|
||||
nearRadius: 1,
|
||||
farRadius: 3);
|
||||
|
||||
ctrl.Tick(observerCx: 100, observerCy: 100);
|
||||
|
||||
int nearCount = 0, farCount = 0;
|
||||
foreach (var (_, kind) in loads)
|
||||
{
|
||||
if (kind == LandblockStreamJobKind.LoadNear) nearCount++;
|
||||
else if (kind == LandblockStreamJobKind.LoadFar) farCount++;
|
||||
}
|
||||
Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1)
|
||||
Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PlayerWalksOutOfNear_ToDemoteRoutesToRemoveEntities()
|
||||
{
|
||||
// Setup: bootstrap region at (100,100) with near=1, far=3.
|
||||
// The bootstrap puts LB (100,100) in the near tier.
|
||||
// Walking 4+ east drops LB (100,100) past the near-hysteresis
|
||||
// threshold (NearRadius+2 = 3); ToDemote should fire.
|
||||
|
||||
var loads = new List<(uint, LandblockStreamJobKind)>();
|
||||
var unloads = new List<uint>();
|
||||
var state = new GpuWorldState();
|
||||
|
||||
// Pre-load LB (100,100) so RemoveEntitiesFromLandblock has something
|
||||
// to find. The actual entity content doesn't matter for routing.
|
||||
var lb100 = new LoadedLandblock(
|
||||
(100u << 24) | (100u << 16) | 0xFFFFu,
|
||||
Heightmap: null!,
|
||||
Entities: new[] { new WorldEntity {
|
||||
Id = 1, SourceGfxObjOrSetupId = 0,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>() } });
|
||||
state.AddLandblock(lb100);
|
||||
Assert.Equal(1, state.Entities.Count);
|
||||
|
||||
var ctrl = new StreamingController(
|
||||
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||||
enqueueUnload: unloads.Add,
|
||||
drainCompletions: _ => System.Array.Empty<LandblockStreamResult>(),
|
||||
applyTerrain: (_, _) => { },
|
||||
state: state,
|
||||
nearRadius: 1,
|
||||
farRadius: 3);
|
||||
|
||||
ctrl.Tick(observerCx: 100, observerCy: 100); // bootstrap
|
||||
loads.Clear();
|
||||
|
||||
// Walk 4 east — LB (100,100) is now Chebyshev distance 4 from new
|
||||
// center (104,100). NearRadius+2 = 3, so 4 > 3 fires the demote.
|
||||
ctrl.Tick(observerCx: 104, observerCy: 100);
|
||||
|
||||
// ToDemote runs synchronously on the render thread (no enqueue).
|
||||
// The visible effect is RemoveEntitiesFromLandblock dropping the entity.
|
||||
Assert.Empty(state.Entities);
|
||||
// Terrain stays loaded (demote != unload).
|
||||
Assert.True(state.IsLoaded((100u << 24) | (100u << 16) | 0xFFFFu));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_DrainingPromoted_RoutesToAddEntitiesToExisting()
|
||||
{
|
||||
var loads = new List<(uint, LandblockStreamJobKind)>();
|
||||
var unloads = new List<uint>();
|
||||
var state = new GpuWorldState();
|
||||
|
||||
// Pre-load a far-tier-style LB record (terrain only, no entities).
|
||||
// Id must be in canonical form (low 16 bits = 0xFFFF) since
|
||||
// AddEntitiesToExistingLandblock canonicalizes incoming ids.
|
||||
uint lbId = 0x3232FFFFu;
|
||||
var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty<WorldEntity>());
|
||||
state.AddLandblock(lb);
|
||||
Assert.Empty(state.Entities);
|
||||
|
||||
// Streamer pushes a Promoted result carrying the entity layer.
|
||||
var promoted = new LandblockStreamResult.Promoted(
|
||||
lbId,
|
||||
new[] { new WorldEntity {
|
||||
Id = 7, SourceGfxObjOrSetupId = 0,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>() } });
|
||||
var queue = new Queue<LandblockStreamResult>();
|
||||
queue.Enqueue(promoted);
|
||||
|
||||
var ctrl = new StreamingController(
|
||||
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||||
enqueueUnload: unloads.Add,
|
||||
drainCompletions: max =>
|
||||
{
|
||||
var batch = new List<LandblockStreamResult>();
|
||||
while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue());
|
||||
return batch;
|
||||
},
|
||||
applyTerrain: (_, _) => { },
|
||||
state: state,
|
||||
nearRadius: 2,
|
||||
farRadius: 2);
|
||||
|
||||
ctrl.Tick(50, 50); // drains the Promoted result
|
||||
|
||||
// Promoted routes to AddEntitiesToExistingLandblock — the entity is now
|
||||
// merged into the existing LB record.
|
||||
Assert.Equal(1, state.Entities.Count);
|
||||
Assert.Equal(7u, state.Entities[0].Id);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ public class StreamingRegionTests
|
|||
{
|
||||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||||
|
||||
var diff = region.RecenterTo(50, 50);
|
||||
var diff = region.RecenterToSingleTier(50, 50);
|
||||
|
||||
Assert.Empty(diff.ToLoad);
|
||||
Assert.Empty(diff.ToUnload);
|
||||
|
|
@ -52,7 +52,7 @@ public class StreamingRegionTests
|
|||
// the radius+2 threshold, so it stays loaded (hysteresis keeps radius+2).
|
||||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||||
|
||||
var diff = region.RecenterTo(51, 50);
|
||||
var diff = region.RecenterToSingleTier(51, 50);
|
||||
|
||||
Assert.Equal(5, diff.ToLoad.Count);
|
||||
Assert.Empty(diff.ToUnload);
|
||||
|
|
@ -71,7 +71,7 @@ public class StreamingRegionTests
|
|||
// x=48 is now 5 away, > radius+2 = 4 → unload. x=49 is 4 away, not > 4 → keep. x=50 is 3 away, not > 4 → keep.
|
||||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||||
|
||||
var diff = region.RecenterTo(53, 50);
|
||||
var diff = region.RecenterToSingleTier(53, 50);
|
||||
|
||||
Assert.Equal(15, diff.ToLoad.Count);
|
||||
Assert.Equal(5, diff.ToUnload.Count);
|
||||
|
|
@ -82,7 +82,7 @@ public class StreamingRegionTests
|
|||
{
|
||||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||||
|
||||
var diff = region.RecenterTo(200, 200);
|
||||
var diff = region.RecenterToSingleTier(200, 200);
|
||||
|
||||
Assert.Equal(25, diff.ToLoad.Count);
|
||||
Assert.Equal(25, diff.ToUnload.Count);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
using AcDream.App.Streaming;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Streaming;
|
||||
|
||||
public class StreamingRegionTwoTierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_TwoRadii_ExposesNearAndFarRadii()
|
||||
{
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 4, farRadius: 12);
|
||||
|
||||
Assert.Equal(4, region.NearRadius);
|
||||
Assert.Equal(12, region.FarRadius);
|
||||
Assert.Equal(100, region.CenterX);
|
||||
Assert.Equal(100, region.CenterY);
|
||||
// Radius (used by existing single-radius hysteresis math) must alias to
|
||||
// FarRadius — the outer ring drives "everything currently loaded" bookkeeping.
|
||||
// If a future change mistakenly aliases Radius to NearRadius, hysteresis
|
||||
// becomes (NearRadius+2) for the far-tier unload, which is wrong.
|
||||
Assert.Equal(region.FarRadius, region.Radius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFirstTickDiff_FirstTick_SplitsLoadIntoNearAndFar()
|
||||
{
|
||||
// near=1, far=3 → near window is 3×3=9, far window is 7×7-3×3=40 LBs.
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
|
||||
var diff = region.ComputeFirstTickDiff();
|
||||
|
||||
Assert.Equal(9, diff.ToLoadNear.Count);
|
||||
Assert.Equal(40, diff.ToLoadFar.Count);
|
||||
Assert.Empty(diff.ToPromote);
|
||||
Assert.Empty(diff.ToDemote);
|
||||
Assert.Empty(diff.ToUnload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar()
|
||||
{
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
|
||||
_ = region.ComputeFirstTickDiff();
|
||||
region.MarkResidentFromBootstrap();
|
||||
|
||||
// Walk one LB east — center (100,100) → (101,100). LB column at lbX=104
|
||||
// (relative dx=+3 from new center) enters the far window from null.
|
||||
var diff = region.RecenterTo(newCx: 101, newCy: 100);
|
||||
|
||||
foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 })
|
||||
{
|
||||
var id = StreamingRegion.EncodeLandblockIdForTest(104, y);
|
||||
Assert.Contains(id, diff.ToLoadFar);
|
||||
}
|
||||
Assert.Empty(diff.ToLoadNear);
|
||||
// The 3 LBs at x=102, y in {99,100,101} were Far from old center
|
||||
// (distance 2) and are now Near from new center (distance ≤1).
|
||||
// They should land in ToPromote.
|
||||
Assert.Equal(3, diff.ToPromote.Count);
|
||||
// All resident LBs from the old window are within hysteresis of
|
||||
// the new center (max distance 4 ≤ FarRadius+2=5), so nothing unloads.
|
||||
Assert.Empty(diff.ToUnload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterTo_PlayerWalks_FarToNear_AppearsInToPromote()
|
||||
{
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
|
||||
_ = region.ComputeFirstTickDiff();
|
||||
region.MarkResidentFromBootstrap();
|
||||
|
||||
// Walk 2 east — center (102, 100). LB (102, 100) was at distance 2 (Far)
|
||||
// from (100,100); now at distance 0 → Near. That's a Promote.
|
||||
var diff = region.RecenterTo(newCx: 102, newCy: 100);
|
||||
|
||||
var promotedId = StreamingRegion.EncodeLandblockIdForTest(102, 100);
|
||||
Assert.Contains(promotedId, diff.ToPromote);
|
||||
Assert.DoesNotContain(promotedId, diff.ToLoadNear);
|
||||
Assert.DoesNotContain(promotedId, diff.ToLoadFar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterTo_PlayerTeleports_NullToNear_AppearsInToLoadNear()
|
||||
{
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
|
||||
_ = region.ComputeFirstTickDiff();
|
||||
region.MarkResidentFromBootstrap();
|
||||
|
||||
// Teleport to (200, 200) — entirely new region.
|
||||
var diff = region.RecenterTo(newCx: 200, newCy: 200);
|
||||
|
||||
Assert.Equal(9, diff.ToLoadNear.Count);
|
||||
Assert.Equal(40, diff.ToLoadFar.Count);
|
||||
Assert.Empty(diff.ToPromote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterTo_NearToFar_DemoteOnlyAfterHysteresis()
|
||||
{
|
||||
// near=2, far=4 → near hysteresis threshold = 4.
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4);
|
||||
_ = region.ComputeFirstTickDiff();
|
||||
region.MarkResidentFromBootstrap();
|
||||
|
||||
// LB (100,100) was Near. Walk 3 east → distance 3 > NearRadius=2 but ≤ 4. No demote yet.
|
||||
var diff1 = region.RecenterTo(newCx: 103, newCy: 100);
|
||||
var lb100 = StreamingRegion.EncodeLandblockIdForTest(100, 100);
|
||||
Assert.DoesNotContain(lb100, diff1.ToDemote);
|
||||
|
||||
// Walk 2 more east → distance 5 > 4. Demote.
|
||||
var diff2 = region.RecenterTo(newCx: 105, newCy: 100);
|
||||
Assert.Contains(lb100, diff2.ToDemote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterTo_FarToNull_UnloadOnlyAfterHysteresis()
|
||||
{
|
||||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
|
||||
_ = region.ComputeFirstTickDiff();
|
||||
region.MarkResidentFromBootstrap();
|
||||
|
||||
// LB (97, 100) was at distance 3 (Far). Walk 1 east → distance 4. ≤ FarRadius+2=5.
|
||||
var diff1 = region.RecenterTo(newCx: 101, newCy: 100);
|
||||
var lb97 = StreamingRegion.EncodeLandblockIdForTest(97, 100);
|
||||
Assert.DoesNotContain(lb97, diff1.ToUnload);
|
||||
|
||||
// Walk 2 more east → distance 6 > 5. Unload.
|
||||
var diff2 = region.RecenterTo(newCx: 103, newCy: 100);
|
||||
Assert.Contains(lb97, diff2.ToUnload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis()
|
||||
{
|
||||
// Start the region centered on (103,100) so the oscillation
|
||||
// between (102,100) and (103,100) never crosses a hysteresis boundary.
|
||||
// NearRadius=2, farRadius=4 → nearUnloadThreshold=4.
|
||||
// Chebyshev distance from (102,100) or (103,100) to any LB in the
|
||||
// initial 9×9 window of (103,100) is ≤ NearRadius+2=4 for all LBs
|
||||
// in the near zone, so no demotes should fire during pure oscillation.
|
||||
var region = new StreamingRegion(centerX: 103, centerY: 100, nearRadius: 2, farRadius: 4);
|
||||
_ = region.ComputeFirstTickDiff();
|
||||
region.MarkResidentFromBootstrap();
|
||||
|
||||
// Bounce between (103,100) and (102,100). All resident LBs stay
|
||||
// within the hysteresis window — no demotes expected.
|
||||
int totalDemotes = 0;
|
||||
int totalPromotes = 0;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var d1 = region.RecenterTo(102, 100);
|
||||
totalDemotes += d1.ToDemote.Count;
|
||||
totalPromotes += d1.ToPromote.Count;
|
||||
var d2 = region.RecenterTo(103, 100);
|
||||
totalDemotes += d2.ToDemote.Count;
|
||||
totalPromotes += d2.ToPromote.Count;
|
||||
}
|
||||
|
||||
// The first step from (103,100) to (102,100) legitimately promotes the
|
||||
// x=100 near-column (5 LBs) that were Far from (103) into Near. After
|
||||
// that initial settle they stay Near for all subsequent oscillations.
|
||||
// So the ceiling is 5 promotes total (not per oscillation).
|
||||
Assert.Equal(0, totalDemotes);
|
||||
Assert.True(totalPromotes <= 5,
|
||||
$"Expected ≤5 promotes across 5 oscillations; got {totalPromotes}");
|
||||
}
|
||||
}
|
||||
47
tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs
Normal file
47
tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
public class WorldEntityAabbTests
|
||||
{
|
||||
[Fact]
|
||||
public void Aabb_DefaultRadius_PositionPlusMinus5()
|
||||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = new Vector3(10, 20, 30),
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
entity.RefreshAabb();
|
||||
|
||||
Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin);
|
||||
Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh()
|
||||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = new Vector3(10, 20, 30),
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
};
|
||||
entity.RefreshAabb();
|
||||
Assert.False(entity.AabbDirty);
|
||||
|
||||
entity.SetPosition(new Vector3(100, 200, 300));
|
||||
Assert.True(entity.AabbDirty);
|
||||
|
||||
entity.RefreshAabb();
|
||||
Assert.False(entity.AabbDirty);
|
||||
Assert.Equal(new Vector3(95, 195, 295), entity.AabbMin);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
using AcDream.UI.Abstractions.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// A.5 T22.5: <see cref="QualitySettings"/> preset table + env-var override
|
||||
/// coverage. Env-var tests clear their variables in <c>finally</c> blocks so
|
||||
/// parallel runners cannot bleed state between tests.
|
||||
/// </summary>
|
||||
public class QualityPresetTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(QualityPreset.Low, 2, 5, 0)]
|
||||
[InlineData(QualityPreset.Medium, 3, 8, 2)]
|
||||
[InlineData(QualityPreset.High, 4, 12, 4)]
|
||||
[InlineData(QualityPreset.Ultra, 5, 15, 4)]
|
||||
public void From_Preset_ProducesExpectedRadiiAndMsaa(
|
||||
QualityPreset preset, int n1, int n2, int msaa)
|
||||
{
|
||||
var s = QualitySettings.From(preset);
|
||||
Assert.Equal(n1, s.NearRadius);
|
||||
Assert.Equal(n2, s.FarRadius);
|
||||
Assert.Equal(msaa, s.MsaaSamples);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(QualityPreset.Low, 4, false)]
|
||||
[InlineData(QualityPreset.Medium, 8, false)]
|
||||
[InlineData(QualityPreset.High, 16, true)]
|
||||
[InlineData(QualityPreset.Ultra, 16, true)]
|
||||
public void From_Preset_ProducesExpectedAnisoAndA2C(
|
||||
QualityPreset preset, int aniso, bool a2c)
|
||||
{
|
||||
var s = QualitySettings.From(preset);
|
||||
Assert.Equal(aniso, s.AnisotropicLevel);
|
||||
Assert.Equal(a2c, s.AlphaToCoverage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(QualityPreset.Low, 2)]
|
||||
[InlineData(QualityPreset.Medium, 3)]
|
||||
[InlineData(QualityPreset.High, 4)]
|
||||
[InlineData(QualityPreset.Ultra, 6)]
|
||||
public void From_Preset_ProducesExpectedMaxCompletions(
|
||||
QualityPreset preset, int expected)
|
||||
{
|
||||
var s = QualitySettings.From(preset);
|
||||
Assert.Equal(expected, s.MaxCompletionsPerFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_NearRadius_OverridesPreset()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", "2");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High); // High = NearRadius=4 normally
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.Equal(2, resolved.NearRadius);
|
||||
Assert.Equal(12, resolved.FarRadius); // FarRadius unaffected
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_FarRadius_OverridesPreset()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", "20");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High);
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.Equal(4, resolved.NearRadius); // NearRadius unaffected
|
||||
Assert.Equal(20, resolved.FarRadius);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_AlphaToCoverage_BooleanParsing()
|
||||
{
|
||||
// Ensure "0" and "false" disable; other values enable.
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "0");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High); // High has A2C=true
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.False(resolved.AlphaToCoverage);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_AlphaToCoverage_FalseString_Disables()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "false");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High);
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.False(resolved.AlphaToCoverage);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_AlphaToCoverage_NonZeroEnables()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "1");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.Low); // Low has A2C=false
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.True(resolved.AlphaToCoverage);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_Unset_LeavesPresetDefault()
|
||||
{
|
||||
// Ensure no env vars are set for this test's fields.
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null);
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null);
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null);
|
||||
|
||||
var s = QualitySettings.From(QualityPreset.High);
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.Equal(s, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_UndefinedPreset_FallsBackToHigh()
|
||||
{
|
||||
var s = QualitySettings.From((QualityPreset)99);
|
||||
Assert.Equal(4, s.NearRadius); // High default
|
||||
Assert.Equal(12, s.FarRadius);
|
||||
Assert.Equal(4, s.MsaaSamples);
|
||||
Assert.True(s.AlphaToCoverage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_MaxCompletionsPerFrame_OverridesPreset()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", "8");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High); // High = 4
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.Equal(8, resolved.MaxCompletionsPerFrame);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_MsaaSamples_OverridesPreset()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", "8");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High); // High = 4
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.Equal(8, resolved.MsaaSamples);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_Anisotropic_OverridesPreset()
|
||||
{
|
||||
System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", "4");
|
||||
try
|
||||
{
|
||||
var s = QualitySettings.From(QualityPreset.High); // High = 16
|
||||
var resolved = QualitySettings.WithEnvOverrides(s);
|
||||
Assert.Equal(4, resolved.AnisotropicLevel);
|
||||
}
|
||||
finally { System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", null); }
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,8 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
VSync: false,
|
||||
FieldOfView: 100f,
|
||||
Gamma: 1.4f,
|
||||
ShowFps: true);
|
||||
ShowFps: true,
|
||||
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra);
|
||||
|
||||
store.SaveDisplay(original);
|
||||
var loaded = store.LoadDisplay();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue