Spec (2026-05-09-phase-a5-two-tier-streaming-design.md): - §2 acceptance metrics reshaped from absolute 240 FPS to refresh-rate-relative + per-preset (95th-pct ≤ 1000ms/refresh standstill; ≤ 1.5× walking) to match the Quality Preset reality. - New §4.10 Quality Preset System (T22.5): enum Low/Medium/High/Ultra, QualitySettings schema, canonical preset values table, env-var override table, wiring notes (GameWindow.OnLoad + ReapplyQualityPreset), MSAA mid-session unsupported caveat, file list, test count (12). - New §11 What was deferred: 8 items (Tier 1 cache, lifestone, JobKind plumbing, Tier 2/3, ToEntries alloc, InvalidateEntity wiring, High preset retest). Former §11 References renumbered to §12. Plan (2026-05-09-phase-a5-two-tier-streaming.md): - New Task 22.5 section inserted between T22 and T23: full inline spec with schema, preset table, env-var list, wiring steps, acceptance criteria, deferred items, commit SHAs. Includes file-name corrections (SettingsState → DisplaySettings, DisplayTab → SettingsPanel). - Self-review cross-check table: new §4.10 row pointing at T22.5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2525 lines
85 KiB
Markdown
2525 lines
85 KiB
Markdown
# Phase A.5 — Two-tier Streaming + Horizon LOD — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Deliver Phase A.5 — extend acdream's streaming radius from 5 (~1 km) to a tiered N₁=4 / N₂=12 layout (~2.3 km horizon) sustaining 240 FPS at standstill on a 240 Hz / 1440p monitor.
|
||
|
||
**Architecture:** Two-tier streaming (near = full detail, far = terrain only) + tightening the existing per-LB entity dispatcher walk + off-thread mesh build (single worker) + fog blend at the near boundary + three visual quality wins (terrain mipmaps + anisotropic, A2C with MSAA on foliage, depth-write audit).
|
||
|
||
**Tech Stack:** C# .NET 10, Silk.NET (OpenGL 4.3+), bindless textures (`GL_ARB_bindless_texture`), `glMultiDrawElementsIndirect`, xUnit for tests. WorldBuilder is the rendering foundation; we extend WB's `ObjectMeshManager` + acdream's `TerrainModernRenderer`.
|
||
|
||
**Spec:** [`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`](../specs/2026-05-09-phase-a5-two-tier-streaming-design.md)
|
||
|
||
---
|
||
|
||
## Conventions
|
||
|
||
- **Working dir:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-darwin-ae8b87` (this worktree).
|
||
- **Branch:** `claude/hopeful-darwin-ae8b87`.
|
||
- **Build:** `dotnet build` from worktree root.
|
||
- **Test:** `dotnet test --no-build` (full suite); filter via `--filter "FullyQualifiedName~<pat>"` for targeted runs.
|
||
- **Commits:** prefix `phase(A.5):` or `feat(A.5):`/`test(A.5):`/`fix(A.5):`/`docs(A.5):` per task type. End with `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>` per the project convention.
|
||
- **Test framework:** xUnit + FluentAssertions. Existing tests use `[Fact]` + `Assert.*` style — follow that.
|
||
|
||
---
|
||
|
||
## Task 1: Add `LandblockStreamTier` and `LandblockStreamJobKind` enums
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Streaming/LandblockStreamTier.cs`
|
||
|
||
- [ ] **Step 1: Write the file**
|
||
|
||
```csharp
|
||
namespace AcDream.App.Streaming;
|
||
|
||
/// <summary>
|
||
/// Streaming-tier classification for a landblock. <see cref="Far"/> means
|
||
/// terrain mesh only; <see cref="Near"/> means terrain + scenery + EnvCells +
|
||
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
|
||
/// </summary>
|
||
public enum LandblockStreamTier
|
||
{
|
||
Far,
|
||
Near,
|
||
}
|
||
|
||
/// <summary>
|
||
/// What work the streaming worker should perform for a given job. Distinct
|
||
/// from <see cref="LandblockStreamTier"/> because <see cref="PromoteToNear"/>
|
||
/// reads only the entity layer (terrain mesh already loaded), while
|
||
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
|
||
/// </summary>
|
||
public enum LandblockStreamJobKind
|
||
{
|
||
/// <summary>Read LandBlock heightmap, build mesh, no entity layer.</summary>
|
||
LoadFar,
|
||
/// <summary>Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.</summary>
|
||
LoadNear,
|
||
/// <summary>Read LandBlockInfo + scenery only — terrain already loaded for this LB.</summary>
|
||
PromoteToNear,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build to verify**
|
||
|
||
Run: `dotnet build`
|
||
Expected: `Build succeeded.` 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/LandblockStreamTier.cs
|
||
git commit -m "feat(A.5 T1): LandblockStreamTier + LandblockStreamJobKind enums"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add `TwoTierDiff` record + extend `LandblockStreamJob.Load` with kind
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Streaming/TwoTierDiff.cs`
|
||
- Modify: `src/AcDream.App/Streaming/LandblockStreamJob.cs`
|
||
- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
|
||
|
||
- [ ] **Step 1: Write `TwoTierDiff.cs`**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
|
||
namespace AcDream.App.Streaming;
|
||
|
||
/// <summary>
|
||
/// Output of <see cref="StreamingRegion.RecenterTo"/> for the two-tier model.
|
||
/// Five disjoint lists describe what changed since the previous Tick. Per
|
||
/// Phase A.5 spec §4.2.
|
||
/// </summary>
|
||
public readonly record struct TwoTierDiff(
|
||
IReadOnlyList<uint> ToLoadFar, // entered far window from null (terrain only)
|
||
IReadOnlyList<uint> ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport)
|
||
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (entities only)
|
||
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
|
||
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)
|
||
```
|
||
|
||
- [ ] **Step 2: Modify `LandblockStreamJob.cs`**
|
||
|
||
Change the `Load` record from:
|
||
|
||
```csharp
|
||
public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
||
```
|
||
|
||
- [ ] **Step 3: Patch the call site to satisfy the compiler**
|
||
|
||
In `LandblockStreamer.EnqueueLoad` (~line 91), change:
|
||
|
||
```csharp
|
||
HandleJob(new LandblockStreamJob.Load(landblockId));
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear));
|
||
```
|
||
|
||
The `LoadNear` placeholder reproduces today's "full load" semantics; Task 16 replaces this with proper routing.
|
||
|
||
- [ ] **Step 4: Build green**
|
||
|
||
Run: `dotnet build`
|
||
Expected: `Build succeeded.` 0 errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/TwoTierDiff.cs src/AcDream.App/Streaming/LandblockStreamJob.cs src/AcDream.App/Streaming/LandblockStreamer.cs
|
||
git commit -m "feat(A.5 T2): TwoTierDiff record + LandblockStreamJob.Load.Kind"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Test — `StreamingRegion` two-radius constructor
|
||
|
||
**Files:**
|
||
- Create: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
|
||
- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — verify fails**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"`
|
||
Expected: FAIL — `StreamingRegion` has no constructor taking `nearRadius`/`farRadius`.
|
||
|
||
- [ ] **Step 3: Add the two-radius constructor**
|
||
|
||
In `src/AcDream.App/Streaming/StreamingRegion.cs`, add (don't remove the
|
||
existing single-radius constructor yet — that gets cleaned up in Task 19):
|
||
|
||
```csharp
|
||
public int NearRadius { get; }
|
||
public int FarRadius { get; }
|
||
|
||
public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius)
|
||
{
|
||
NearRadius = nearRadius;
|
||
FarRadius = farRadius;
|
||
Radius = farRadius; // outer ring drives Resident bookkeeping below
|
||
Recenter(centerX, centerY);
|
||
}
|
||
```
|
||
|
||
If the existing constructor is `public StreamingRegion(int cx, int cy, int radius)`,
|
||
preserve it as a thin wrapper:
|
||
|
||
```csharp
|
||
public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { }
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — verify passes**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs
|
||
git commit -m "test(A.5 T3): StreamingRegion two-radius constructor"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Test + implement `ComputeFirstTickDiff`
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
|
||
- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs`
|
||
|
||
- [ ] **Step 1: Add the failing test**
|
||
|
||
Append to `StreamingRegionTwoTierTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void RecenterTo_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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — verify fails**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_FirstTick"`
|
||
Expected: FAIL or compile error — `ComputeFirstTickDiff` doesn't exist.
|
||
|
||
- [ ] **Step 3: Implement `ComputeFirstTickDiff`**
|
||
|
||
In `StreamingRegion.cs`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring,
|
||
/// ToLoadFar for every LB in the outer ring (between near and far). Used
|
||
/// by <see cref="StreamingController.Tick"/> on the first call before any
|
||
/// RecenterTo.
|
||
/// </summary>
|
||
public TwoTierDiff ComputeFirstTickDiff()
|
||
{
|
||
var near = new List<uint>();
|
||
var far = new List<uint>();
|
||
for (int dx = -FarRadius; dx <= FarRadius; dx++)
|
||
{
|
||
for (int dy = -FarRadius; dy <= FarRadius; dy++)
|
||
{
|
||
int nx = CenterX + dx;
|
||
int ny = CenterY + dy;
|
||
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
|
||
int absDx = System.Math.Abs(dx);
|
||
int absDy = System.Math.Abs(dy);
|
||
var id = EncodeLandblockId(nx, ny);
|
||
if (absDx <= NearRadius && absDy <= NearRadius)
|
||
near.Add(id);
|
||
else
|
||
far.Add(id);
|
||
}
|
||
}
|
||
return new TwoTierDiff(
|
||
ToLoadFar: far,
|
||
ToLoadNear: near,
|
||
ToPromote: System.Array.Empty<uint>(),
|
||
ToDemote: System.Array.Empty<uint>(),
|
||
ToUnload: System.Array.Empty<uint>());
|
||
}
|
||
```
|
||
|
||
Uses Chebyshev (chess-king) distance — same convention as the existing `Recenter`.
|
||
|
||
- [ ] **Step 4: Run test — verify passes**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_FirstTick"`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs
|
||
git commit -m "feat(A.5 T4): StreamingRegion ComputeFirstTickDiff"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Test + implement `RecenterTo` two-tier overload (covers null→Far, Far→Near, Near→Far, Far→null)
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
|
||
- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs`
|
||
|
||
- [ ] **Step 1: Add the failing test (null→Far transition)**
|
||
|
||
```csharp
|
||
[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 moves from (100,100) to (101,100).
|
||
// The east 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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — verify fails**
|
||
|
||
Expected: FAIL — `MarkResidentFromBootstrap` / `EncodeLandblockIdForTest` don't exist + `RecenterTo` doesn't yet produce a `TwoTierDiff`.
|
||
|
||
- [ ] **Step 3: Implement two-tier `RecenterTo` + helpers**
|
||
|
||
In `StreamingRegion.cs`:
|
||
|
||
```csharp
|
||
internal enum TierResidence { None, Far, Near }
|
||
private readonly Dictionary<uint, TierResidence> _tierResidence = new();
|
||
|
||
public void MarkResidentFromBootstrap()
|
||
{
|
||
for (int dx = -FarRadius; dx <= FarRadius; dx++)
|
||
{
|
||
for (int dy = -FarRadius; dy <= FarRadius; dy++)
|
||
{
|
||
int nx = CenterX + dx;
|
||
int ny = CenterY + dy;
|
||
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
|
||
int absDx = System.Math.Abs(dx);
|
||
int absDy = System.Math.Abs(dy);
|
||
var id = EncodeLandblockId(nx, ny);
|
||
_tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius)
|
||
? TierResidence.Near
|
||
: TierResidence.Far;
|
||
}
|
||
}
|
||
}
|
||
|
||
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
|
||
=> EncodeLandblockId(lbX, lbY);
|
||
|
||
/// <summary>
|
||
/// Two-tier overload of RecenterTo. Computes the 5-list diff per Phase A.5 spec §4.2.
|
||
/// Hysteresis: NearRadius+2 for near→far demote; FarRadius+2 for far→null unload.
|
||
/// </summary>
|
||
public TwoTierDiff RecenterTo(int newCx, int newCy)
|
||
{
|
||
int nearUnloadThreshold = NearRadius + 2;
|
||
int farUnloadThreshold = FarRadius + 2;
|
||
|
||
var toLoadFar = new List<uint>();
|
||
var toLoadNear = new List<uint>();
|
||
var toPromote = new List<uint>();
|
||
var toDemote = new List<uint>();
|
||
var toUnload = new List<uint>();
|
||
|
||
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
|
||
var newCenterIds = new HashSet<uint>();
|
||
for (int dx = -FarRadius; dx <= FarRadius; dx++)
|
||
{
|
||
for (int dy = -FarRadius; dy <= FarRadius; dy++)
|
||
{
|
||
int nx = newCx + dx;
|
||
int ny = newCy + dy;
|
||
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
|
||
int absDx = System.Math.Abs(dx);
|
||
int absDy = System.Math.Abs(dy);
|
||
bool inNear = absDx <= NearRadius && absDy <= NearRadius;
|
||
var id = EncodeLandblockId(nx, ny);
|
||
newCenterIds.Add(id);
|
||
|
||
if (!_tierResidence.TryGetValue(id, out var current))
|
||
{
|
||
if (inNear) toLoadNear.Add(id);
|
||
else toLoadFar.Add(id);
|
||
_tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far;
|
||
}
|
||
else if (current == TierResidence.Far && inNear)
|
||
{
|
||
toPromote.Add(id);
|
||
_tierResidence[id] = TierResidence.Near;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pass 2: handle previously-resident LBs — demote / unload by distance.
|
||
foreach (var kvp in _tierResidence.ToArray())
|
||
{
|
||
var id = kvp.Key;
|
||
var current = kvp.Value;
|
||
int lbX = (int)((id >> 24) & 0xFFu);
|
||
int lbY = (int)((id >> 16) & 0xFFu);
|
||
int absDx = System.Math.Abs(lbX - newCx);
|
||
int absDy = System.Math.Abs(lbY - newCy);
|
||
int distance = System.Math.Max(absDx, absDy);
|
||
|
||
if (newCenterIds.Contains(id))
|
||
{
|
||
// Possible Near→Far demote even though id is in window: was Near,
|
||
// now outside near radius (but still within hysteresis window).
|
||
if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius))
|
||
{
|
||
if (distance > nearUnloadThreshold)
|
||
{
|
||
toDemote.Add(id);
|
||
_tierResidence[id] = TierResidence.Far;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Outside new window — check unload thresholds.
|
||
if (current == TierResidence.Near)
|
||
{
|
||
if (distance > nearUnloadThreshold)
|
||
{
|
||
toDemote.Add(id);
|
||
_tierResidence[id] = TierResidence.Far;
|
||
if (distance > farUnloadThreshold)
|
||
{
|
||
toUnload.Add(id);
|
||
_tierResidence.Remove(id);
|
||
}
|
||
}
|
||
}
|
||
else if (current == TierResidence.Far)
|
||
{
|
||
if (distance > farUnloadThreshold)
|
||
{
|
||
toUnload.Add(id);
|
||
_tierResidence.Remove(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
CenterX = newCx;
|
||
CenterY = newCy;
|
||
|
||
return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload);
|
||
}
|
||
```
|
||
|
||
If `CenterX` / `CenterY` are currently `{ get; }` (init-only), change to
|
||
`{ get; private set; }`.
|
||
|
||
- [ ] **Step 4: Run test — verify passes**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_PlayerWalks_NullToFar"`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs
|
||
git commit -m "feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Tests for Far→Near, null→Near (teleport), Near→Far hysteresis, Far→null hysteresis, oscillation
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs`
|
||
|
||
- [ ] **Step 1: Add Far→Near (Promote) test**
|
||
|
||
```csharp
|
||
[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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add null→Near (teleport) test**
|
||
|
||
```csharp
|
||
[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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add Near→Far hysteresis test**
|
||
|
||
```csharp
|
||
[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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add Far→null hysteresis test**
|
||
|
||
```csharp
|
||
[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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add oscillation no-thrash test**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis()
|
||
{
|
||
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4);
|
||
_ = region.ComputeFirstTickDiff();
|
||
region.MarkResidentFromBootstrap();
|
||
|
||
// Bounce between (102,100) and (103,100). Distance from each to (100,100)
|
||
// is 2 and 3 — both within NearRadius+2=4 hysteresis. No demote should fire.
|
||
int totalDemotes = 0;
|
||
int totalPromotes = 0;
|
||
for (int i = 0; i < 5; i++)
|
||
{
|
||
var d1 = region.RecenterTo(103, 100);
|
||
totalDemotes += d1.ToDemote.Count;
|
||
totalPromotes += d1.ToPromote.Count;
|
||
var d2 = region.RecenterTo(102, 100);
|
||
totalDemotes += d2.ToDemote.Count;
|
||
totalPromotes += d2.ToPromote.Count;
|
||
}
|
||
|
||
Assert.Equal(0, totalDemotes);
|
||
// Some promote on the very first crossing is expected (LBs that were Far
|
||
// becoming Near); after that, oscillation should settle.
|
||
Assert.True(totalPromotes <= 4,
|
||
$"Expected ≤4 promotes across 5 oscillations; got {totalPromotes}");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run all five tests — verify pass**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"`
|
||
Expected: 6 passing total (the 1 from Task 3 + 5 added here).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs
|
||
git commit -m "test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Extend `LandblockStreamResult.Loaded` with Tier + MeshData; add `Promoted`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/LandblockStreamJob.cs`
|
||
- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
|
||
|
||
- [ ] **Step 1: Replace `LandblockStreamResult` with extended variants**
|
||
|
||
In `LandblockStreamJob.cs`, replace the existing `LandblockStreamResult`
|
||
record block:
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Terrain;
|
||
using AcDream.Core.World;
|
||
|
||
public abstract record LandblockStreamResult(uint LandblockId)
|
||
{
|
||
/// <summary>
|
||
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
|
||
/// (terrain only) from Near (terrain + entities). <see cref="MeshData"/>
|
||
/// is built off the render thread on the streaming worker.
|
||
/// </summary>
|
||
public sealed record Loaded(
|
||
uint LandblockId,
|
||
LandblockStreamTier Tier,
|
||
LoadedLandblock Landblock,
|
||
LandblockMeshData MeshData
|
||
) : LandblockStreamResult(LandblockId);
|
||
|
||
/// <summary>
|
||
/// A previously-Far-resident landblock was promoted to Near. Terrain
|
||
/// mesh is already on the GPU; the result carries the entity layer
|
||
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
|
||
/// entry.
|
||
/// </summary>
|
||
public sealed record Promoted(
|
||
uint LandblockId,
|
||
IReadOnlyList<WorldEntity> Entities
|
||
) : LandblockStreamResult(LandblockId);
|
||
|
||
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
|
||
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
|
||
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Patch `LandblockStreamer.HandleJob` to compile (placeholder MeshData)**
|
||
|
||
In `LandblockStreamer.HandleJob` (line ~167), update the `Loaded` construction:
|
||
|
||
```csharp
|
||
// TEMPORARY: passes default! for MeshData — Task 13 wires the real mesh build.
|
||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||
load.LandblockId,
|
||
LandblockStreamTier.Near,
|
||
lb,
|
||
MeshData: default! /* TODO(A.5 T13) */));
|
||
```
|
||
|
||
- [ ] **Step 3: Build verify**
|
||
|
||
Run: `dotnet build`
|
||
Expected: build succeeded.
|
||
|
||
- [ ] **Step 4: Run all tests still pass**
|
||
|
||
Run: `dotnet test --no-build`
|
||
Expected: previously-passing tests still pass; new tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/LandblockStreamJob.cs src/AcDream.App/Streaming/LandblockStreamer.cs
|
||
git commit -m "feat(A.5 T7): LandblockStreamResult.Loaded.Tier + MeshData; Promoted variant"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Add `WorldEntity.AabbMin/AabbMax` cache + dirty flag + `RefreshAabb` + `SetPosition`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/World/WorldEntity.cs`
|
||
- Create: `tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
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,
|
||
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,
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — verify fails**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~WorldEntityAabbTests"`
|
||
Expected: FAIL — fields/methods don't exist.
|
||
|
||
- [ ] **Step 3: Add fields and methods to `WorldEntity`**
|
||
|
||
Locate `WorldEntity.cs` and add:
|
||
|
||
```csharp
|
||
// Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the
|
||
// dispatcher's frustum cull is a memory read, not a per-frame recompute.
|
||
public Vector3 AabbMin { get; private set; }
|
||
public Vector3 AabbMax { get; private set; }
|
||
public bool AabbDirty { get; private set; } = true;
|
||
|
||
private const float DefaultAabbRadius = 5.0f;
|
||
|
||
public void RefreshAabb()
|
||
{
|
||
var p = Position;
|
||
AabbMin = new Vector3(p.X - DefaultAabbRadius, p.Y - DefaultAabbRadius, p.Z - DefaultAabbRadius);
|
||
AabbMax = new Vector3(p.X + DefaultAabbRadius, p.Y + DefaultAabbRadius, p.Z + DefaultAabbRadius);
|
||
AabbDirty = false;
|
||
}
|
||
|
||
public void SetPosition(Vector3 pos)
|
||
{
|
||
Position = pos;
|
||
AabbDirty = true;
|
||
}
|
||
```
|
||
|
||
If `Position` is currently `{ get; init; }`, change to `{ get; set; }` so
|
||
`SetPosition` can write it. Object-initializer assignments still compile.
|
||
|
||
- [ ] **Step 4: Run test — verify passes**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~WorldEntityAabbTests"`
|
||
Expected: PASS, 2 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs src/AcDream.Core/World/WorldEntity.cs
|
||
git commit -m "feat(A.5 T8): WorldEntity AABB cache + dirty flag"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Swap `_surfaceCache` to `ConcurrentDictionary` for thread-safety
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Terrain/LandblockMesh.cs`
|
||
- Modify: the `_surfaceCache` owner (find via grep)
|
||
|
||
- [ ] **Step 1: Locate the `_surfaceCache` owner**
|
||
|
||
Run: `Grep "surfaceCache|SurfaceCache" --include "*.cs" src/AcDream.App` from worktree root.
|
||
Identify which class declares the cache passed to `LandblockMesh.Build`.
|
||
|
||
- [ ] **Step 2: Widen `LandblockMesh.Build` parameter to `IDictionary<uint, SurfaceInfo>`**
|
||
|
||
In `LandblockMesh.cs`, change:
|
||
|
||
```csharp
|
||
public static LandblockMeshData Build(
|
||
LandBlock block,
|
||
uint landblockX,
|
||
uint landblockY,
|
||
float[] heightTable,
|
||
TerrainBlendingContext ctx,
|
||
Dictionary<uint, SurfaceInfo> surfaceCache)
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
public static LandblockMeshData Build(
|
||
LandBlock block,
|
||
uint landblockX,
|
||
uint landblockY,
|
||
float[] heightTable,
|
||
TerrainBlendingContext ctx,
|
||
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache)
|
||
```
|
||
|
||
The lookup pattern in Build (lines ~108-112) is:
|
||
|
||
```csharp
|
||
if (!surfaceCache.TryGetValue(palCode, out var surf))
|
||
{
|
||
surf = TerrainBlending.BuildSurface(palCode, ctx);
|
||
surfaceCache[palCode] = surf;
|
||
}
|
||
```
|
||
|
||
This is NOT atomic under contention. Two workers may both run `BuildSurface`
|
||
for the same palCode and the last write wins. Result is deterministic
|
||
(same inputs → same SurfaceInfo) so the race is benign. We accept it.
|
||
|
||
- [ ] **Step 3: At the cache-owner site, switch to `ConcurrentDictionary<uint, SurfaceInfo>`**
|
||
|
||
```csharp
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, SurfaceInfo> _surfaceCache = new();
|
||
```
|
||
|
||
Compiles unchanged because of the interface widening.
|
||
|
||
- [ ] **Step 4: Build + all tests pass**
|
||
|
||
Run: `dotnet build && dotnet test --no-build`
|
||
Expected: build succeeded; all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Terrain/LandblockMesh.cs <surface-cache-owner.cs>
|
||
git commit -m "refactor(A.5 T9): _surfaceCache → ConcurrentDictionary for off-thread mesh build"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Add DatCollection thread-safety lock
|
||
|
||
**Files:**
|
||
- Modify: wherever `DatCollection` is owned + accessed (likely `GameWindow.cs` and various spawn handlers).
|
||
|
||
**Background:** Per `LandblockStreamer.cs:18-27` comments, `DatCollection`
|
||
is not thread-safe. A.5 needs the worker to call `_dats.Get<LandBlock>` /
|
||
`_dats.Get<LandBlockInfo>` concurrently with the render thread's other
|
||
dat reads (entity spawn, particle effects, animation sequencer).
|
||
|
||
**Mitigation:** Wrap `DatCollection` accesses in a `lock` so reads
|
||
serialize. Lock contention is minimal in practice.
|
||
|
||
- [ ] **Step 1: Locate DatCollection access sites**
|
||
|
||
Run: `Grep "_dats\.Get|DatCollection\." --include "*.cs" src/AcDream.App src/AcDream.Core` from worktree root.
|
||
|
||
- [ ] **Step 2: Add `_datsLock` field next to the DatCollection field**
|
||
|
||
```csharp
|
||
private readonly object _datsLock = new();
|
||
```
|
||
|
||
- [ ] **Step 3: Wrap each `_dats.Get<T>(...)` access in the lock**
|
||
|
||
Two patterns acceptable:
|
||
|
||
(a) Inline lock at each call site:
|
||
|
||
```csharp
|
||
LandBlock? block;
|
||
lock (_datsLock) { block = _dats.Get<LandBlock>(id); }
|
||
```
|
||
|
||
(b) Helper method:
|
||
|
||
```csharp
|
||
private T? GetDat<T>(uint id) where T : class
|
||
{
|
||
lock (_datsLock) { return _dats.Get<T>(id); }
|
||
}
|
||
```
|
||
|
||
Pattern (b) is cleaner but requires touching every call site. Pattern (a)
|
||
is faster to apply. Either is acceptable.
|
||
|
||
For the streamer factory specifically (where worker thread does dat reads),
|
||
the lock MUST be held — see Task 13 wiring.
|
||
|
||
- [ ] **Step 4: Build + all tests pass**
|
||
|
||
Run: `dotnet build && dotnet test --no-build`
|
||
Expected: build succeeded; all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add <modified-files>
|
||
git commit -m "fix(A.5 T10): serialize DatCollection access via lock for off-thread streaming"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Activate `LandblockStreamer` worker thread
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
|
||
|
||
**Background:** `WorkerLoop` exists but `Start()` is a no-op (synchronous mode).
|
||
A.5 activates the worker.
|
||
|
||
- [ ] **Step 1: Activate the worker thread in `Start()`**
|
||
|
||
Replace `Start()`:
|
||
|
||
```csharp
|
||
public void Start()
|
||
{
|
||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||
if (_worker != null) return;
|
||
_worker = new Thread(WorkerLoop)
|
||
{
|
||
IsBackground = true,
|
||
Name = "acdream.streaming.worker",
|
||
};
|
||
_worker.Start();
|
||
}
|
||
```
|
||
|
||
Remove the `#pragma warning disable CS0649` around `_worker` since it's
|
||
now assigned.
|
||
|
||
- [ ] **Step 2: Make enqueue methods non-blocking — write to inbox channel**
|
||
|
||
Replace:
|
||
|
||
```csharp
|
||
public void EnqueueLoad(uint landblockId)
|
||
{
|
||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||
HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear));
|
||
}
|
||
```
|
||
|
||
with:
|
||
|
||
```csharp
|
||
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind)
|
||
{
|
||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
|
||
}
|
||
|
||
public void EnqueueUnload(uint landblockId)
|
||
{
|
||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update existing call sites to pass `JobKind`**
|
||
|
||
Run: `Grep "\.EnqueueLoad\(" --include "*.cs"` from worktree root.
|
||
|
||
For each, update to pass an appropriate `LandblockStreamJobKind`. Tests
|
||
that don't care can pass `LandblockStreamJobKind.LoadNear` (today's behavior).
|
||
|
||
- [ ] **Step 4: Build + run streaming tests**
|
||
|
||
Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~Streaming"`
|
||
Expected: build succeeded; all streaming tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/LandblockStreamer.cs <call-sites>
|
||
git commit -m "feat(A.5 T11): activate LandblockStreamer worker thread; EnqueueLoad takes JobKind"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Inject mesh-build dependency into `LandblockStreamer`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs`
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the construction site)
|
||
|
||
- [ ] **Step 1: Add `_buildMeshOrNull` constructor param + field**
|
||
|
||
In `LandblockStreamer.cs`:
|
||
|
||
```csharp
|
||
private readonly Func<uint, LandblockMeshData?> _buildMeshOrNull;
|
||
|
||
public LandblockStreamer(
|
||
Func<uint, LoadedLandblock?> loadLandblock,
|
||
Func<uint, LandblockMeshData?> buildMeshOrNull)
|
||
{
|
||
_loadLandblock = loadLandblock;
|
||
_buildMeshOrNull = buildMeshOrNull;
|
||
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
|
||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
|
||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `HandleJob` to build mesh + post `Loaded` with Tier + MeshData**
|
||
|
||
```csharp
|
||
case LandblockStreamJob.Load load:
|
||
try
|
||
{
|
||
var lb = _loadLandblock(load.LandblockId);
|
||
if (lb is null)
|
||
{
|
||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||
load.LandblockId, "LandblockLoader.Load returned null"));
|
||
break;
|
||
}
|
||
var mesh = _buildMeshOrNull(load.LandblockId);
|
||
if (mesh is null)
|
||
{
|
||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||
load.LandblockId, "LandblockMesh.Build returned null"));
|
||
break;
|
||
}
|
||
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
||
? LandblockStreamTier.Far : LandblockStreamTier.Near;
|
||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||
load.LandblockId, tier, lb, mesh));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||
load.LandblockId, ex.ToString()));
|
||
}
|
||
break;
|
||
```
|
||
|
||
The `LoadFar` fast path (skip `LandBlockInfo` read) is OK to defer — the
|
||
worker still reads everything for now; the render-thread routing in Task 14
|
||
filters far-tier entities out anyway. Performance optimization for fast-path
|
||
goes in a follow-up task or N.6.
|
||
|
||
- [ ] **Step 3: Wire mesh-build factory at `LandblockStreamer` construction in `GameWindow`**
|
||
|
||
In `GameWindow.cs`, locate the `_streamer = new LandblockStreamer(...)` line.
|
||
Update:
|
||
|
||
```csharp
|
||
_streamer = new LandblockStreamer(
|
||
loadLandblock: id =>
|
||
{
|
||
lock (_datsLock) { return LandblockLoader.Load(_dats, id); }
|
||
},
|
||
buildMeshOrNull: id =>
|
||
{
|
||
LandBlock? block;
|
||
lock (_datsLock) { block = _dats.Get<LandBlock>(id); }
|
||
if (block is null) return null;
|
||
uint lbX = (id >> 24) & 0xFFu;
|
||
uint lbY = (id >> 16) & 0xFFu;
|
||
// _heightTable, _terrainCtx, _surfaceCache populated at startup
|
||
return LandblockMesh.Build(block, lbX, lbY, _heightTable, _terrainCtx, _surfaceCache);
|
||
});
|
||
```
|
||
|
||
`_surfaceCache` is now `ConcurrentDictionary` (Task 9).
|
||
|
||
After construction, call `_streamer.Start()` (Task 11 activated this).
|
||
|
||
- [ ] **Step 4: Build + run streaming tests**
|
||
|
||
Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~Streaming"`
|
||
Expected: build succeeded; tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/LandblockStreamer.cs src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "feat(A.5 T12): inject mesh-build dependency into LandblockStreamer"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `StreamingController` two-tier `Tick` + `applyTerrain` accepts MeshData
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/StreamingController.cs`
|
||
- Create: `tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs`
|
||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||
|
||
- [ ] **Step 1: Stub the new GpuWorldState methods**
|
||
|
||
In `GpuWorldState.cs`, add stubs (Task 14 implements):
|
||
|
||
```csharp
|
||
public void RemoveEntitiesFromLandblock(uint landblockId)
|
||
{
|
||
throw new System.NotImplementedException("A.5 T14");
|
||
}
|
||
|
||
public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList<WorldEntity> entities)
|
||
{
|
||
throw new System.NotImplementedException("A.5 T14");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `StreamingController` for two-tier**
|
||
|
||
Replace the existing constructor and `Tick`:
|
||
|
||
```csharp
|
||
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
|
||
private readonly Action<uint> _enqueueUnload;
|
||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||
private readonly Action<uint>? _removeTerrain;
|
||
private readonly GpuWorldState _state;
|
||
private StreamingRegion? _region;
|
||
|
||
public int NearRadius { get; set; }
|
||
public int FarRadius { get; set; }
|
||
public int MaxCompletionsPerFrame { get; set; } = 4;
|
||
|
||
public StreamingController(
|
||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||
Action<uint> enqueueUnload,
|
||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
|
||
GpuWorldState state,
|
||
int nearRadius,
|
||
int farRadius,
|
||
Action<uint>? removeTerrain = null)
|
||
{
|
||
_enqueueLoad = enqueueLoad;
|
||
_enqueueUnload = enqueueUnload;
|
||
_drainCompletions = drainCompletions;
|
||
_applyTerrain = applyTerrain;
|
||
_removeTerrain = removeTerrain;
|
||
_state = state;
|
||
NearRadius = nearRadius;
|
||
FarRadius = farRadius;
|
||
}
|
||
|
||
public void Tick(int observerCx, int observerCy)
|
||
{
|
||
if (_region is null)
|
||
{
|
||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||
var bootstrap = _region.ComputeFirstTickDiff();
|
||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
_region.MarkResidentFromBootstrap();
|
||
}
|
||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||
{
|
||
var diff = _region.RecenterTo(observerCx, observerCy);
|
||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||
}
|
||
|
||
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
||
foreach (var result in drained)
|
||
{
|
||
switch (result)
|
||
{
|
||
case LandblockStreamResult.Loaded loaded:
|
||
_applyTerrain(loaded.Landblock, loaded.MeshData);
|
||
_state.AddLandblock(loaded.Landblock);
|
||
break;
|
||
case LandblockStreamResult.Promoted promoted:
|
||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||
break;
|
||
case LandblockStreamResult.Unloaded unloaded:
|
||
_state.RemoveLandblock(unloaded.LandblockId);
|
||
_removeTerrain?.Invoke(unloaded.LandblockId);
|
||
break;
|
||
case LandblockStreamResult.Failed failed:
|
||
System.Console.WriteLine(
|
||
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
|
||
break;
|
||
case LandblockStreamResult.WorkerCrashed crashed:
|
||
System.Console.WriteLine(
|
||
$"streaming: worker CRASHED: {crashed.Error}");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing test (first-tick bootstrap)**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using AcDream.App.Streaming;
|
||
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 completions = new List<LandblockStreamResult>();
|
||
var state = new GpuWorldState();
|
||
|
||
var ctrl = new StreamingController(
|
||
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||
enqueueUnload: unloads.Add,
|
||
drainCompletions: _ => completions,
|
||
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);
|
||
Assert.Equal(40, farCount);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build + run new test**
|
||
|
||
Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~StreamingControllerTwoTierTests"`
|
||
Expected: build succeeded; new test PASS. Existing single-radius `StreamingControllerTests`
|
||
will fail compile — fix in Task 16.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs src/AcDream.App/Streaming/StreamingController.cs src/AcDream.App/Streaming/GpuWorldState.cs
|
||
git commit -m "feat(A.5 T13): StreamingController two-tier Tick + first-tick bootstrap"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Implement `GpuWorldState.RemoveEntitiesFromLandblock` + `AddEntitiesToExistingLandblock`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||
- Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```csharp
|
||
using System.Linq;
|
||
using AcDream.App.Streaming;
|
||
using AcDream.Core.World;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Streaming;
|
||
|
||
public class GpuWorldStateTwoTierTests
|
||
{
|
||
[Fact]
|
||
public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
|
||
{
|
||
var state = new GpuWorldState();
|
||
var lb = new LoadedLandblock(0xAAAA_FFFF, Heightmap: null!,
|
||
Entities: new[]
|
||
{
|
||
new WorldEntity { Id = 1, MeshRefs = System.Array.Empty<MeshRef>() },
|
||
new WorldEntity { Id = 2, MeshRefs = System.Array.Empty<MeshRef>() },
|
||
});
|
||
state.AddLandblock(lb);
|
||
Assert.Equal(2, state.Entities.Count);
|
||
|
||
state.RemoveEntitiesFromLandblock(0xAAAA_FFFF);
|
||
|
||
Assert.Empty(state.Entities);
|
||
Assert.True(state.IsLoaded(0xAAAA_FFFF));
|
||
}
|
||
|
||
[Fact]
|
||
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
|
||
{
|
||
var state = new GpuWorldState();
|
||
var lb = new LoadedLandblock(0xAAAA_FFFF, Heightmap: null!,
|
||
Entities: new[] { new WorldEntity { Id = 1, MeshRefs = System.Array.Empty<MeshRef>() } });
|
||
state.AddLandblock(lb);
|
||
|
||
state.AddEntitiesToExistingLandblock(0xAAAA_FFFF, new[]
|
||
{
|
||
new WorldEntity { Id = 2, MeshRefs = System.Array.Empty<MeshRef>() },
|
||
new WorldEntity { Id = 3, MeshRefs = System.Array.Empty<MeshRef>() },
|
||
});
|
||
|
||
Assert.Equal(3, state.Entities.Count);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — verify fail**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~GpuWorldStateTwoTierTests"`
|
||
Expected: FAIL with `NotImplementedException`.
|
||
|
||
- [ ] **Step 3: Implement the methods**
|
||
|
||
Replace the stubs:
|
||
|
||
```csharp
|
||
public void RemoveEntitiesFromLandblock(uint landblockId)
|
||
{
|
||
if (!_loaded.TryGetValue(landblockId, out var lb)) return;
|
||
if (_wbSpawnAdapter is not null)
|
||
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
||
_loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
|
||
_pendingByLandblock.Remove(landblockId);
|
||
RebuildFlatView();
|
||
}
|
||
|
||
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
|
||
{
|
||
if (!_loaded.TryGetValue(landblockId, out var lb))
|
||
{
|
||
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
|
||
if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket))
|
||
{
|
||
bucket = new List<WorldEntity>();
|
||
_pendingByLandblock[landblockId] = bucket;
|
||
}
|
||
bucket.AddRange(entities);
|
||
return;
|
||
}
|
||
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
|
||
merged.AddRange(lb.Entities);
|
||
merged.AddRange(entities);
|
||
_loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
|
||
if (_wbSpawnAdapter is not null)
|
||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]);
|
||
RebuildFlatView();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — verify pass**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~GpuWorldStateTwoTierTests"`
|
||
Expected: PASS, 2 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs src/AcDream.App/Streaming/GpuWorldState.cs
|
||
git commit -m "feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Add `TerrainModernRenderer.AddLandblockWithMesh` (prebuilt mesh entry point)
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
|
||
|
||
- [ ] **Step 1: Refactor existing `AddLandblock` to delegate to `AddLandblockInternal`**
|
||
|
||
Today's `AddLandblock(LoadedLandblock lb)` builds the mesh and adds it.
|
||
Refactor:
|
||
|
||
```csharp
|
||
public void AddLandblock(LoadedLandblock lb)
|
||
{
|
||
// Legacy synchronous path — fallback for callers not yet migrated.
|
||
var meshData = LandblockMesh.Build(
|
||
lb.Heightmap, /* lbX, lbY from id */, _heightTable, _terrainCtx, _surfaceCache);
|
||
AddLandblockInternal(lb, meshData);
|
||
}
|
||
|
||
public void AddLandblockWithMesh(LoadedLandblock lb, LandblockMeshData meshData)
|
||
{
|
||
AddLandblockInternal(lb, meshData);
|
||
}
|
||
|
||
private void AddLandblockInternal(LoadedLandblock lb, LandblockMeshData meshData)
|
||
{
|
||
// ... existing AddLandblock body, but using the passed meshData instead
|
||
// of building it inline.
|
||
}
|
||
```
|
||
|
||
If `AddLandblock` doesn't build mesh inline today (e.g., if mesh is built
|
||
elsewhere and stored on `LoadedLandblock`), the refactor is simpler:
|
||
just add `AddLandblockWithMesh(lb, meshData)` as a new entry point that
|
||
takes the mesh externally.
|
||
|
||
- [ ] **Step 2: Build verify**
|
||
|
||
Run: `dotnet build`
|
||
Expected: build succeeded.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/TerrainModernRenderer.cs
|
||
git commit -m "refactor(A.5 T15): TerrainModernRenderer.AddLandblockWithMesh prebuilt-mesh entry"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Update existing single-radius `StreamingController` tests + wire two-tier into `GameWindow`
|
||
|
||
**Files:**
|
||
- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs`
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||
|
||
- [ ] **Step 1: Run existing tests to identify failures**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingControllerTests"`
|
||
Expected: compile errors / failures pointing at the old constructor signature.
|
||
|
||
- [ ] **Step 2: Update each existing test**
|
||
|
||
Replace `radius: N` with `nearRadius: N, farRadius: N`. Replace
|
||
`enqueueLoad: id => ...` with `enqueueLoad: (id, _) => ...` (ignore tier
|
||
in tests that don't care). Replace `applyTerrain: lb => ...` with
|
||
`applyTerrain: (lb, _) => ...`.
|
||
|
||
For tests asserting on the original `RegionDiff`-shaped behavior, port
|
||
to the `TwoTierDiff` shape. Asserts on `ToLoad` move to `ToLoadNear`
|
||
when `nearRadius == farRadius` (single-tier behavior).
|
||
|
||
- [ ] **Step 3: Wire two-tier into `GameWindow.cs`**
|
||
|
||
Locate `StreamingController` construction. Replace with:
|
||
|
||
```csharp
|
||
int nearRadius = ParseEnvInt("ACDREAM_NEAR_RADIUS", defaultValue: 4);
|
||
int farRadius = ParseEnvInt("ACDREAM_FAR_RADIUS", defaultValue: 12);
|
||
|
||
// Backward-compat: if ACDREAM_STREAM_RADIUS is set, treat it as nearRadius
|
||
// and infer farRadius = max(streamRadius, default farRadius).
|
||
int streamRadius = ParseEnvInt("ACDREAM_STREAM_RADIUS", defaultValue: -1);
|
||
if (streamRadius > 0)
|
||
{
|
||
nearRadius = streamRadius;
|
||
farRadius = System.Math.Max(streamRadius, farRadius);
|
||
}
|
||
|
||
_streamingController = new StreamingController(
|
||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
||
enqueueUnload: id => _streamer.EnqueueUnload(id),
|
||
drainCompletions: max => _streamer.DrainCompletions(max),
|
||
applyTerrain: (lb, mesh) => _terrainModernRenderer.AddLandblockWithMesh(lb, mesh),
|
||
state: _gpuWorldState,
|
||
nearRadius: nearRadius,
|
||
farRadius: farRadius,
|
||
removeTerrain: id => _terrainModernRenderer.RemoveLandblock(id));
|
||
```
|
||
|
||
If `ParseEnvInt` doesn't exist, locate the existing pattern for env-var int
|
||
parsing and reuse, or add a small helper.
|
||
|
||
- [ ] **Step 4: Build + all tests pass**
|
||
|
||
Run: `dotnet build && dotnet test --no-build`
|
||
Expected: build succeeded; all tests pass.
|
||
|
||
- [ ] **Step 5: Visual gate — launch and verify no regressions**
|
||
|
||
Build the App project; the user launches the client (per CLAUDE.md
|
||
launch flow) and verifies:
|
||
- World renders at default radii (N₁=4, N₂=12).
|
||
- No crashes during streaming.
|
||
- Player movement works.
|
||
|
||
If anything regresses, halt and debug.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "feat(A.5 T16): wire two-tier streaming into GameWindow + port existing tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Test + implement entity bucketing Change #1 — animated-entity walk fix
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||
- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs`
|
||
|
||
- [ ] **Step 1: Extract pure-CPU `WalkEntities` helper**
|
||
|
||
In `WbDrawDispatcher.cs`, extract a testable helper:
|
||
|
||
```csharp
|
||
internal struct WalkResult
|
||
{
|
||
public int EntitiesWalked;
|
||
public List<(WorldEntity Entity, MeshRef MeshRef)> ToDraw;
|
||
}
|
||
|
||
internal static WalkResult WalkEntities(
|
||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||
IReadOnlyList<WorldEntity> Entities,
|
||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
|
||
FrustumPlanes? frustum,
|
||
uint? neverCullLandblockId,
|
||
HashSet<uint>? visibleCellIds,
|
||
HashSet<uint>? animatedEntityIds)
|
||
{
|
||
var result = new WalkResult { ToDraw = new() };
|
||
foreach (var entry in landblockEntries)
|
||
{
|
||
bool landblockVisible = frustum is null
|
||
|| entry.LandblockId == neverCullLandblockId
|
||
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
|
||
|
||
if (!landblockVisible)
|
||
{
|
||
// A.5 T17 Change #1: walk only animated entities, not all entities.
|
||
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
|
||
if (entry.AnimatedById is null) continue;
|
||
foreach (var animatedId in animatedEntityIds)
|
||
{
|
||
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
||
if (entity.MeshRefs.Count == 0) continue;
|
||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
|
||
result.EntitiesWalked++;
|
||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||
result.ToDraw.Add((entity, entity.MeshRefs[i]));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
foreach (var entity in entry.Entities)
|
||
{
|
||
result.EntitiesWalked++;
|
||
if (entity.MeshRefs.Count == 0) continue;
|
||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||
continue;
|
||
// Per-entity AABB cull (uses cached AABB after Task 18 lands).
|
||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||
{
|
||
var p = entity.Position;
|
||
var aMin = new Vector3(p.X - 5f, p.Y - 5f, p.Z - 5f);
|
||
var aMax = new Vector3(p.X + 5f, p.Y + 5f, p.Z + 5f);
|
||
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) continue;
|
||
}
|
||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||
result.ToDraw.Add((entity, entity.MeshRefs[i]));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `WbDrawDispatcher.Draw` to use `WalkEntities`**
|
||
|
||
Replace the inline walk in `Draw` (lines ~191-288) with a call to
|
||
`WalkEntities`, then build groups from the result. The classify+upload+
|
||
indirect-draw phases remain unchanged.
|
||
|
||
The signature of `Draw`'s `landblockEntries` parameter changes to include
|
||
`AnimatedById`. Adjust the call site in `GameWindow.cs` accordingly.
|
||
|
||
- [ ] **Step 3: Update `GpuWorldState.LandblockEntries` to yield `AnimatedById`**
|
||
|
||
In `GpuWorldState.cs`, modify `LandblockEntries` to compute and yield
|
||
`AnimatedById`:
|
||
|
||
```csharp
|
||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||
IReadOnlyList<WorldEntity> Entities,
|
||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
||
{
|
||
get
|
||
{
|
||
foreach (var kvp in _loaded)
|
||
{
|
||
// Build AnimatedById on the fly. Cheap (~132 entities/LB max).
|
||
// A.5 follow-up could cache this per-AddLandblock if profiling shows hot.
|
||
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
|
||
foreach (var e in kvp.Value.Entities)
|
||
byId[e.Id] = e;
|
||
|
||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
|
||
else
|
||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the test**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.World;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public class WbDrawDispatcherBucketingTests
|
||
{
|
||
[Fact]
|
||
public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
|
||
{
|
||
var entities = new List<WorldEntity>();
|
||
for (int i = 0; i < 1000; i++)
|
||
entities.Add(new WorldEntity { Id = (uint)i, MeshRefs = System.Array.Empty<MeshRef>() });
|
||
|
||
var animatedById = new Dictionary<uint, WorldEntity> { [42] = entities[42] };
|
||
var animatedSet = new HashSet<uint> { 42 };
|
||
|
||
// Construct an "always-fail" frustum: 6 planes pointing inward at the origin
|
||
// with the LB AABB far away from the origin → IsAabbVisible returns false.
|
||
var frustum = MakeAllFailFrustum();
|
||
var entries = new[]
|
||
{
|
||
(LandblockId: 0xAAAA_FFFFu,
|
||
AabbMin: new Vector3(10000, 10000, 10000),
|
||
AabbMax: new Vector3(20000, 20000, 20000),
|
||
Entities: (IReadOnlyList<WorldEntity>)entities,
|
||
AnimatedById: (IReadOnlyDictionary<uint, WorldEntity>?)animatedById),
|
||
};
|
||
|
||
var result = WbDrawDispatcher.WalkEntities(
|
||
entries, frustum, neverCullLandblockId: null,
|
||
visibleCellIds: null, animatedEntityIds: animatedSet);
|
||
|
||
Assert.Equal(1, result.EntitiesWalked);
|
||
}
|
||
|
||
private static FrustumPlanes MakeAllFailFrustum()
|
||
{
|
||
// Six planes at origin pointing inward — entities at (10000,...) fail all of them.
|
||
return new FrustumPlanes(
|
||
Left: new Vector4(1, 0, 0, 0),
|
||
Right: new Vector4(-1, 0, 0, 0),
|
||
Bottom: new Vector4(0, 1, 0, 0),
|
||
Top: new Vector4(0, -1, 0, 0),
|
||
Near: new Vector4(0, 0, 1, 0),
|
||
Far: new Vector4(0, 0, -1, 0));
|
||
}
|
||
}
|
||
```
|
||
|
||
If `FrustumPlanes` constructor signature differs, adapt the helper.
|
||
|
||
- [ ] **Step 5: Build + run test**
|
||
|
||
Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~WbDrawDispatcherBucketingTests"`
|
||
Expected: build succeeded; test PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Streaming/GpuWorldState.cs
|
||
git commit -m "feat(A.5 T17): WbDrawDispatcher Change #1 — animated-entity walk fix + WalkEntities extraction"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Use cached AABB in `WbDrawDispatcher.WalkEntities` + populate at register time
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||
- Modify: `src/AcDream.Core/World/LandblockLoader.cs`
|
||
- Modify: `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`
|
||
|
||
- [ ] **Step 1: Populate AABB at `LandblockLoader.BuildEntitiesFromInfo`**
|
||
|
||
In `LandblockLoader.cs`, modify the entity construction inside both `foreach`
|
||
loops to call `RefreshAabb()`:
|
||
|
||
```csharp
|
||
foreach (var stab in info.Objects)
|
||
{
|
||
if (!IsSupported(stab.Id)) continue;
|
||
var entity = new WorldEntity
|
||
{
|
||
Id = nextId++,
|
||
SourceGfxObjOrSetupId = stab.Id,
|
||
Position = stab.Frame.Origin,
|
||
Rotation = stab.Frame.Orientation,
|
||
MeshRefs = Array.Empty<MeshRef>(),
|
||
};
|
||
entity.RefreshAabb();
|
||
result.Add(entity);
|
||
}
|
||
|
||
// Same pattern for the buildings loop.
|
||
```
|
||
|
||
- [ ] **Step 2: Populate AABB at `EntitySpawnAdapter.OnCreate`**
|
||
|
||
In `EntitySpawnAdapter.cs`, find `OnCreate(WorldEntity entity)` and add
|
||
`entity.RefreshAabb();` after the entity's fields are populated (before
|
||
the per-instance state setup).
|
||
|
||
- [ ] **Step 3: Update dynamic-entity position-change paths**
|
||
|
||
Run: `Grep -n "\.Position\s*=" --include "*.cs" src/AcDream.App src/AcDream.Core` from worktree root.
|
||
|
||
For each non-init-context assignment (i.e., not inside an object-initializer
|
||
`new WorldEntity { Position = ... }`), replace with `entity.SetPosition(newPos)`.
|
||
Common sites: live position update handler, animation tick, movement controller.
|
||
|
||
- [ ] **Step 4: Use cached AABB in `WalkEntities`**
|
||
|
||
In `WbDrawDispatcher.WalkEntities`, replace the per-frame AABB recompute:
|
||
|
||
```csharp
|
||
// OLD:
|
||
var p = entity.Position;
|
||
var aMin = new Vector3(p.X - 5f, p.Y - 5f, p.Z - 5f);
|
||
var aMax = new Vector3(p.X + 5f, p.Y + 5f, p.Z + 5f);
|
||
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) continue;
|
||
|
||
// NEW:
|
||
if (entity.AabbDirty) entity.RefreshAabb();
|
||
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) continue;
|
||
```
|
||
|
||
- [ ] **Step 5: Build + all tests pass**
|
||
|
||
Run: `dotnet build && dotnet test --no-build`
|
||
Expected: build succeeded; all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.Core/World/LandblockLoader.cs src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
|
||
git commit -m "feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Mipmaps + 16x anisotropic on `TerrainAtlas`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs`
|
||
|
||
- [ ] **Step 1: Generate mipmaps after atlas upload + set sampler params**
|
||
|
||
Locate the atlas upload code in `TerrainAtlas.cs` (the `Upload` method).
|
||
After the `glTexImage*` / `glTexSubImage*` calls, add:
|
||
|
||
```csharp
|
||
_gl.GenerateMipmap(TextureTarget.Texture2DArray);
|
||
|
||
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter,
|
||
(int)TextureMinFilter.LinearMipmapLinear);
|
||
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter,
|
||
(int)TextureMagFilter.Linear);
|
||
|
||
// Anisotropic 16x via GL_EXT/ARB_texture_filter_anisotropic.
|
||
const TextureParameterName GL_TEXTURE_MAX_ANISOTROPY = (TextureParameterName)0x84FE;
|
||
_gl.TexParameter(TextureTarget.Texture2DArray, GL_TEXTURE_MAX_ANISOTROPY, 16.0f);
|
||
```
|
||
|
||
If `TextureMinFilter.LinearMipmapLinear` isn't in the Silk.NET enum, cast
|
||
the int value `(int)0x2703`.
|
||
|
||
- [ ] **Step 2: Build verify**
|
||
|
||
Run: `dotnet build`
|
||
Expected: build succeeded.
|
||
|
||
- [ ] **Step 3: Visual gate — launch + verify**
|
||
|
||
User launches the client. Walk to a vantage point looking at terrain at ~2km.
|
||
Before this change: distant terrain shimmers (moving sparkles).
|
||
After: smooth.
|
||
|
||
If shimmer persists, verify the bindless atlas handles in `terrain_modern.frag`
|
||
sample with mipmaps (the shader uses `texture(...)` which respects sampler
|
||
state automatically).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/TerrainAtlas.cs
|
||
git commit -m "feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: A2C with MSAA on foliage shader
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (GL context creation)
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (enable A2C around opaque pass)
|
||
- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
|
||
|
||
- [ ] **Step 1: Audit MSAA framebuffer compatibility**
|
||
|
||
Run: `Grep "Framebuffer|RenderTarget|ClearColor|BindFramebuffer" --include "*.cs" src/AcDream.App/Rendering` from worktree root.
|
||
|
||
Inspect each path for default-framebuffer assumptions:
|
||
- Sky pass: expected to write to default framebuffer; should work under MSAA automatically.
|
||
- Particle pass: alpha-blend billboards; MSAA-friendly.
|
||
- ImGui overlay: drawn after 3D pass via `ImGuiPanelRenderer`; should be after MSAA resolve.
|
||
- Any offscreen FBO usage: verify resolves correctly to the MSAA default framebuffer.
|
||
|
||
If audit finds blocking issues, defer Task 20 (per spec §10 Risk #2 fallback)
|
||
and ship Tasks 19 + 21 only. Document the result.
|
||
|
||
If audit clean, proceed.
|
||
|
||
- [ ] **Step 2: Enable MSAA 4x on the GL context**
|
||
|
||
In `GameWindow.cs`, find the `WindowOptions` setup. Add MSAA samples:
|
||
|
||
```csharp
|
||
var opts = WindowOptions.Default with { Samples = 4 }; // MSAA 4x
|
||
```
|
||
|
||
Or set via the existing `opts.Samples = 4` field assignment if that's the
|
||
pattern.
|
||
|
||
- [ ] **Step 3: Enable `GL_SAMPLE_ALPHA_TO_COVERAGE` around the opaque pass**
|
||
|
||
In `WbDrawDispatcher.Draw`, around the opaque pass (line ~400):
|
||
|
||
```csharp
|
||
if (_opaqueDrawCount > 0)
|
||
{
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.DepthMask(true);
|
||
_gl.Enable(EnableCap.SampleAlphaToCoverage); // A.5 T20 — A2C for ClipMap foliage
|
||
_shader.SetInt("uRenderPass", 0);
|
||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||
_gl.MultiDrawElementsIndirect(...); // existing call
|
||
_gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||
}
|
||
```
|
||
|
||
A2C is no-op on fully-opaque alpha (≥1.0), so non-foliage opaque batches
|
||
are visually unaffected.
|
||
|
||
- [ ] **Step 4: Update `mesh_modern.frag` for A2C-friendly output**
|
||
|
||
Find the ClipMap branch. Replace:
|
||
|
||
```glsl
|
||
if (texColor.a < 0.5) discard;
|
||
outColor = vec4(texColor.rgb, 1.0);
|
||
```
|
||
|
||
with:
|
||
|
||
```glsl
|
||
// A.5 T20 — A2C: pass alpha through so GL_SAMPLE_ALPHA_TO_COVERAGE
|
||
// derives sample mask from coverage.
|
||
if (texColor.a < 0.05) discard;
|
||
outColor = vec4(texColor.rgb, texColor.a);
|
||
```
|
||
|
||
- [ ] **Step 5: Build + visual gate**
|
||
|
||
Run: `dotnet build`
|
||
Visual gate: user launches client. Foliage edges should appear smoother
|
||
(multi-sampled). Verify sky / particles / ImGui still render correctly.
|
||
|
||
If anything broken (sky cleared wrong, particles flicker, ImGui glitches),
|
||
roll back via `git revert` and ship without A2C (Tasks 19 + 21 only).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/Shaders/mesh_modern.frag
|
||
git commit -m "feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Depth-write audit + lock-in test
|
||
|
||
**Files:**
|
||
- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs`
|
||
|
||
- [ ] **Step 1: Audit `WbDrawDispatcher.Draw` depth-write state**
|
||
|
||
Read lines ~400-435 of `WbDrawDispatcher.cs`. Confirm:
|
||
- Opaque pass: `_gl.DepthMask(true)` ✓
|
||
- Transparent pass: `_gl.DepthMask(false)` ✓
|
||
- After transparent: `_gl.DepthMask(true)` to restore ✓
|
||
|
||
If any inconsistency, fix in same task.
|
||
|
||
- [ ] **Step 2: Write the lock-in test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Meshing;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public class WbDispatcherDepthMaskTests
|
||
{
|
||
[Theory]
|
||
[InlineData(TranslucencyKind.Opaque, true)] // opaque pass — depth write
|
||
[InlineData(TranslucencyKind.ClipMap, true)] // foliage — depth write (binary alpha)
|
||
[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);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test — verify passes**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~WbDispatcherDepthMaskTests"`
|
||
Expected: PASS, 5 cases.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs
|
||
git commit -m "test(A.5 T21): lock in depth-write attribution per translucency kind"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: Wire fog params from N₁/N₂ + env-var multipliers
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (or wherever `SceneLightingUbo` is updated per frame)
|
||
|
||
- [ ] **Step 1: Locate `SceneLightingUbo` update site**
|
||
|
||
Run: `Grep "FogStart|FogEnd" --include "*.cs" src/AcDream.App` from worktree root.
|
||
|
||
- [ ] **Step 2: Compute fog params from N₁/N₂ + env-var multipliers**
|
||
|
||
In the per-frame fog-update path:
|
||
|
||
```csharp
|
||
const float LandblockSize = 192.0f;
|
||
float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f);
|
||
float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f);
|
||
_sceneLighting.FogStart = _streamingController.NearRadius * LandblockSize * startMult;
|
||
_sceneLighting.FogEnd = _streamingController.FarRadius * LandblockSize * endMult;
|
||
// Fog color sourced from current sky state (existing path — unchanged).
|
||
```
|
||
|
||
If `ParseEnvFloat` doesn't exist:
|
||
|
||
```csharp
|
||
private static float ParseEnvFloat(string name, float defaultValue)
|
||
{
|
||
var s = System.Environment.GetEnvironmentVariable(name);
|
||
if (s is not null && float.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, out var v))
|
||
return v;
|
||
return defaultValue;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build + visual gate**
|
||
|
||
Run: `dotnet build`
|
||
Visual gate: user launches client. At default mults, distant terrain
|
||
fades into sky color between ~538m (near boundary + some fog ramp) and
|
||
~2188m (far boundary nearly fully opaque). The N₁ scenery boundary should
|
||
be visually masked.
|
||
|
||
If fog band is too thin / too thick, iterate on env-var mults without
|
||
rebuild.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add <modified-files>
|
||
git commit -m "feat(A.5 T22): fog params wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22.5 (NEW — Quality Preset System)
|
||
|
||
**Inserted between T22 (fog wiring) and T23 (DIAG budgets). Added mid-execution at user's direction. Estimate: ~1 day.**
|
||
|
||
**Background:** User added this task between T22 and T23 with a complete inline spec. Shipped as commits `afa4200` (schema + tests) and `28d2c60` (wiring). Design spec at §4.10 of the A.5 spec doc.
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.UI.Abstractions/Settings/QualityPreset.cs`
|
||
- Modify: `src/AcDream.UI.Abstractions/Settings/DisplaySettings.cs` (add `Quality` field)
|
||
- NOTE: `SettingsState.cs` (from the original inline spec) did not exist; `Quality` went onto `DisplaySettings` instead — the natural home for display-related settings.
|
||
- Modify: `src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs` (Display tab Quality dropdown)
|
||
- NOTE: the original inline spec named `DisplayTab.cs`; the actual file is `SettingsPanel.cs` with a `RenderDisplayTab` method. Same intent, different file name.
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (apply preset on launch + on mid-session change via `ReapplyQualityPreset`)
|
||
- Create: `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs`
|
||
|
||
**Schema:**
|
||
|
||
```csharp
|
||
public enum QualityPreset { Low, Medium, High, Ultra }
|
||
|
||
public readonly record struct QualitySettings(
|
||
int NearRadius, int FarRadius,
|
||
int MsaaSamples, int AnisotropicLevel,
|
||
bool AlphaToCoverage,
|
||
int MaxCompletionsPerFrame);
|
||
```
|
||
|
||
`QualitySettings.From(preset)` returns canonical values per preset:
|
||
|
||
| Preset | NearRadius | FarRadius | MsaaSamples | AnisotropicLevel | AlphaToCoverage | MaxCompletionsPerFrame |
|
||
|---|---|---|---|---|---|---|
|
||
| Low | 2 | 5 | 0 | 4 | false | 2 |
|
||
| Medium | 3 | 8 | 2 | 8 | false | 3 |
|
||
| High | 4 | 12 | 4 | 16 | true | 4 |
|
||
| Ultra | 5 | 15 | 4 | 16 | true | 6 |
|
||
|
||
`QualitySettings.WithEnvOverrides(baseSettings)` applies per-field env-var overrides:
|
||
`ACDREAM_NEAR_RADIUS`, `ACDREAM_FAR_RADIUS`, `ACDREAM_MSAA_SAMPLES`,
|
||
`ACDREAM_ANISOTROPIC`, `ACDREAM_A2C`, `ACDREAM_MAX_COMPLETIONS_PER_FRAME`.
|
||
|
||
**Wiring:**
|
||
|
||
1. `DisplaySettings.Quality` persists via the existing `settings.json` infrastructure (Phase L.0).
|
||
2. `SettingsPanel.RenderDisplayTab` Combo widget for Quality dropdown.
|
||
3. `GameWindow.OnLoad` applies preset: streamer + controller built with preset's
|
||
`NearRadius`/`FarRadius`; `TerrainAtlas.SetAnisotropic` from preset; `WindowOptions.Samples`
|
||
from preset (window creation time only); `WbDrawDispatcher.AlphaToCoverage` from preset;
|
||
`StreamingController.MaxCompletionsPerFrame` from preset.
|
||
4. Env-var overrides applied per field via `WithEnvOverrides`; logged at startup.
|
||
5. Mid-session change via F11 → Quality dropdown → `ReapplyQualityPreset` rebuilds the
|
||
streaming pipeline. MSAA samples mid-session change is structurally unsupported
|
||
(OpenGL requires window recreation); logs a warning.
|
||
|
||
**Acceptance criteria (as shipped):**
|
||
|
||
- Standstill: at user's selected preset, 95% of frames hit ≤ (1000ms / monitor refresh).
|
||
- Walking: 95% ≤ 1.5× (1000ms / monitor refresh).
|
||
- Visual gate: same on all presets.
|
||
|
||
**Out of scope (deferred):**
|
||
|
||
- Auto-detect first-launch preset (Phase A.6 / N.6.5).
|
||
- Adaptive runtime preset drop on budget miss.
|
||
- Per-feature toggles below preset level.
|
||
|
||
**Commits:** `afa4200` (schema + tests), `28d2c60` (wiring).
|
||
|
||
---
|
||
|
||
## Task 23: Per-subsystem regression budget logging in DIAG output
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||
- Modify: `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
|
||
|
||
- [ ] **Step 1: Add budget threshold + flag in `WbDrawDispatcher.MaybeFlushDiag`**
|
||
|
||
Replace:
|
||
|
||
```csharp
|
||
Console.WriteLine(
|
||
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} ...");
|
||
```
|
||
|
||
with:
|
||
|
||
```csharp
|
||
const long BudgetUs = 2000;
|
||
string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : "";
|
||
Console.WriteLine(
|
||
$"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} ...");
|
||
```
|
||
|
||
Same pattern in `TerrainModernRenderer.MaybeFlushTerrainDiag` with
|
||
`BudgetUs = 1000`.
|
||
|
||
- [ ] **Step 2: Build verify**
|
||
|
||
Run: `dotnet build`
|
||
Expected: build succeeded.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/TerrainModernRenderer.cs
|
||
git commit -m "feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] + [TERRAIN-DIAG]"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 24: Capture before-baseline (radius=5 single-tier today)
|
||
|
||
**Files:**
|
||
- Create: `docs/plans/2026-05-09-phase-a5-perf-baseline.md`
|
||
|
||
- [ ] **Step 1: Build + launch in background with single-tier override**
|
||
|
||
```powershell
|
||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||
$env:ACDREAM_LIVE = "1"
|
||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||
$env:ACDREAM_TEST_PORT = "9000"
|
||
$env:ACDREAM_TEST_USER = "testaccount"
|
||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||
$env:ACDREAM_WB_DIAG = "1"
|
||
$env:ACDREAM_NEAR_RADIUS = "5"
|
||
$env:ACDREAM_FAR_RADIUS = "5" # collapse to single-tier for the baseline
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "before-radius5.log"
|
||
```
|
||
|
||
Run as `run_in_background: true`.
|
||
|
||
- [ ] **Step 2: User logs in `+Acdream` and stands at Holtburg dueling field 30s**
|
||
|
||
Then close the window.
|
||
|
||
- [ ] **Step 3: Read `[WB-DIAG]` from the log**
|
||
|
||
```powershell
|
||
Select-String -Path before-radius5.log -Pattern "\[WB-DIAG\]" | Select-Object -Last 5
|
||
Select-String -Path before-radius5.log -Pattern "\[TERRAIN-DIAG\]" | Select-Object -Last 5
|
||
```
|
||
|
||
Capture median + p95 cpu_us for each subsystem.
|
||
|
||
- [ ] **Step 4: Write the baseline doc**
|
||
|
||
```markdown
|
||
# Phase A.5 — perf baseline
|
||
|
||
## Before (radius=5 single-tier, today's behavior)
|
||
|
||
**Captured:** <date> at Holtburg dueling field, NearRadius=5, FarRadius=5,
|
||
30s standstill.
|
||
|
||
| Subsystem | cpu_us median | cpu_us p95 |
|
||
|---|---|---|
|
||
| Entity dispatcher | <fill> | <fill> |
|
||
| Terrain dispatcher | <fill> | <fill> |
|
||
|
||
Frame time: <fill> ms median.
|
||
Effective FPS: <fill>.
|
||
|
||
This is the "before" anchor. Task 25 captures the "after" comparison.
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-05-09-phase-a5-perf-baseline.md
|
||
git commit -m "docs(A.5 T24): perf baseline captured (before A.5)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 25: Capture after-baseline (full A.5: N₁=4 / N₂=12)
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-05-09-phase-a5-perf-baseline.md`
|
||
|
||
- [ ] **Step 1: Launch with default A.5 settings**
|
||
|
||
```powershell
|
||
# Same env vars as Task 24 minus ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS
|
||
# (uses defaults 4 / 12).
|
||
$env:ACDREAM_WB_DIAG = "1"
|
||
Remove-Item Env:ACDREAM_NEAR_RADIUS -ErrorAction SilentlyContinue
|
||
Remove-Item Env:ACDREAM_FAR_RADIUS -ErrorAction SilentlyContinue
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "after-default.log"
|
||
```
|
||
|
||
- [ ] **Step 2: Standstill 30s + walking trace 60s**
|
||
|
||
Standstill at Holtburg dueling field, then walk to North Yanshi.
|
||
|
||
- [ ] **Step 3: Append after numbers to baseline doc**
|
||
|
||
```markdown
|
||
## After (Phase A.5: N₁=4, N₂=12, full bucketing + threading + visual)
|
||
|
||
**Captured:** <date>, full A.5.
|
||
|
||
### Standstill (30s, Holtburg dueling field)
|
||
|
||
| Subsystem | cpu_us median | cpu_us p95 |
|
||
|---|---|---|
|
||
| Entity dispatcher | <fill> | <fill> |
|
||
| Terrain dispatcher | <fill> | <fill> |
|
||
|
||
Frame time: <fill> ms median, <fill> ms p99.
|
||
Effective FPS: <fill> median.
|
||
|
||
**Acceptance criterion 2 (median ≤ 4.166ms):** PASS / FAIL.
|
||
**Acceptance criterion 6 entity (≤ 2.0ms):** PASS / FAIL.
|
||
**Acceptance criterion 6 terrain (≤ 1.0ms):** PASS / FAIL.
|
||
|
||
### Walking trace (60s, Holtburg → North Yanshi at run speed)
|
||
|
||
| Subsystem | cpu_us median | cpu_us p95 |
|
||
|---|---|---|
|
||
| Entity dispatcher | <fill> | <fill> |
|
||
| Terrain dispatcher | <fill> | <fill> |
|
||
|
||
Frame time: <fill> ms median, <fill> ms p95.
|
||
Effective FPS: <fill> median.
|
||
|
||
**Acceptance criterion 3 (median ≥ 144 FPS):** PASS / FAIL.
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-05-09-phase-a5-perf-baseline.md
|
||
git commit -m "docs(A.5 T25): perf baseline captured (after A.5)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 26: Visual gate — user confirms acceptance criterion 5
|
||
|
||
**Files:** none (procedural)
|
||
|
||
- [ ] **Step 1: User walks Holtburg → North Yanshi at run speed**
|
||
|
||
User launches client at default settings. Walks the standard route. Confirms:
|
||
|
||
1. Horizon visible at ~2.3 km. ✓ / ✗
|
||
2. Fog blend at N₁ smooths the scenery boundary (no harsh cliff). ✓ / ✗
|
||
3. Distant terrain does not shimmer (mipmaps work). ✓ / ✗
|
||
4. Tree edges are smooth (A2C works, if shipped). ✓ / ✗
|
||
5. No new z-fighting / depth artifacts. ✓ / ✗
|
||
|
||
- [ ] **Step 2: Triage failures**
|
||
|
||
If any criterion fails, halt. Common failures + fixes:
|
||
|
||
| Symptom | Likely cause | Fix |
|
||
|---|---|---|
|
||
| Distant terrain shimmers | Mipmap step skipped or sampler params wrong | Re-verify Task 19; check `glGenerateMipmap` is being called and sampler uses `LinearMipmapLinear` |
|
||
| Tree edges still pixel-stepped | A2C not enabled | Verify `Enable(EnableCap.SampleAlphaToCoverage)` in opaque pass |
|
||
| Hard scenery cliff at N₁ | Fog band too thin | Lower `ACDREAM_FOG_START_MULT` (0.5), raise `ACDREAM_FOG_END_MULT` (1.0) |
|
||
| Far horizon too washed out | Fog band too thick | Raise `ACDREAM_FOG_START_MULT`, lower `ACDREAM_FOG_END_MULT` |
|
||
| FPS dips below 144 walking | Streaming hitch | Check `[WB-DIAG]` BUDGET_OVER flag during walk; investigate hot path |
|
||
|
||
If Bucketing Change #3 (sub-LB cell cull) is needed because Tasks 17+18
|
||
didn't hit the 2.0ms entity dispatcher budget, add Task 18.5 implementing
|
||
4×4 sub-LB cell cull per spec §4.6 Change #3.
|
||
|
||
- [ ] **Step 3: No commit (procedural)**
|
||
|
||
Visual gate result documented in Task 28 SHIP commit message.
|
||
|
||
---
|
||
|
||
## Task 27: Update roadmap, ISSUES, CLAUDE.md, memory
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-04-11-roadmap.md`
|
||
- Modify: `docs/ISSUES.md`
|
||
- Modify: `CLAUDE.md`
|
||
- Create: `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_phase_a5_state.md`
|
||
- Modify: `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\MEMORY.md`
|
||
|
||
- [ ] **Step 1: Add A.5 SHIPPED row to roadmap**
|
||
|
||
In `docs/plans/2026-04-11-roadmap.md` "Phases already shipped" table:
|
||
|
||
```markdown
|
||
| A.5 | Two-tier streaming + horizon LOD — N₁=4 (full detail, 81 LBs) + N₂=12 (terrain only, 544 LBs); fog blend at N₁; per-LB entity dispatcher walk tightened (Change #1 animated-walk fix + Change #2 cached AABB); single-worker off-thread mesh build; mipmaps + 16x anisotropic on TerrainAtlas; A2C with MSAA 4x on foliage; depth-write audit + lock-in test. Acceptance: <fill from baseline>. Spec at `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`. Plan archived at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`. | Live ✓ |
|
||
```
|
||
|
||
Move A.5 from "Phases ahead" to shipped.
|
||
|
||
Update "Currently in flight" pointer:
|
||
```markdown
|
||
**Currently in flight: Phase N.6 — Perf polish.**
|
||
```
|
||
(or whatever phase comes next.)
|
||
|
||
- [ ] **Step 2: Close A.5-related issues in `docs/ISSUES.md`**
|
||
|
||
Move any A.5-prefixed open issues to "Recently closed" with the SHIP commit
|
||
SHA. (If none exist, skip.)
|
||
|
||
- [ ] **Step 3: Update `CLAUDE.md` "Currently in flight" line**
|
||
|
||
Find the section after "Currently in flight: Phase N.6 — Perf polish." and
|
||
update if needed. Update the WB integration cribs section to note A.5's
|
||
two-tier streaming wiring location for future readers.
|
||
|
||
- [ ] **Step 4: Write memory entry**
|
||
|
||
Create `memory/project_phase_a5_state.md`:
|
||
|
||
```markdown
|
||
---
|
||
name: "Project: Phase A.5 state (shipped <date>)"
|
||
description: A.5 shipped two-tier streaming with N₁=4 / N₂=12, fog-tuned horizon, single-worker off-thread mesh build, entity bucketing tightening, mipmaps + A2C + depth-audit. Three high-value gotchas captured.
|
||
type: project
|
||
---
|
||
|
||
**Phase A.5 — Two-tier Streaming + Horizon LOD — shipped <date>.**
|
||
|
||
<short summary mirroring N.5b memory pattern; concrete numbers from the
|
||
perf baseline doc>
|
||
|
||
## Three high-value gotchas surfaced during A.5
|
||
|
||
1. <gotcha 1 — typically the most surprising>
|
||
2. <gotcha 2>
|
||
3. <gotcha 3>
|
||
|
||
## Files added or modified summary
|
||
|
||
**Added:**
|
||
- src/AcDream.App/Streaming/LandblockStreamTier.cs
|
||
- src/AcDream.App/Streaming/TwoTierDiff.cs
|
||
- tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs
|
||
- tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs
|
||
- tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs
|
||
- tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs
|
||
- tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
|
||
- tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs
|
||
- docs/plans/2026-05-09-phase-a5-perf-baseline.md
|
||
- docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md
|
||
- docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
|
||
|
||
**Modified:**
|
||
- src/AcDream.App/Streaming/StreamingRegion.cs (two-radii + TwoTierDiff)
|
||
- src/AcDream.App/Streaming/StreamingController.cs (two-tier Tick)
|
||
- src/AcDream.App/Streaming/LandblockStreamer.cs (worker thread + mesh build)
|
||
- src/AcDream.App/Streaming/LandblockStreamJob.cs (Loaded.Tier + MeshData; Promoted)
|
||
- src/AcDream.App/Streaming/GpuWorldState.cs (RemoveEntities/AddEntitiesToExisting; AnimatedById)
|
||
- src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs (WalkEntities + Change #1 + cached AABB)
|
||
- src/AcDream.App/Rendering/TerrainModernRenderer.cs (AddLandblockWithMesh)
|
||
- src/AcDream.App/Rendering/TerrainAtlas.cs (mipmaps + anisotropic)
|
||
- src/AcDream.App/Rendering/Shaders/mesh_modern.frag (A2C output)
|
||
- src/AcDream.App/Rendering/GameWindow.cs (MSAA 4x + fog wiring + two-tier construction)
|
||
- src/AcDream.Core/World/WorldEntity.cs (AABB cache)
|
||
- src/AcDream.Core/World/LandblockLoader.cs (RefreshAabb at register)
|
||
- src/AcDream.Core/Terrain/LandblockMesh.cs (IDictionary surfaceCache)
|
||
```
|
||
|
||
Update `MEMORY.md` index with one-line pointer:
|
||
|
||
```markdown
|
||
- [Project: Phase A.5 state](project_phase_a5_state.md) — A.5 SHIPPED <date>. Two-tier streaming N₁=4 / N₂=12, ~2.3km fog horizon, off-thread mesh build, entity bucketing tightening, mipmaps + A2C + depth audit.
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md CLAUDE.md
|
||
git commit -m "docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship"
|
||
```
|
||
|
||
(Memory files are outside the worktree at `~/.claude/projects/.../memory/`.
|
||
Memory commits use the same git instance — same `git add` + `git commit`,
|
||
just paths under `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\`.)
|
||
|
||
---
|
||
|
||
## Task 28: SHIP commit
|
||
|
||
**Files:** none (marker commit)
|
||
|
||
- [ ] **Step 1: Final build + full test pass**
|
||
|
||
Run: `dotnet build && dotnet test --no-build`
|
||
Expected: build succeeded; **all** tests pass.
|
||
|
||
- [ ] **Step 2: N.5b sentinel re-run**
|
||
|
||
Run: `dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"`
|
||
Expected: 89+ passing, 0 failures.
|
||
|
||
- [ ] **Step 3: SHIP commit**
|
||
|
||
```bash
|
||
git commit --allow-empty -m "$(cat <<'EOF'
|
||
phase(A.5): SHIP — two-tier streaming + horizon LOD
|
||
|
||
Acceptance:
|
||
- Standstill at Holtburg (30s, NearRadius=4, FarRadius=12):
|
||
median <fill>ms (target ≤ 4.166ms = 240Hz). p99 <fill>ms.
|
||
- Walking Holtburg → North Yanshi (60s):
|
||
median <fill> FPS (target ≥ 144 FPS). p95 <fill> FPS.
|
||
- Visual gate: horizon visible at ~2.3km; fog blend smooths N₁
|
||
scenery boundary; no shimmer at distance; smooth tree edges; no
|
||
new depth artifacts.
|
||
- N.5b conformance sentinel: 89+ passing, 0 failures.
|
||
|
||
Decisions (per spec §4):
|
||
- N₁=4 (full-detail, 81 LBs), N₂=12 (terrain-only, 544 LBs).
|
||
- Bucketing Change #1 (animated-walk fix) + Change #2 (cached AABB)
|
||
shipped. Change #3 (sub-LB cell cull) NOT shipped — budget hit
|
||
without it.
|
||
- Single-worker off-thread mesh build (Q6 Option A).
|
||
- Hysteresis radius+2 on both tiers (Q7 Option A).
|
||
- Mipmaps + 16x anisotropic + A2C with MSAA 4x + depth-write audit
|
||
all shipped (Q8 Option C).
|
||
- Acceptance gate: Q9 Option B (tiered — strict standstill, relaxed
|
||
walking).
|
||
|
||
Spec: docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md
|
||
Plan: docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
|
||
Perf baseline: docs/plans/2026-05-09-phase-a5-perf-baseline.md
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
Spec coverage cross-check:
|
||
|
||
| Spec section | Implementing tasks |
|
||
|---|---|
|
||
| §3 Two-tier streaming model | T1, T3-T6 (StreamingRegion), T13-T16 (StreamingController + GameWindow) |
|
||
| §4.1 Tier enum | T1 |
|
||
| §4.2 StreamingRegion two-radii | T3-T6 |
|
||
| §4.3 StreamingController routing | T13 |
|
||
| §4.4 LandblockStreamResult variants | T7 |
|
||
| §4.5 Worker thread mesh build | T9 (cache), T10 (lock), T11 (activate), T12 (inject) |
|
||
| §4.6 Bucketing Change #1 (animated-walk fix) | T17 |
|
||
| §4.6 Bucketing Change #2 (cached AABB) | T8 (schema), T18 (use + populate) |
|
||
| §4.6 Bucketing Change #3 (sub-LB cull) | conditional — added as T18.5 only if Tasks 17+18 don't hit 2.0ms budget |
|
||
| §4.7 TerrainModernRenderer | T15 (AddLandblockWithMesh entry); no structural change |
|
||
| §4.8 Fog tuning | T22 |
|
||
| §4.10 Quality Preset System (NEW — mid-execution addition) | T22.5 |
|
||
| §4.9.1 Mipmaps | T19 |
|
||
| §4.9.2 A2C with MSAA | T20 |
|
||
| §4.9.3 Depth-write audit | T21 |
|
||
| §6 Threading model | T9, T10, T11, T12 |
|
||
| §7 Error handling | inherited from existing patterns; spot-checks during T11/T12 |
|
||
| §8 Testing strategy | T3-T6, T8, T13, T14, T17, T21 (per-task tests) |
|
||
| §2 Acceptance metrics | T23 (logging), T24 (before), T25 (after), T26 (visual gate) |
|
||
| §11 Wrap-up | T27, T28 |
|
||
|
||
Placeholder scan: only intentional `<fill>` markers in baseline doc + memory
|
||
entry + SHIP commit message — these are runtime-captured numbers / dates
|
||
documented as fillable at Tasks 24, 25, 27, 28.
|
||
|
||
Type consistency:
|
||
- `LandblockStreamJobKind`: `LoadFar` / `LoadNear` / `PromoteToNear` ✓
|
||
- `TwoTierDiff`: `ToLoadFar` / `ToLoadNear` / `ToPromote` / `ToDemote` / `ToUnload` ✓
|
||
- `LandblockStreamResult.Loaded(LandblockId, Tier, Landblock, MeshData)` ✓
|
||
- `LandblockStreamResult.Promoted(LandblockId, Entities)` ✓
|
||
- `WorldEntity` adds `AabbMin` / `AabbMax` / `AabbDirty` / `RefreshAabb()` / `SetPosition()` ✓
|
||
- `GpuWorldState`: `RemoveEntitiesFromLandblock` / `AddEntitiesToExistingLandblock` ✓
|
||
- `TerrainModernRenderer.AddLandblockWithMesh(lb, meshData)` ✓
|
||
- `WbDrawDispatcher.WalkEntities(entries, frustum, neverCullLb, visibleCells, animatedSet)` returning `WalkResult` ✓
|
||
|
||
All consistent across tasks.
|