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:
Erik 2026-05-10 10:09:03 +02:00
commit d3d78fa14f
37 changed files with 6001 additions and 281 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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