From fcaff713527884f6be81895c9640844f2ff7451b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 21:52:00 +0200 Subject: [PATCH 01/45] docs(A.5): two-tier streaming + horizon LOD design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm output for Phase A.5. Locks key decisions: - Hardware target: 240 Hz / 1440p, 4.166ms vsync budget - Tier radii: N₁=4 (full detail, 81 LBs), N₂=12 (terrain only, 544 LBs) - Far-tier strategy: terrain-only + fog blend at N₁ (zero engineering cost) - Bucketing: tighten existing per-LB walk (Q5 Option A); persistent groups deferred to a later phase - Worker thread: single-thread mesh build off render path (Q6 Option A) - Hysteresis: existing radius+2 convention applied to both tiers - Visual ride-alongs: mipmaps + anisotropic + A2C/MSAA + depth-write audit - Acceptance: 240Hz standstill / 144 FPS walking (Q9 Option B) Spec at docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md. Awaiting user review before transitioning to writing-plans. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...5-09-phase-a5-two-tier-streaming-design.md | 687 ++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md diff --git a/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md b/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md new file mode 100644 index 0000000..44ed02a --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md @@ -0,0 +1,687 @@ +# Phase A.5 — Two-tier Streaming + Horizon LOD — Design + +**Created:** 2026-05-09 (immediately after N.5b ship + brainstorm). +**Status:** Spec — awaiting user review before plan-writing. +**Branch:** `claude/hopeful-darwin-ae8b87` (worktree under `.claude/worktrees/hopeful-darwin-ae8b87`). +**Predecessor:** Phase N.5b SHIP at `08b7362`. A.5 handoff at `f7f8867`. + +--- + +## 1. Goal + +Scale acdream's visible reach from radius=5 (~1 km) to radius=12 (~2.3 km horizon) +while sustaining 240 FPS at standstill on a 240 Hz / 1440p monitor. + +Delivered through: +1. Two-tier streaming (near = full detail, far = terrain only). +2. Tightening the existing per-LB entity dispatcher walk. +3. Off-thread mesh build (single worker). +4. Fog blend at the near-tier boundary to mask the scenery cutoff. +5. Three nearly-free visual quality wins (terrain mipmaps + anisotropic, A2C + with MSAA on foliage, depth-write audit). + +The headline win: walking around Holtburg, the user sees a real horizon +(2.3 km of visible terrain) without the client falling off a perf cliff. + +**User goal verbatim (2026-05-09):** +> "I just want great smooth HIGH fps visuals. Should look great. As long as +> it scales and we get very high FPS" + +--- + +## 2. Hardware target + acceptance metrics + +### Target hardware + +- AMD Radeon RX 9070 XT (RDNA 4, ~December 2025). +- 240 Hz @ 2560×1440 (verified via `Get-CimInstance Win32_VideoController`). +- Frame budget: **4.166 ms** at vsync. + +### Acceptance metrics (Q9 Option B — tiered) + +1. **Build green; existing tests still green.** N.5b conformance sentinel + passes (visual mesh Z = TerrainSurface.SampleZ within 1 mm). +2. **Standstill at Holtburg dueling field, 30 s with `[WB-DIAG]` and `[TERRAIN-DIAG]`:** + - Median frame time ≤ 4.166 ms (240 FPS sustained). + - p99 ≤ 4.5 ms (no vsync misses). +3. **Walking Holtburg → North Yanshi at run speed, 60 s trace:** + - Median ≥ 144 FPS (≤ 6.94 ms). + - p95 ≥ 120 FPS (≤ 8.33 ms). +4. **First traversal into virgin region (cold mesh cache):** + - Render thread frame time stays ≤ 8.33 ms throughout while the worker + fills the far-tier horizon (~2.7 s of "horizon filling in" is OK). +5. **Visual gate (user-driven):** user launches the client, walks + Holtburg → North Yanshi, and confirms: + - Horizon visible at ~2.3 km. + - Fog blend at N₁ smooths the scenery boundary (no harsh cliff). + - Distant terrain does not shimmer (mipmaps work). + - Tree edges are smooth (A2C works). + - No new z-fighting / depth artifacts (depth-write audit). +6. **Per-subsystem regression budgets** (added to `[WB-DIAG]` / + `[TERRAIN-DIAG]` output): + - Entity dispatcher cpu_us median ≤ **2.0 ms** at standstill. + - Terrain dispatcher cpu_us median ≤ **1.0 ms** at standstill (all 625 LBs). +7. **N.5b sentinel intact:** TerrainSlot, TerrainModernConformance, Wb*, + MatrixComposition, TextureCacheBindless, SplitFormulaDivergence — all + pass clean. +8. **SHIP record + perf baseline doc + memory entry** mirroring N.5b's pattern. + +A failure on (5) is a SHIP-blocker. A failure on (3) walking-FPS criterion +escalates to "fix or document the tradeoff and ship N.6 next" — not a +direct blocker but pushes the gate to user discretion. + +--- + +## 3. Two-tier streaming model + +### Tier definitions + +| Tier | Radius | LB count | Loads | GPU mem | +|---|---|---|---|---| +| **Near** (N₁ = 4) | 9×9 = 81 LBs | terrain mesh + LandBlockInfo (stabs/buildings) + scenery generation + EnvCells + collision data + entity registration with WB dispatcher | scenery instance buffers + per-entity textures (depends on PaletteOverrides) | +| **Far** (N₂ = 12) | 25×25 - 9×9 = 544 LBs | terrain mesh ONLY (LandBlock heightmap + atlas blend) | ~14 MB shared atlas slots | +| **Total** | 25×25 = 625 LBs | combined | ~30 MB total estimated | + +### Hysteresis (Q7 Option A — match existing radius+2 convention) + +- **Near-tier:** entity load at distance 4, demote (entity unload) at distance 6. +- **Far-tier:** terrain load at distance 12, terrain unload at distance 14. + +Both boundaries get the same 2-LB buffer. Phase A.1's existing hysteresis +mechanism in `StreamingRegion.RecenterTo` is the reference pattern; A.5 +extends it from one radius to two. + +### Tier transitions + +| Transition | Trigger | Action | +|---|---|---| +| `null → far` | LB enters far window from outside | Worker reads LandBlock heightmap, builds mesh, posts `LandblockStreamResult.Loaded { Tier = Far }`. Render thread adds slot in `TerrainModernRenderer`. No entity work. | +| `null → near` | LB jumps null → near in one tick (first-tick bootstrap; teleport into virgin region) | Worker reads LandBlock heightmap + `LandBlockInfo`, generates scenery, builds entity list, builds mesh. Posts `LandblockStreamResult.Loaded { Tier = Near }`. Render thread adds terrain slot AND merges entities. | +| `far → near` | LB enters near window from far-resident | Worker reads `LandBlockInfo`, generates scenery, builds entity list. Posts `LandblockStreamResult.Promoted`. Render thread merges entities into `GpuWorldState` for the existing LB (terrain already loaded). | +| `near → far` | LB leaves near window past hysteresis (distance > 6) | Render thread drops the LB's entities from `GpuWorldState` (which fires `_wbSpawnAdapter.OnLandblockUnloaded`). Terrain stays. | +| `far → null` | LB leaves far window past hysteresis (distance > 14) | Render thread removes the terrain slot from `TerrainModernRenderer`. | + +The order matters: when a player walks outward, the same LB goes +`near → far → null` over time. Each transition is one event per LB per +crossing. + +### Why the player crossing the N₁ boundary works + +The player is always at radius=0 from the streaming center (the streaming +center IS the player). The boundary effects are about LBs at the edge of N₁ +crossing inward/outward as the player moves. Server-spawned NPCs are +delivered by ACE's broadcast (radius typically 5-7 LBs ≥ N₁), so when an +LB promotes back to near, ACE will already have its NPCs broadcast or +re-broadcast as the player moves through. Dat-static entities (stabs, +buildings) are reloaded from `LandBlockInfo` on promotion. Scenery is +re-generated from the deterministic seed at the same time. + +--- + +## 4. Component-by-component design + +### 4.1 `LandblockStreamTier` — new enum + +```csharp +namespace AcDream.App.Streaming; + +public enum LandblockStreamTier +{ + Far, // terrain only + Near, // full detail (terrain + entities + scenery + EnvCells) +} +``` + +### 4.2 `StreamingRegion` — extended to two radii + +```csharp +public sealed class StreamingRegion +{ + public int CenterX { get; } + public int CenterY { get; } + public int NearRadius { get; } // N₁ (default 4) + public int FarRadius { get; } // N₂ (default 12) + + public IReadOnlyCollection NearVisible { get; } // 9×9 window + public IReadOnlyCollection FarVisible { get; } // 25×25 window minus near + public IReadOnlyCollection Resident { get; } // hysteresis-retained + + public TwoTierDiff RecenterTo(int newCx, int newCy); +} + +public readonly record struct TwoTierDiff( + IReadOnlyList ToLoadFar, // entered far window from null (need terrain only) + IReadOnlyList ToLoadNear, // entered near window from null (need terrain + entities — first-tick bootstrap, teleport) + IReadOnlyList ToPromote, // entered near window from far-resident (need entities only — terrain already loaded) + IReadOnlyList ToDemote, // exited near window past hysteresis (drop entities) + IReadOnlyList ToUnload); // exited far window past hysteresis (drop terrain) +``` + +The hysteresis math: +- Near-unload threshold: `NearRadius + 2` = 6. +- Far-unload threshold: `FarRadius + 2` = 14. + +A landblock is "near-resident" if its distance ≤ 6; "far-resident" if its +distance is in (6, 14]. Beyond 14, it unloads entirely. + +### 4.3 `StreamingController` — routes by tier + +```csharp +public sealed class StreamingController +{ + public int NearRadius { get; set; } = 4; + public int FarRadius { get; set; } = 12; + public int MaxCompletionsPerFrame { get; set; } = 4; + + // Action signatures change to carry the tier. + private readonly Action _enqueueLoad; + private readonly Action _enqueueUnload; + // ... + + public void Tick(int observerCx, int observerCy) + { + // First-tick bootstrap: every near-window LB → ToLoadNear; every + // far-window-only LB → ToLoadFar. + // Steady-state RecenterTo: produces 5 transition lists. + // - ToLoadFar → _enqueueLoad(id, JobKind.LoadFar) + // - ToLoadNear → _enqueueLoad(id, JobKind.LoadNear) + // - ToPromote → _enqueueLoad(id, JobKind.PromoteToNear) + // - ToDemote → _state.RemoveEntities(id) on render thread (no worker job) + // - ToUnload → _enqueueUnload(id) + // Drain completions and route by result variant. + } +} + +public enum LandblockStreamJobKind { LoadFar, LoadNear, PromoteToNear } +``` + +The render thread decides the job kind up-front based on its own knowledge +of which LBs are currently terrain-resident; the worker never peeks at +render-thread state. Three distinct worker paths: + +- **`LoadFar`:** read `LandBlock` heightmap only. Skip `LandBlockInfo`, + skip `LandblockLoader.BuildEntitiesFromInfo`, skip + `SceneryGenerator`/`WbSceneryAdapter`. Build `LandblockMesh`. Post + `LandblockStreamResult.Loaded(Tier=Far, Entities=[], MeshData=mesh)`. +- **`LoadNear`:** read `LandBlock` + `LandBlockInfo` + scenery generation + + build mesh. Post `LandblockStreamResult.Loaded(Tier=Near, Entities=..., + MeshData=mesh)`. Used for first-tick bootstrap of the inner ring and + for the rare null→Near jump (teleport into virgin region). +- **`PromoteToNear`:** read `LandBlockInfo` + scenery generation only. + Skip `LandBlock` heightmap (mesh already on GPU). Skip + `LandblockMesh.Build`. Post `LandblockStreamResult.Promoted(id, entities)`. + +### 4.4 `LandblockStreamResult` — new variants + +```csharp +public abstract record LandblockStreamResult +{ + public sealed record Loaded( + uint LandblockId, + LandblockStreamTier Tier, + LandBlock Heightmap, + IReadOnlyList Entities, // empty for Far + LandblockMeshData MeshData // built off-thread + ) : LandblockStreamResult; + + public sealed record Promoted( + uint LandblockId, + IReadOnlyList Entities // entity layer for an already-loaded far-tier LB + ) : LandblockStreamResult; + + // Existing: + public sealed record Unloaded(uint LandblockId) : LandblockStreamResult; + public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult; + public sealed record WorkerCrashed(string Error) : LandblockStreamResult; +} +``` + +`Loaded` carries `MeshData` — the mesh is built on the worker thread, NOT +in `_applyTerrain` on the render thread. `Promoted` only carries entities; +the mesh is already in `TerrainModernRenderer`. + +### 4.5 `LandblockStreamer` — single worker, mesh-build on-worker + +Existing `LandblockStreamer` (today on a single background thread) gets +extended to: + +1. Read dat as today (`DatCollection.Get` etc.). +2. Build `LandblockMesh` on the same thread: + ```csharp + var meshData = LandblockMesh.Build( + block, lbX, lbY, heightTable, _ctx, _surfaceCache); + ``` +3. Post `LandblockStreamResult.Loaded(... MeshData = meshData)` to the + completion queue. + +Thread-safety implications: +- `_ctx` (TerrainBlendingContext) is read-only after init — no change. +- `_surfaceCache`: today a plain `Dictionary`, + populated lazily by `LandblockMesh.Build`. Currently safe because + Build runs on the render thread; A.5 moves Build to the worker, so + the cache must be thread-safe. **Swap to + `ConcurrentDictionary`** with `GetOrAdd` for the + populate path. The factory inside `GetOrAdd` may run twice for the + same key under contention (acceptable — the result is deterministic). + +### 4.6 `WbDrawDispatcher` — entity bucketing tightening (Q5 Option A) + +Three targeted changes inside the existing `Draw` flow: + +#### Change 1: Animated-entity walk fix + +Today (at lines 197-204 of `WbDrawDispatcher.cs`): + +```csharp +foreach (var entry in landblockEntries) { + bool landblockVisible = ...; + if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0)) + continue; + + foreach (var entity in entry.Entities) { + ... + if (!landblockVisible && !isAnimated) continue; +``` + +The `if (!landblockVisible && ...) continue;` only skips if there are NO +animated entities. When `animatedEntityIds` is non-empty, the inner loop +walks every entity in the invisible LB just to find the few animated +ones. With ~10.7K entities at N₁=4, this is wasted iteration. + +**Fix:** when an LB is invisible, iterate `animatedEntityIds` directly +and look each up in a per-LB `Dictionary` map (added +to `LoadedLandblock` or kept in a parallel structure). + +```csharp +foreach (var entry in landblockEntries) { + bool landblockVisible = ...; + if (!landblockVisible) { + if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue; + // Walk only animated entities in this invisible LB. + foreach (var animatedId in animatedEntityIds) { + if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue; + // ... draw the entity + } + continue; + } + foreach (var entity in entry.Entities) { ... } +} +``` + +#### Change 2: Per-entity AABB cache at register time + +Today: `Draw` recomputes `aMin = position - 5`, `aMax = position + 5` per +entity per frame. Cheap individually, but ~16K × per frame = measurable. + +**Fix:** add `Vector3 AabbMin, AabbMax` fields to `WorldEntity` (or a +parallel struct keyed by entity id). Populate at `EntitySpawnAdapter.OnCreate` +(server-spawned) and `LandblockLoader.BuildEntitiesFromInfo` (dat-static) +time. Static entities never invalidate. Dynamic entities (NPCs, players) +update on position change — add `WorldEntity.PositionDirty` flag set by +the live position update path; AABB recompute happens lazily on first +read after dirty. + +The AABB radius today is hard-coded `PerEntityCullRadius = 5.0f` — keep +that as a per-mesh-bucket fallback; future improvement is to compute the +real AABB from the mesh, but defer that to a later phase (it's a +cross-cutting change). + +#### Change 3: 4×4 sub-LB cell cull for partially-visible LBs + +When an LB is fully visible (its AABB entirely inside the frustum), all +its entities are drawn — no per-entity cull needed. Today's per-entity +cull is wasted work in this case. + +When an LB is partially visible, today's per-entity cull is the right +work — but it walks all ~132 entities. Cheap with the AABB-cache fix +(memory read), so the win here is small. Worth doing only if the cache +fix alone isn't enough to hit the 2.0ms budget. + +**Add only if needed:** bucket each LB's entities into 4×4 sub-cells +(each 48 m). Compute a sub-cell AABB at register time. Per frame: for +partially-visible LBs, cull at sub-cell granularity first; walk +entities only inside surviving sub-cells. + +Ship change #1 and #2 unconditionally; ship #3 only if the budget +isn't hit by #1 + #2. + +### 4.7 `TerrainModernRenderer` — no structural change + +The slot allocator (`TerrainSlotAllocator`) already grows by power-of-two +doubling. At N₂=12 worst case, ~961 slots × ~15 KB per slot = ~14 MB. +Allocator handles it without modification. + +Per-LB frustum cull stays per-slot — at ~961 slots × ~0.3 µs/AABB-test +the worst-case cull pass is ~0.3 ms. Acceptable inside the 1.0 ms terrain +dispatcher budget. + +The DEIC (`DrawElementsIndirectCommand`) array grows accordingly. The +existing per-frame `BufferSubData` upload absorbs a 961-entry array +without issue (~19 KB). + +### 4.8 Fog tuning (`SceneLightingUbo`) + +Existing fields (Phase G.1+): +- `FogStart` — distance at which fog begins (today: somewhere outside the + visible terrain range). +- `FogEnd` — distance at which fog reaches full opacity. +- `FogColor` — sourced from current sky state. + +A.5 change: dynamically tune `FogStart` and `FogEnd` based on the +current N₁/N₂: + +- `FogStart = N₁ × LandblockSize × 0.7` ≈ `4 × 192 × 0.7` = **~538 m**. +- `FogEnd = N₂ × LandblockSize × 0.95` ≈ `12 × 192 × 0.95` = **~2188 m**. + +The fog color matches the current sky color (already provided by +`SkyStateProvider`) — at the far horizon, fog blends terrain into +sky, hiding the N₂ edge. + +The 0.7 / 0.95 multipliers are tuning knobs. Iterate during user gate. +**Expose as env vars during development** (`ACDREAM_FOG_START_MULT`, +`ACDREAM_FOG_END_MULT`) to allow fast iteration without a recompile. + +### 4.9 Visual quality wins (Q8 Option C — all three) + +#### 4.9.1 Mipmaps + 16x anisotropic on `TerrainAtlas` + +Today: `TerrainAtlas.Upload` uses `GL_LINEAR` minification, no mipmaps. + +A.5 change: after upload, call `glGenerateMipmap(GL_TEXTURE_2D_ARRAY)`. +Sampler state: `GL_LINEAR_MIPMAP_LINEAR` (trilinear) + +`GL_TEXTURE_MAX_ANISOTROPY = 16`. + +Affects only `TerrainAtlas`. Mesh atlas (entity textures) and other +texture caches stay as-is. + +Verification: at N₂=12, walk to a vantage point looking at terrain at +range 2 km. With the fix, no shimmer. Without, "moving sparkles" visible +at distance. + +#### 4.9.2 Alpha-to-coverage with MSAA on foliage + +Today: `mesh_modern.frag` uses `if (alpha < cutoff) discard;` for ClipMap +translucency. Produces hard, pixel-edged tree silhouettes. + +A.5 change: +- Enable MSAA 4x on the GL render target (window framebuffer). +- In `mesh_modern.frag`, for ClipMap pass: write + `gl_SampleMask[0]` based on alpha threshold instead of binary discard. + +Risk: MSAA framebuffer interaction with sky / particles / UI overlay. +Audit: +- `SkyRenderer` — clears its own framebuffer? If so, must clear the MSAA + attachment instead. Investigate. +- `ParticleRenderer` — billboards already use alpha-blend; MSAA-friendly. +- ImGui overlay — drawn after the 3D pass; must not interact with MSAA + resolve. + +If the audit finds blocking issues, ship 4.9.1 + 4.9.3 only and defer +4.9.2 to a later phase. Document the result either way. + +#### 4.9.3 Depth-write audit on translucent batches + +Walk all translucent batch paths in `WbDrawDispatcher.Draw` and verify: +- Alpha-blend (`AlphaBlend`, `Additive`, `InvAlpha`): `glDepthMask(false)`. +- Clip-map (binary alpha): `glDepthMask(true)` (foliage casts depth). +- Opaque: `glDepthMask(true)`. + +Today's code at lines 401-433 sets `DepthMask(true)` for opaque, +`DepthMask(false)` for transparent. Confirm ClipMap is in the opaque +pass (it is, per `IsOpaque` returning true for ClipMap at line 738). + +If audit finds nothing wrong, ship a comment + a unit test that locks in +the partition. Cheap insurance against future regression. + +--- + +## 5. Data flow + +### Per-frame (steady state) + +``` +GameWindow.OnUpdate(dt) + └─ StreamingController.Tick(playerCx, playerCy) + ├─ region.RecenterTo(...) // produces TwoTierDiff if center changed + ├─ for each ToLoadFar: _enqueueLoad(id, LoadFar) + ├─ for each ToLoadNear: _enqueueLoad(id, LoadNear) + ├─ for each ToPromote: _enqueueLoad(id, PromoteToNear) + ├─ for each ToDemote: _state.RemoveEntities(id) // on render thread + ├─ for each ToUnload: _enqueueUnload(id) + └─ drainCompletions(MaxCompletionsPerFrame=4) + ├─ Loaded.Far: _terrain.AddLandblock(meshData); _state.AddLandblock(...) + ├─ Loaded.Near: _terrain.AddLandblock(meshData); _state.AddLandblock(... entities) + ├─ Promoted: _state.AddEntitiesToExisting(id, entities) + ├─ Unloaded: _terrain.RemoveLandblock(id); _state.RemoveLandblock(id) + └─ Failed/Crash: log + +GameWindow.OnRender + ├─ TerrainModernRenderer.Draw(camera, frustum) + │ └─ glMultiDrawElementsIndirect across all near + far slots that pass cull + └─ WbDrawDispatcher.Draw(camera, gpuWorldState.LandblockEntries, frustum, visibleCellIds, animatedEntityIds) + ├─ for each LB entry: + │ ├─ if invisible: walk only animatedEntityIds (Change #1) + │ └─ if visible: walk entities, AABB cache lookup (Change #2) + ├─ classify into groups, build SSBO, multi-draw indirect + └─ flush DIAG every ~5 s +``` + +### Worker thread + +``` +LandblockStreamer.WorkerLoop + while running: + job = jobQueue.dequeue() + switch job.Kind: + LoadFar: + block = dats.Get(id) + meshData = LandblockMesh.Build(block, ..., _surfaceCache) + completionQueue.enqueue(Loaded(id, Far, block, [], meshData)) + LoadNear: + block = dats.Get(id) + info = dats.Get(...) + entities = LandblockLoader.BuildEntitiesFromInfo(info) + scenery = WbSceneryAdapter.GenerateScenery(block, ...) + meshData = LandblockMesh.Build(block, ..., _surfaceCache) + completionQueue.enqueue(Loaded(id, Near, block, entities ∪ scenery, meshData)) + PromoteToNear: + info = dats.Get(...) + // Heightmap not re-read; scenery generation needs LandBlock for height + // sampling — read it again from disk cache (DatCollection caches the + // last-read block; cheap second access) OR pass through from render + // thread's terrain-slot snapshot (deferred plan-level decision). + block = dats.Get(id) + entities = LandblockLoader.BuildEntitiesFromInfo(info) + scenery = WbSceneryAdapter.GenerateScenery(block, ...) + completionQueue.enqueue(Promoted(id, entities ∪ scenery)) +``` + +--- + +## 6. Threading model + +- **Render thread:** drives `StreamingController.Tick`, drains the + completion queue, calls `TerrainModernRenderer.AddLandblock` / + `RemoveLandblock`, mutates `GpuWorldState`. All GL calls on this thread. +- **One streaming worker thread:** dat reads, mesh build, scenery generation. + Owns `_surfaceCache` (now `ConcurrentDictionary`) — render thread does + not access it directly. +- **Network thread:** unchanged from Phase A.3 — drains UDP into the + channel; render thread decodes. + +Synchronization: +- Job queue: `Channel` (writer = render thread via + `_enqueueLoad`; reader = worker). +- Completion queue: `ConcurrentQueue` (writer = + worker; reader = render thread). +- `_surfaceCache`: `ConcurrentDictionary` populated by + `LandblockMesh.Build` on the worker; read by future paths if any + (none today). +- `TerrainBlendingContext`: read-only post-init. No lock. + +--- + +## 7. Error handling + +- **Worker crash:** caught in worker loop, posts + `LandblockStreamResult.WorkerCrashed`. Render thread logs to console. + (Existing pattern.) +- **Dat read failure:** posts `LandblockStreamResult.Failed`. Render + thread logs. Streaming continues with the LB skipped — region still + tracks it as resident so we don't retry forever, but the slot stays empty. +- **AABB cache invalidation race:** dynamic entity moves while the + dispatcher is walking. Acceptable — at worst, the entity culls or + draws based on the previous frame's position. Position is updated in + the network handler (also render-thread today) so no actual race. +- **Promotion timing:** if the player crosses N₁ inward, we enqueue a + `Near` load on the worker. Until it completes, the LB has terrain but + no scenery / entities. Frame budget is unaffected (only `LoadedLandblock` + changes, and the dispatcher already handles missing entities by walking + zero-length lists). +- **Unload during in-flight load:** enqueue an unload while a load is + in flight. When the load completes, render thread sees the LB is no + longer resident — drop the result silently. Same pattern as today. + +--- + +## 8. Testing strategy + +### Unit tests (offline, no GL) + +Add to `tests/AcDream.Core.Tests/Streaming/`: +- `StreamingRegion_TwoTier_FirstTick_LoadsNearAndFarSeparately` — first + call produces `ToLoadNear` populated for inner ring, `ToLoadFar` + populated for outer ring, `ToPromote` empty (nothing was previously + resident). +- `StreamingRegion_TwoTier_NullToFar_OnFarRingEntry` — LB rolls into + far window from null. Asserts entry in `ToLoadFar`, not + `ToLoadNear`. +- `StreamingRegion_TwoTier_FarToNear_OnNearRingEntry` — LB was + far-resident, player walks toward it, LB enters near window. Asserts + entry in `ToPromote`, not `ToLoadNear`. +- `StreamingRegion_TwoTier_NullToNear_OnTeleport` — observer center + jumps far enough that an LB goes from null → Near in one frame + (e.g., teleport). Asserts entry in `ToLoadNear`, not `ToPromote`. +- `StreamingRegion_TwoTier_NearToFar_OnNearBoundaryExitPlusHysteresis` — + asserts entry in `ToDemote` only after distance exceeds + `NearRadius + 2`. +- `StreamingRegion_TwoTier_FarToNull_OnFarBoundaryExitPlusHysteresis` — + asserts entry in `ToUnload` only after distance exceeds + `FarRadius + 2`. +- `StreamingRegion_TwoTier_HysteresisHoldsAcrossOscillation` — walk + back-and-forth across N₁ five times within the hysteresis radius; + assert no demote events fire. +- `StreamingController_TwoTier_DrainsRoutedByVariant` — `Loaded.Far`, + `Loaded.Near`, and `Promoted` each route to the right state mutation + on the render thread. + +Add to `tests/AcDream.Core.Tests/Rendering/Wb/`: +- `WbDrawDispatcher_AnimatedEntities_InInvisibleLb_NoFullEntityWalk` — + verify Change #1 (only iterates `animatedEntityIds`, not `Entities`). +- `WbDrawDispatcher_PerEntityAabbCached_NotRecomputed` — assert AABB + fields are read, not recomputed, for static entities. + +### Conformance tests + +- `TerrainModernConformanceTests` (existing) — must still pass. The + visual mesh Z must agree with `TerrainSurface.SampleZFromHeightmap` + to within 1 mm across both tiers. +- `LandblockMeshTests` (existing) — must still pass. Worker-thread + mesh build produces byte-identical results to render-thread build + for the same inputs. + +### Perf gate (manual, with `[WB-DIAG]` + `[TERRAIN-DIAG]`) + +- **Standstill bench:** launch with `ACDREAM_WB_DIAG=1`, stand at + Holtburg dueling field for 60 s. Read median + p95 + p99 from log. +- **Walking bench:** launch with diag, run from Holtburg to North + Yanshi, ~60 s. Same metrics. +- **First traversal bench:** clear OS file cache (or reboot), launch + with diag, walk into a region not previously visited, capture the + worker-thread fill duration + render-thread frame time during fill. + +### Visual gate (manual, user-driven) + +User launches the client, walks the standard route, confirms: +1. Horizon visible at 2.3 km. +2. Fog blend is smooth (no scenery cliff at N₁). +3. No shimmer on distant terrain. +4. Smooth tree edges (foliage A2C). +5. No new z-fighting / depth artifacts. + +--- + +## 9. Out of scope (explicitly deferred) + +Per the brainstorm Q10 confirmation: + +- **GPU-side culling** (compute pre-pass) — N.6. +- **Persistent-mapped indirect buffer** — N.6. +- **Multi-thread mesh-build worker pool** — N.6 if first-traversal fill + feels too slow at gate. +- **Static/dynamic persistent groups** (Q5 Option B — the "compute the + group key once at spawn" architecture change) — separate later phase + (likely A.6 or N.6.5). +- **Billboard / impostor scenery** at far tier — escalation only if the + fog'd terrain horizon looks too bare at gate. +- **Wider N₁ hysteresis** (Option C, radius+3) — single-line tweak only + if gate finds entity pop-in along the boundary. +- **Far-tier terrain mesh LOD** (decimating 2×2 LBs) — not needed at + N₂=12; revisit only if N₂ grows beyond 15. +- **Sky / particles modern path migration** — N.7+ phases. +- **EnvCell modern path migration** — separate phase. +- **Shadow mapping** — separate visual phase, later. +- **Strict 240 Hz during walking** (Q9 Option A) — graduate to in a + perf-polish phase if we want to commit to it. + +--- + +## 10. Risks + +1. **Fog tuning visual gate** *(highest risk).* Hardest non-engineering + risk. The 0.7 / 0.95 multipliers in §4.8 are first-cut numbers. If + the fog band is too thin (visible scenery cliff at N₁) or too thick + (terrain looks washed out), iterate on the multipliers. Mitigation: + expose `FogStart` / `FogEnd` as tunable env vars during A.5 + development for fast iteration. +2. **A2C / MSAA framebuffer interaction** *(moderate risk).* MSAA on + the GL render target may break sky / particles / UI rendering. + Audit during implementation. **Fallback: ship Q8 Option B (mipmaps + + depth-audit only) if A2C goes sideways.** Document the result. +3. **Worker starvation on first-traversal** *(low-moderate risk).* + ~2.7 s of sequential mesh build on first walk into virgin region. + Render thread frame time stays in budget; the visible effect is the + horizon visibly filling. Acceptable per Q9 Option B; graduate to + multi-worker pool in N.6 if user complains. +4. **Tier-boundary churn** *(low risk).* When player crosses N₁ both + directions, demote→promote→demote fires. Hysteresis (radius+2) is + the buffer. If thrash visible, widen to radius+3. +5. **Entity AABB cache invalidation** *(low risk).* Dynamic entities + must recompute AABB on position change. Single-threaded render + thread means no concurrent mutation; the dirty-flag pattern is + straightforward. +6. **Server broadcast radius mismatch** *(low risk).* If ACE's broadcast + radius is < N₁=4, NPCs in outer near-tier LBs won't be + server-broadcast (they don't exist in our state). Mitigation: + N₁=4 is conservative — typical ACE configs broadcast at 5-7 LBs. + If observed, drop N₁ to 3. + +--- + +## 11. References + +- **Handoff (cold-start):** [`docs/research/2026-05-10-phase-a5-handoff.md`](../../research/2026-05-10-phase-a5-handoff.md) +- **N.5b handoff (predecessor):** [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md) +- **N.5b perf baseline:** [`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`](../../plans/2026-05-09-phase-n5b-perf-baseline.md) +- **Roadmap A.5 entry:** [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md) +- **N.5b memory state:** `memory/project_phase_n5b_state.md` (three high-value + gotchas — bindless uniform-sampler driver quirk, MaybeFlushTerrainDiag + underflow, visual gate confirmation requirement). +- **Existing streaming files:** + - [`src/AcDream.App/Streaming/StreamingController.cs`](../../../src/AcDream.App/Streaming/StreamingController.cs) + - [`src/AcDream.App/Streaming/StreamingRegion.cs`](../../../src/AcDream.App/Streaming/StreamingRegion.cs) + - [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../../src/AcDream.App/Streaming/GpuWorldState.cs) + - [`src/AcDream.App/Streaming/LandblockStreamer.cs`](../../../src/AcDream.App/Streaming/LandblockStreamer.cs) +- **Existing dispatcher:** [`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs) +- **Existing terrain renderer:** [`src/AcDream.App/Rendering/TerrainModernRenderer.cs`](../../../src/AcDream.App/Rendering/TerrainModernRenderer.cs) +- **Mesh builder (will move off render thread):** [`src/AcDream.Core/Terrain/LandblockMesh.cs`](../../../src/AcDream.Core/Terrain/LandblockMesh.cs) From b373523f9845d3f3bfa954c90fcb21b10e87aa23 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:10:38 +0200 Subject: [PATCH 02/45] docs(A.5): two-tier streaming + horizon LOD implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28-task TDD-first implementation plan for Phase A.5. Maps each spec section to concrete bite-sized tasks with failing-test → minimal-impl → commit cycles. Self-review at plan footer cross-checks coverage, type consistency, placeholders. Plan structure: - T1-T6: StreamingRegion two-tier + 5-list TwoTierDiff with hysteresis - T7: LandblockStreamResult.Loaded.Tier + MeshData; Promoted variant - T8: WorldEntity AABB cache + dirty flag - T9-T12: off-thread mesh build (ConcurrentDictionary + DatLock + worker activation + DI) - T13-T16: StreamingController two-tier + GpuWorldState two-tier ops + GameWindow wiring - T17-T18: WbDrawDispatcher bucketing tightening (Change #1 + Change #2) - T19-T21: visual quality (mipmaps + A2C + depth-write lock-in) - T22: fog params from N₁/N₂ + env-var multipliers - T23: BUDGET_OVER flag in DIAG output - T24-T26: perf baseline (before/after) + visual user gate - T27-T28: roadmap/ISSUES/CLAUDE.md updates + memory + SHIP commit Plan at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md. Spec at docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md (fcaff71). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-09-phase-a5-two-tier-streaming.md | 2455 +++++++++++++++++ 1 file changed, 2455 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md diff --git a/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md new file mode 100644 index 0000000..275b0cf --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md @@ -0,0 +1,2455 @@ +# 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~"` 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) ` 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; + +/// +/// Streaming-tier classification for a landblock. means +/// terrain mesh only; means terrain + scenery + EnvCells + +/// entity registration with the WB dispatcher. Per Phase A.5 spec §3. +/// +public enum LandblockStreamTier +{ + Far, + Near, +} + +/// +/// What work the streaming worker should perform for a given job. Distinct +/// from because +/// reads only the entity layer (terrain mesh already loaded), while +/// reads everything from scratch. Per Phase A.5 spec §4.3. +/// +public enum LandblockStreamJobKind +{ + /// Read LandBlock heightmap, build mesh, no entity layer. + LoadFar, + /// Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer. + LoadNear, + /// Read LandBlockInfo + scenery only — terrain already loaded for this LB. + 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; + +/// +/// Output of for the two-tier model. +/// Five disjoint lists describe what changed since the previous Tick. Per +/// Phase A.5 spec §4.2. +/// +public readonly record struct TwoTierDiff( + IReadOnlyList ToLoadFar, // entered far window from null (terrain only) + IReadOnlyList ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport) + IReadOnlyList ToPromote, // entered near window from far-resident (entities only) + IReadOnlyList ToDemote, // exited near window past hysteresis (drop entities) + IReadOnlyList 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 +/// +/// 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 on the first call before any +/// RecenterTo. +/// +public TwoTierDiff ComputeFirstTickDiff() +{ + var near = new List(); + var far = new List(); + 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(), + ToDemote: System.Array.Empty(), + ToUnload: System.Array.Empty()); +} +``` + +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 _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); + +/// +/// 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. +/// +public TwoTierDiff RecenterTo(int newCx, int newCy) +{ + int nearUnloadThreshold = NearRadius + 2; + int farUnloadThreshold = FarRadius + 2; + + var toLoadFar = new List(); + var toLoadNear = new List(); + var toPromote = new List(); + var toDemote = new List(); + var toUnload = new List(); + + // Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote. + var newCenterIds = new HashSet(); + 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) +{ + /// + /// A landblock load completed. distinguishes Far + /// (terrain only) from Near (terrain + entities). + /// is built off the render thread on the streaming worker. + /// + public sealed record Loaded( + uint LandblockId, + LandblockStreamTier Tier, + LoadedLandblock Landblock, + LandblockMeshData MeshData + ) : LandblockStreamResult(LandblockId); + + /// + /// 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. + /// + public sealed record Promoted( + uint LandblockId, + IReadOnlyList 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(), + }; + 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(), + }; + 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`** + +In `LandblockMesh.cs`, change: + +```csharp +public static LandblockMeshData Build( + LandBlock block, + uint landblockX, + uint landblockY, + float[] heightTable, + TerrainBlendingContext ctx, + Dictionary surfaceCache) +``` + +to: + +```csharp +public static LandblockMeshData Build( + LandBlock block, + uint landblockX, + uint landblockY, + float[] heightTable, + TerrainBlendingContext ctx, + System.Collections.Generic.IDictionary 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`** + +```csharp +private readonly System.Collections.Concurrent.ConcurrentDictionary _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 +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` / +`_dats.Get` 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(...)` access in the lock** + +Two patterns acceptable: + +(a) Inline lock at each call site: + +```csharp +LandBlock? block; +lock (_datsLock) { block = _dats.Get(id); } +``` + +(b) Helper method: + +```csharp +private T? GetDat(uint id) where T : class +{ + lock (_datsLock) { return _dats.Get(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 +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 +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 _buildMeshOrNull; + +public LandblockStreamer( + Func loadLandblock, + Func buildMeshOrNull) +{ + _loadLandblock = loadLandblock; + _buildMeshOrNull = buildMeshOrNull; + _inbox = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + _outbox = Channel.CreateUnbounded( + 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(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 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 _enqueueLoad; +private readonly Action _enqueueUnload; +private readonly Func> _drainCompletions; +private readonly Action _applyTerrain; +private readonly Action? _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 enqueueLoad, + Action enqueueUnload, + Func> drainCompletions, + Action applyTerrain, + GpuWorldState state, + int nearRadius, + int farRadius, + Action? 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(); + var completions = new List(); + 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() }, + new WorldEntity { Id = 2, MeshRefs = System.Array.Empty() }, + }); + 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() } }); + state.AddLandblock(lb); + + state.AddEntitiesToExistingLandblock(0xAAAA_FFFF, new[] + { + new WorldEntity { Id = 2, MeshRefs = System.Array.Empty() }, + new WorldEntity { Id = 3, MeshRefs = System.Array.Empty() }, + }); + + 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()); + _pendingByLandblock.Remove(landblockId); + RebuildFlatView(); +} + +public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList 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(); + _pendingByLandblock[landblockId] = bucket; + } + bucket.AddRange(entities); + return; + } + var merged = new List(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 Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? 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 Entities, + IReadOnlyDictionary? 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(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(); + for (int i = 0; i < 1000; i++) + entities.Add(new WorldEntity { Id = (uint)i, MeshRefs = System.Array.Empty() }); + + var animatedById = new Dictionary { [42] = entities[42] }; + var animatedSet = new HashSet { 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)entities, + AnimatedById: (IReadOnlyDictionary?)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(), + }; + 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 +git commit -m "feat(A.5 T22): fog params wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars" +``` + +--- + +## 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:** at Holtburg dueling field, NearRadius=5, FarRadius=5, +30s standstill. + +| Subsystem | cpu_us median | cpu_us p95 | +|---|---|---| +| Entity dispatcher | | | +| Terrain dispatcher | | | + +Frame time: ms median. +Effective FPS: . + +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:** , full A.5. + +### Standstill (30s, Holtburg dueling field) + +| Subsystem | cpu_us median | cpu_us p95 | +|---|---|---| +| Entity dispatcher | | | +| Terrain dispatcher | | | + +Frame time: ms median, ms p99. +Effective FPS: 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 | | | +| Terrain dispatcher | | | + +Frame time: ms median, ms p95. +Effective FPS: 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: . 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 )" +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 .** + + + +## Three high-value gotchas surfaced during A.5 + +1. +2. +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 . 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 ms (target ≤ 4.166ms = 240Hz). p99 ms. +- Walking Holtburg → North Yanshi (60s): + median FPS (target ≥ 144 FPS). p95 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) +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.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 `` 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. From d67d16fcfc295d030b74b92a5b6c156059d5c079 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:15:57 +0200 Subject: [PATCH 03/45] feat(A.5 T1): LandblockStreamTier + LandblockStreamJobKind enums Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/LandblockStreamTier.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/AcDream.App/Streaming/LandblockStreamTier.cs diff --git a/src/AcDream.App/Streaming/LandblockStreamTier.cs b/src/AcDream.App/Streaming/LandblockStreamTier.cs new file mode 100644 index 0000000..c4a9e5d --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockStreamTier.cs @@ -0,0 +1,28 @@ +namespace AcDream.App.Streaming; + +/// +/// Streaming-tier classification for a landblock. means +/// terrain mesh only; means terrain + scenery + EnvCells + +/// entity registration with the WB dispatcher. Per Phase A.5 spec §3. +/// +public enum LandblockStreamTier +{ + Far, + Near, +} + +/// +/// What work the streaming worker should perform for a given job. Distinct +/// from because +/// reads only the entity layer (terrain mesh already loaded), while +/// reads everything from scratch. Per Phase A.5 spec §4.3. +/// +public enum LandblockStreamJobKind +{ + /// Read LandBlock heightmap, build mesh, no entity layer. + LoadFar, + /// Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer. + LoadNear, + /// Read LandBlockInfo + scenery only — terrain already loaded for this LB. + PromoteToNear, +} From 90a2027d1437112f8cc8691caff154d19f997f7d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:20:48 +0200 Subject: [PATCH 04/45] feat(A.5 T2): TwoTierDiff record + LandblockStreamJob.Load.Kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TwoTierDiff — the five-list output of StreamingRegion.RecenterTo (ToLoadFar/Near, ToPromote, ToDemote, ToUnload) per spec §4.2. Used by T3–T6 (StreamingRegion) and T13 (StreamingController). Extends LandblockStreamJob.Load with a LandblockStreamJobKind parameter so the streaming worker can route far vs near vs promote jobs differently (spec §4.3). Patches the one call site in LandblockStreamer.EnqueueLoad with LoadNear as a placeholder that preserves today's full-load semantics until T11 activates the worker thread and T16 routes by tier. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/LandblockStreamJob.cs | 2 +- src/AcDream.App/Streaming/LandblockStreamer.cs | 2 +- src/AcDream.App/Streaming/TwoTierDiff.cs | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.App/Streaming/TwoTierDiff.cs diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index aff6500..e5b9602 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -10,7 +10,7 @@ namespace AcDream.App.Streaming; /// public abstract record LandblockStreamJob(uint LandblockId) { - public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId); + public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); } diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index fff7fc6..a325fb6 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -88,7 +88,7 @@ public sealed class LandblockStreamer : IDisposable // Synchronous mode: invoke the load delegate inline. The result lands // in the outbox and DrainCompletions picks it up later in the same // (or next) frame. - HandleJob(new LandblockStreamJob.Load(landblockId)); + HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear)); } public void EnqueueUnload(uint landblockId) diff --git a/src/AcDream.App/Streaming/TwoTierDiff.cs b/src/AcDream.App/Streaming/TwoTierDiff.cs new file mode 100644 index 0000000..2a24dab --- /dev/null +++ b/src/AcDream.App/Streaming/TwoTierDiff.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace AcDream.App.Streaming; + +/// +/// Output of for the two-tier model. +/// Five disjoint lists describe what changed since the previous Tick. Per +/// Phase A.5 spec §4.2. +/// +public readonly record struct TwoTierDiff( + IReadOnlyList ToLoadFar, // entered far window from null (terrain only) + IReadOnlyList ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport) + IReadOnlyList ToPromote, // entered near window from far-resident (entities only) + IReadOnlyList ToDemote, // exited near window past hysteresis (drop entities) + IReadOnlyList ToUnload); // exited far window past hysteresis (drop terrain) From 21550ecff283a3fd742eed6ed0407d58eaa1e3e9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:25:26 +0200 Subject: [PATCH 05/45] fix(A.5 T2): document Kind placeholder in HandleJob Code review on commit 90a2027 flagged that HandleJob silently ignores load.Kind. Add a TODO(A.5 T11/T16) comment at the case arm so the unused field reads as a planned stub, not a bug. No semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/LandblockStreamer.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index a325fb6..b79946a 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -157,6 +157,11 @@ public sealed class LandblockStreamer : IDisposable switch (job) { case LandblockStreamJob.Load load: + // TODO(A.5 T11/T16): route by load.Kind. LoadFar will skip + // LandBlockInfo + scenery generation; PromoteToNear will skip + // mesh build (terrain already on GPU). Today every Kind takes + // the full-load path via _loadLandblock, which matches today's + // single-tier semantics. try { var lb = _loadLandblock(load.LandblockId); From 7fd9c829549f104b7885652557133e86b88572bf Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:27:50 +0200 Subject: [PATCH 06/45] test(A.5 T3): StreamingRegion two-radius constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NearRadius/FarRadius properties and a four-arg constructor (centerX, centerY, nearRadius, farRadius). Radius is set to farRadius so existing hysteresis math (unload threshold = Radius+2) uses the outer ring as the bookkeeping boundary. Old three-arg constructor becomes a thin wrapper: this(cx, cy, radius, radius) — no behaviour change, 25 pre-existing streaming tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/StreamingRegion.cs | 18 ++++++++++++------ .../Streaming/StreamingRegionTwoTierTests.cs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs index b28b547..bcebe44 100644 --- a/src/AcDream.App/Streaming/StreamingRegion.cs +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -10,9 +10,11 @@ namespace AcDream.App.Streaming; /// public sealed class StreamingRegion { - public int CenterX { get; private set; } - public int CenterY { get; private set; } - public int Radius { get; } + public int CenterX { get; private set; } + public int CenterY { get; private set; } + public int Radius { get; } + public int NearRadius { get; } + public int FarRadius { get; } // Strictly the (2r+1)×(2r+1) window (clamped to world bounds). private readonly HashSet _visible = new(); @@ -43,12 +45,16 @@ public sealed class StreamingRegion /// public IReadOnlyCollection Resident => _resident; - public StreamingRegion(int cx, int cy, int radius) + public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius) { - Radius = radius; - Recenter(cx, cy); + NearRadius = nearRadius; + FarRadius = farRadius; + Radius = farRadius; // outer ring drives Resident bookkeeping + Recenter(centerX, centerY); } + public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { } + private void Recenter(int cx, int cy) { CenterX = cx; diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs new file mode 100644 index 0000000..ccf8f13 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -0,0 +1,18 @@ +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); + } +} From 378f32ac7ab057fc639f7c14c216dae9d50e7861 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:30:30 +0200 Subject: [PATCH 07/45] fix(A.5 T3): pin Radius==FarRadius invariant in two-tier ctor test Code review on commit 7fd9c82 flagged that the test asserted NearRadius, FarRadius, CenterX, CenterY but not the load-bearing alias Radius == FarRadius. That alias is what makes the existing hysteresis math (Radius+2 unload threshold) correctly target the far-tier boundary. Future typos would silently break far-tier hysteresis. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/StreamingRegionTwoTierTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index ccf8f13..65df093 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -14,5 +14,10 @@ public class StreamingRegionTwoTierTests Assert.Equal(12, region.FarRadius); Assert.Equal(100, region.CenterX); Assert.Equal(100, region.CenterY); + // Radius (used by existing single-radius hysteresis math) must alias to + // FarRadius — the outer ring drives "everything currently loaded" bookkeeping. + // If a future change mistakenly aliases Radius to NearRadius, hysteresis + // becomes (NearRadius+2) for the far-tier unload, which is wrong. + Assert.Equal(region.FarRadius, region.Radius); } } From 7bcababf82e30598d63834e49f2377ea56188611 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:34:55 +0200 Subject: [PATCH 08/45] feat(A.5 T4): StreamingRegion ComputeFirstTickDiff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first-tick bootstrap diff: ToLoadNear for the (2*near+1)^2 inner window, ToLoadFar for the outer annulus up to FarRadius. Uses Chebyshev distance, matching existing Recenter convention. Also renames the single-tier RecenterTo → RecenterToSingleTier to free the canonical name for the upcoming two-tier overload (T5). Updates StreamingRegionTests and StreamingController to call the renamed method. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/StreamingController.cs | 2 +- src/AcDream.App/Streaming/StreamingRegion.cs | 36 ++++++++++++++++++- .../Streaming/StreamingRegionTests.cs | 8 ++--- .../Streaming/StreamingRegionTwoTierTests.cs | 14 ++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 67ed631..c320429 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -79,7 +79,7 @@ public sealed class StreamingController } else if (_region.CenterX != observerCx || _region.CenterY != observerCy) { - var diff = _region.RecenterTo(observerCx, observerCy); + var diff = _region.RecenterToSingleTier(observerCx, observerCy); foreach (var id in diff.ToLoad) _enqueueLoad(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs index bcebe44..7d0fc57 100644 --- a/src/AcDream.App/Streaming/StreamingRegion.cs +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -87,13 +87,47 @@ public sealed class StreamingRegion internal static uint EncodeLandblockId(int lbX, int lbY) => ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFFu; + /// + /// 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 on the first call before any + /// RecenterTo. + /// + public TwoTierDiff ComputeFirstTickDiff() + { + var near = new List(); + var far = new List(); + 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(), + ToDemote: System.Array.Empty(), + ToUnload: System.Array.Empty()); + } + /// /// Recompute the visible window around a new center and return the /// delta vs. the previous state. Hysteresis: landblocks aren't unloaded /// until they're further than Radius + 2 from the new center, /// so boundary crossings don't thrash. /// - public RegionDiff RecenterTo(int newCx, int newCy) + public RegionDiff RecenterToSingleTier(int newCx, int newCy) { // Snapshot the old resident set so we can diff against it. var oldResident = new HashSet(_resident); diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs index 741ea2b..899291e 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs @@ -36,7 +36,7 @@ public class StreamingRegionTests { var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); - var diff = region.RecenterTo(50, 50); + var diff = region.RecenterToSingleTier(50, 50); Assert.Empty(diff.ToLoad); Assert.Empty(diff.ToUnload); @@ -52,7 +52,7 @@ public class StreamingRegionTests // the radius+2 threshold, so it stays loaded (hysteresis keeps radius+2). var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); - var diff = region.RecenterTo(51, 50); + var diff = region.RecenterToSingleTier(51, 50); Assert.Equal(5, diff.ToLoad.Count); Assert.Empty(diff.ToUnload); @@ -71,7 +71,7 @@ public class StreamingRegionTests // x=48 is now 5 away, > radius+2 = 4 → unload. x=49 is 4 away, not > 4 → keep. x=50 is 3 away, not > 4 → keep. var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); - var diff = region.RecenterTo(53, 50); + var diff = region.RecenterToSingleTier(53, 50); Assert.Equal(15, diff.ToLoad.Count); Assert.Equal(5, diff.ToUnload.Count); @@ -82,7 +82,7 @@ public class StreamingRegionTests { var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); - var diff = region.RecenterTo(200, 200); + var diff = region.RecenterToSingleTier(200, 200); Assert.Equal(25, diff.ToLoad.Count); Assert.Equal(25, diff.ToUnload.Count); diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index 65df093..105b224 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -20,4 +20,18 @@ public class StreamingRegionTwoTierTests // becomes (NearRadius+2) for the far-tier unload, which is wrong. Assert.Equal(region.FarRadius, region.Radius); } + + [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); + } } From fb6b61e8ef689478528a24e763d703eab42d424a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:36:20 +0200 Subject: [PATCH 09/45] feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking Adds TierResidence enum (None/Far/Near), _tierResidence dictionary seeded by MarkResidentFromBootstrap, and the canonical two-tier RecenterTo overload returning TwoTierDiff. Pass 1 walks the new far window and emits ToLoadFar / ToLoadNear / ToPromote; Pass 2 walks prior residents and emits ToDemote / ToUnload using Chebyshev hysteresis thresholds (NearRadius+2 / FarRadius+2). EncodeLandblockIdForTest exposes the encoding rule to test assemblies. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/StreamingRegion.cs | 147 +++++++++++++++++- .../Streaming/StreamingRegionTwoTierTests.cs | 19 +++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs index 7d0fc57..b4c1056 100644 --- a/src/AcDream.App/Streaming/StreamingRegion.cs +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace AcDream.App.Streaming; @@ -22,6 +23,9 @@ public sealed class StreamingRegion // Everything currently loaded: window + hysteresis-retained landblocks. private readonly HashSet _resident = new(); + // Two-tier residence tracking: maps each resident LB to its current tier. + private readonly Dictionary _tierResidence = new(); + /// /// Landblock IDs in the current visible window in the AC 8.8 coordinate /// form: (lbX << 24) | (lbY << 16) | 0xFFFF. The trailing @@ -121,6 +125,142 @@ public sealed class StreamingRegion ToUnload: System.Array.Empty()); } + /// + /// Call once after to seed + /// _tierResidence with the initial window. Every LB in the inner + /// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far. + /// + public void MarkResidentFromBootstrap() + { + _tierResidence.Clear(); + 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; + } + } + } + + /// + /// Test-visible wrapper around so test + /// assemblies can build expected IDs without duplicating the encoding rule. + /// + internal static uint EncodeLandblockIdForTest(int lbX, int lbY) + => EncodeLandblockId(lbX, lbY); + + /// + /// Two-tier recenter: 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. Requires (or a prior + /// call to this method) to have seeded _tierResidence. + /// + public TwoTierDiff RecenterTo(int newCx, int newCy) + { + int nearUnloadThreshold = NearRadius + 2; + int farUnloadThreshold = FarRadius + 2; + + var toLoadFar = new List(); + var toLoadNear = new List(); + var toPromote = new List(); + var toDemote = new List(); + var toUnload = new List(); + + // Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote. + var newCenterIds = new HashSet(); + 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)) + { + // Not resident at all — fresh load. + if (inNear) toLoadNear.Add(id); + else toLoadFar.Add(id); + _tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far; + } + else if (current == TierResidence.Far && inNear) + { + // Was Far, now inside Near ring — promote. + toPromote.Add(id); + _tierResidence[id] = TierResidence.Near; + } + // Near→Near and Far→Far are no-ops. + } + } + + // Pass 2: check previously-resident LBs for demote / unload. + 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); // Chebyshev + + if (newCenterIds.Contains(id)) + { + // Still in the far window — only Near→Far demote possible here. + if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius)) + { + if (distance > nearUnloadThreshold) + { + toDemote.Add(id); + _tierResidence[id] = TierResidence.Far; + } + } + continue; + } + + // Outside the new window — demote / unload by threshold. + 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); + } + /// /// Recompute the visible window around a new center and return the /// delta vs. the previous state. Hysteresis: landblocks aren't unloaded @@ -166,7 +306,7 @@ public sealed class StreamingRegion } /// -/// Output of : the landblocks to +/// Output of : the landblocks to /// start loading (newly entered the visible window) and the landblocks to /// unload (fell outside the unload threshold, which is Radius + 2). /// Both lists are disjoint from the current @@ -175,3 +315,8 @@ public sealed class StreamingRegion public readonly record struct RegionDiff( IReadOnlyList ToLoad, IReadOnlyList ToUnload); + +/// +/// Tracks which tier a landblock currently occupies in the two-tier streaming model. +/// +internal enum TierResidence { None, Far, Near } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index 105b224..0d6f5b0 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -34,4 +34,23 @@ public class StreamingRegionTwoTierTests Assert.Empty(diff.ToDemote); Assert.Empty(diff.ToUnload); } + + [Fact] + public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Walk one LB east — center (100,100) → (101,100). LB column at lbX=104 + // (relative dx=+3 from new center) enters the far window from null. + var diff = region.RecenterTo(newCx: 101, newCy: 100); + + foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 }) + { + var id = StreamingRegion.EncodeLandblockIdForTest(104, y); + Assert.Contains(id, diff.ToLoadFar); + } + Assert.Empty(diff.ToLoadNear); + } } From 326b698161de064b262f9bb71b625202f2c5d27b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:39:16 +0200 Subject: [PATCH 10/45] test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage Adds 5 tests to StreamingRegionTwoTierTests covering all tier-transition paths: - FarToNear promote (walk 2 east from initial center) - NullToNear teleport (loads 9 near + 40 far for a fully fresh region) - NearToFar demote only after NearRadius+2 hysteresis threshold - FarToNull unload only after FarRadius+2 hysteresis threshold - oscillation no-thrash: bouncing 1 LB across a near boundary fires 0 demotes and at most 5 promotes total (one initial settle of the x=100 near-column) Oscillation test fix: initialise the region at the oscillation midpoint (103,100) rather than at a distant starting center (100,100) so the initial move into the oscillation range doesn't itself trigger legitimate demotes, isolating the no-thrash invariant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/StreamingRegionTwoTierTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index 0d6f5b0..19364cf 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -53,4 +53,107 @@ public class StreamingRegionTwoTierTests } Assert.Empty(diff.ToLoadNear); } + + [Fact] + public void RecenterTo_PlayerWalks_FarToNear_AppearsInToPromote() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Walk 2 east — center (102, 100). LB (102, 100) was at distance 2 (Far) + // from (100,100); now at distance 0 → Near. That's a Promote. + var diff = region.RecenterTo(newCx: 102, newCy: 100); + + var promotedId = StreamingRegion.EncodeLandblockIdForTest(102, 100); + Assert.Contains(promotedId, diff.ToPromote); + Assert.DoesNotContain(promotedId, diff.ToLoadNear); + Assert.DoesNotContain(promotedId, diff.ToLoadFar); + } + + [Fact] + public void RecenterTo_PlayerTeleports_NullToNear_AppearsInToLoadNear() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Teleport to (200, 200) — entirely new region. + var diff = region.RecenterTo(newCx: 200, newCy: 200); + + Assert.Equal(9, diff.ToLoadNear.Count); + Assert.Equal(40, diff.ToLoadFar.Count); + Assert.Empty(diff.ToPromote); + } + + [Fact] + public void RecenterTo_NearToFar_DemoteOnlyAfterHysteresis() + { + // near=2, far=4 → near hysteresis threshold = 4. + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // LB (100,100) was Near. Walk 3 east → distance 3 > NearRadius=2 but ≤ 4. No demote yet. + var diff1 = region.RecenterTo(newCx: 103, newCy: 100); + var lb100 = StreamingRegion.EncodeLandblockIdForTest(100, 100); + Assert.DoesNotContain(lb100, diff1.ToDemote); + + // Walk 2 more east → distance 5 > 4. Demote. + var diff2 = region.RecenterTo(newCx: 105, newCy: 100); + Assert.Contains(lb100, diff2.ToDemote); + } + + [Fact] + public void RecenterTo_FarToNull_UnloadOnlyAfterHysteresis() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // LB (97, 100) was at distance 3 (Far). Walk 1 east → distance 4. ≤ FarRadius+2=5. + var diff1 = region.RecenterTo(newCx: 101, newCy: 100); + var lb97 = StreamingRegion.EncodeLandblockIdForTest(97, 100); + Assert.DoesNotContain(lb97, diff1.ToUnload); + + // Walk 2 more east → distance 6 > 5. Unload. + var diff2 = region.RecenterTo(newCx: 103, newCy: 100); + Assert.Contains(lb97, diff2.ToUnload); + } + + [Fact] + public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis() + { + // Start the region centered on (103,100) so the oscillation + // between (102,100) and (103,100) never crosses a hysteresis boundary. + // NearRadius=2, farRadius=4 → nearUnloadThreshold=4. + // Chebyshev distance from (102,100) or (103,100) to any LB in the + // initial 9×9 window of (103,100) is ≤ NearRadius+2=4 for all LBs + // in the near zone, so no demotes should fire during pure oscillation. + var region = new StreamingRegion(centerX: 103, centerY: 100, nearRadius: 2, farRadius: 4); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Bounce between (103,100) and (102,100). All resident LBs stay + // within the hysteresis window — no demotes expected. + int totalDemotes = 0; + int totalPromotes = 0; + for (int i = 0; i < 5; i++) + { + var d1 = region.RecenterTo(102, 100); + totalDemotes += d1.ToDemote.Count; + totalPromotes += d1.ToPromote.Count; + var d2 = region.RecenterTo(103, 100); + totalDemotes += d2.ToDemote.Count; + totalPromotes += d2.ToPromote.Count; + } + + // The first step from (103,100) to (102,100) legitimately promotes the + // x=100 near-column (5 LBs) that were Far from (103) into Near. After + // that initial settle they stay Near for all subsequent oscillations. + // So the ceiling is 5 promotes total (not per oscillation). + Assert.Equal(0, totalDemotes); + Assert.True(totalPromotes <= 5, + $"Expected ≤5 promotes across 5 oscillations; got {totalPromotes}"); + } } From 16588824394450c56aadbaad18bf6192b7e16b44 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:49:35 +0200 Subject: [PATCH 11/45] fix(A.5 T4-T6): bootstrap guard + dead enum + test cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on commits 7bcabab/fb6b61e/326b698 flagged 2 Important + 4 Minor issues. Apply all fixes: Important: - Two-tier RecenterTo + MarkResidentFromBootstrap now throw InvalidOperationException on misuse — calling RecenterTo before the bootstrap silently emitted the entire window as fresh loads (no demotes/unloads since _tierResidence was empty), a correctness hazard that produced no exception. Calling MarkResidentFromBootstrap twice silently dropped accumulated tier state. Both now crash loudly via a _bootstrapped flag. - Dropped TierResidence.None from the enum — never assigned, never checked; absence from the dictionary already encodes "not resident." Minor: - Renamed test: RecenterTo_FirstTick_* → ComputeFirstTickDiff_FirstTick_* (the test calls ComputeFirstTickDiff, not RecenterTo). - Strengthened RecenterTo_PlayerWalks_NullToFar_* with assertions for ToPromote.Count==3 (the x=102 column promoting Far→Near) and ToUnload.Empty (everything within hysteresis). - Replaced System.Math.Abs with Math.Abs in new code to match the file's existing `using System;` convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/StreamingRegion.cs | 41 +++++++++++++++---- .../Streaming/StreamingRegionTwoTierTests.cs | 9 +++- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs index b4c1056..01eb85d 100644 --- a/src/AcDream.App/Streaming/StreamingRegion.cs +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -26,6 +26,13 @@ public sealed class StreamingRegion // Two-tier residence tracking: maps each resident LB to its current tier. private readonly Dictionary _tierResidence = new(); + // Set true after MarkResidentFromBootstrap. The two-tier RecenterTo + // requires this state to be seeded; calling RecenterTo before the + // bootstrap silently emits the entire window as fresh loads (no demotes, + // no unloads, since _tierResidence is empty), which is a correctness + // hazard. The flag converts that into a loud InvalidOperationException. + private bool _bootstrapped; + /// /// Landblock IDs in the current visible window in the AC 8.8 coordinate /// form: (lbX << 24) | (lbY << 16) | 0xFFFF. The trailing @@ -132,6 +139,12 @@ public sealed class StreamingRegion /// public void MarkResidentFromBootstrap() { + if (_bootstrapped) + throw new InvalidOperationException( + "MarkResidentFromBootstrap was already called; calling it again would " + + "reset accumulated tier-residence state and silently drop differential " + + "data built up by interim RecenterTo calls."); + _tierResidence.Clear(); for (int dx = -FarRadius; dx <= FarRadius; dx++) { @@ -140,14 +153,15 @@ public sealed class StreamingRegion 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); + int absDx = Math.Abs(dx); + int absDy = Math.Abs(dy); var id = EncodeLandblockId(nx, ny); _tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius) ? TierResidence.Near : TierResidence.Far; } } + _bootstrapped = true; } /// @@ -165,6 +179,13 @@ public sealed class StreamingRegion /// public TwoTierDiff RecenterTo(int newCx, int newCy) { + if (!_bootstrapped) + throw new InvalidOperationException( + "Two-tier RecenterTo called before MarkResidentFromBootstrap. " + + "First call ComputeFirstTickDiff to enqueue the bootstrap loads, " + + "then MarkResidentFromBootstrap to seed _tierResidence, then RecenterTo " + + "for subsequent observer moves."); + int nearUnloadThreshold = NearRadius + 2; int farUnloadThreshold = FarRadius + 2; @@ -183,8 +204,8 @@ public sealed class StreamingRegion 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); + int absDx = Math.Abs(dx); + int absDy = Math.Abs(dy); bool inNear = absDx <= NearRadius && absDy <= NearRadius; var id = EncodeLandblockId(nx, ny); newCenterIds.Add(id); @@ -213,9 +234,9 @@ public sealed class StreamingRegion 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); // Chebyshev + int absDx = Math.Abs(lbX - newCx); + int absDy = Math.Abs(lbY - newCy); + int distance = Math.Max(absDx, absDy); // Chebyshev if (newCenterIds.Contains(id)) { @@ -317,6 +338,8 @@ public readonly record struct RegionDiff( IReadOnlyList ToUnload); /// -/// Tracks which tier a landblock currently occupies in the two-tier streaming model. +/// Tracks which tier a landblock currently occupies in the two-tier streaming +/// model. Absence from the dictionary encodes "not resident"; the enum has no +/// None member to avoid suggesting a third runtime state. /// -internal enum TierResidence { None, Far, Near } +internal enum TierResidence { Far, Near } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index 19364cf..5891245 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -22,7 +22,7 @@ public class StreamingRegionTwoTierTests } [Fact] - public void RecenterTo_FirstTick_SplitsLoadIntoNearAndFar() + public void ComputeFirstTickDiff_FirstTick_SplitsLoadIntoNearAndFar() { // near=1, far=3 → near window is 3×3=9, far window is 7×7-3×3=40 LBs. var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); @@ -52,6 +52,13 @@ public class StreamingRegionTwoTierTests Assert.Contains(id, diff.ToLoadFar); } Assert.Empty(diff.ToLoadNear); + // The 3 LBs at x=102, y in {99,100,101} were Far from old center + // (distance 2) and are now Near from new center (distance ≤1). + // They should land in ToPromote. + Assert.Equal(3, diff.ToPromote.Count); + // All resident LBs from the old window are within hysteresis of + // the new center (max distance 4 ≤ FarRadius+2=5), so nothing unloads. + Assert.Empty(diff.ToUnload); } [Fact] From 295bce9bb2a4868d7bab72fb0aff7420dec20e72 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:53:07 +0200 Subject: [PATCH 12/45] feat(A.5 T7): LandblockStreamResult.Loaded.Tier+MeshData; Promoted variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Loaded result record with a LandblockStreamTier discriminator and a LandblockMeshData payload (default! stub — T13 wires the real off-thread mesh build). Adds the Promoted variant for Far→Near upgrades that only need the entity layer, not a mesh rebuild. LandblockStreamer.HandleJob passes Tier.Near + default! MeshData at the existing synchronous load site; StreamingControllerTests updated to match the new positional signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/LandblockStreamJob.cs | 26 ++++++++++++++++++- .../Streaming/LandblockStreamer.cs | 6 ++++- .../Streaming/StreamingControllerTests.cs | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index e5b9602..dfc837d 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using AcDream.Core.Terrain; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -22,7 +24,29 @@ public abstract record LandblockStreamJob(uint LandblockId) /// public abstract record LandblockStreamResult(uint LandblockId) { - public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId); + /// + /// A landblock load completed. distinguishes Far + /// (terrain only) from Near (terrain + entities). + /// is built off the render thread on the streaming worker. + /// + public sealed record Loaded( + uint LandblockId, + LandblockStreamTier Tier, + LoadedLandblock Landblock, + LandblockMeshData MeshData + ) : LandblockStreamResult(LandblockId); + + /// + /// 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. + /// + public sealed record Promoted( + uint LandblockId, + IReadOnlyList Entities + ) : LandblockStreamResult(LandblockId); + public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId); public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId); diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index b79946a..4f41486 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -169,8 +169,12 @@ public sealed class LandblockStreamer : IDisposable _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( load.LandblockId, "LandblockLoader.Load returned null")); else + // TEMPORARY: passes default! for MeshData — Task 13 wires the real mesh build. _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( - load.LandblockId, lb)); + load.LandblockId, + LandblockStreamTier.Near, + lb, + MeshData: default! /* TODO(A.5 T13) */)); } catch (Exception ex) { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index f7fa328..9b7fdcb 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -78,7 +78,7 @@ public class StreamingControllerTests // Entities (positional record). Adjust if the first positional arg // name differs. var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); - fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, lb)); + fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, MeshData: default!)); controller.Tick(50, 50); From a0741bd13a5a699518b67dff5002171a0394b36c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:54:25 +0200 Subject: [PATCH 13/45] feat(A.5 T8): WorldEntity AABB cache + dirty flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AabbMin/AabbMax (per-entity world-space bounding box) and AabbDirty flag to WorldEntity. RefreshAabb() recomputes the box from Position ±5 m (DefaultAabbRadius). SetPosition() writes Position and marks the cache dirty so the dispatcher calls RefreshAabb on first read rather than carrying stale bounds. AabbDirty defaults to true on construction — freshly-built entities have zero AabbMin/AabbMax until RefreshAabb is called. Two new conformance tests verify the ±5 m geometry and the dirty/clean state machine. Per Phase A.5 spec §4.6 Change #2. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/WorldEntity.cs | 24 ++++++++++ .../World/WorldEntityAabbTests.cs | 47 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index d1dfed4..20643d3 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -71,6 +71,30 @@ public sealed class WorldEntity /// present. Zero (no parts hidden) is the default. /// public ulong HiddenPartsMask { get; init; } + + // 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. + // AabbDirty starts true so the dispatcher calls RefreshAabb on first read + // (AabbMin/AabbMax are Vector3.Zero until refreshed). + 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; + } } /// diff --git a/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs b/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs new file mode 100644 index 0000000..cafa60e --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs @@ -0,0 +1,47 @@ +using System.Numerics; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.World; + +public class WorldEntityAabbTests +{ + [Fact] + public void Aabb_DefaultRadius_PositionPlusMinus5() + { + var entity = new WorldEntity + { + Id = 1, + SourceGfxObjOrSetupId = 0, + Position = new Vector3(10, 20, 30), + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + entity.RefreshAabb(); + + Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin); + Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax); + } + + [Fact] + public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh() + { + var entity = new WorldEntity + { + Id = 1, + SourceGfxObjOrSetupId = 0, + Position = new Vector3(10, 20, 30), + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + 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); + } +} From 4be392b3614695196a023020a74728414e07221d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:55:53 +0200 Subject: [PATCH 14/45] refactor(A.5 T9): _surfaceCache -> ConcurrentDictionary for off-thread mesh build Widens LandblockMesh.Build's surfaceCache parameter from Dictionary to IDictionary so any IDictionary implementation compiles at call sites. Switches GameWindow._surfaceCache from Dictionary to ConcurrentDictionary so T11's streaming worker can call Build off the render thread without a lock. The TryGetValue+assign lookup inside Build is not atomic, but BuildSurface is deterministic (same palCode -> same SurfaceInfo), making last-write-wins under concurrent access benign. Comment added at the pattern site. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 6 ++++-- src/AcDream.Core/Terrain/LandblockMesh.cs | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c2aae70..7c53a03 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -111,7 +111,9 @@ public sealed class GameWindow : IDisposable // LandblockMesh.Build without re-deriving these each time. private float[]? _heightTable; private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx; - private Dictionary? _surfaceCache; + // Was: Dictionary. ConcurrentDictionary so the off-thread + // mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock. + private System.Collections.Concurrent.ConcurrentDictionary? _surfaceCache; // Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes // (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains @@ -1465,7 +1467,7 @@ public sealed class GameWindow : IDisposable RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); _heightTable = heightTable; - _surfaceCache = new Dictionary(); + _surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary(); // (Bindless detection moved above — must precede TerrainAtlas.Build / // TerrainModernRenderer ctor so they can consume BindlessSupport.) diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 573acf5..81e6724 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -46,7 +46,7 @@ public static class LandblockMesh uint landblockY, float[] heightTable, TerrainBlendingContext ctx, - Dictionary surfaceCache) + System.Collections.Generic.IDictionary surfaceCache) { ArgumentNullException.ThrowIfNull(block); ArgumentNullException.ThrowIfNull(heightTable); @@ -105,6 +105,10 @@ public static class LandblockMesh uint palCode = TerrainBlending.GetPalCode( rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL); + // Lookup-or-build pattern. Not atomic under concurrent access + // (TryGetValue then assign), but BuildSurface is deterministic — + // two workers building the same palCode produce equal SurfaceInfo, + // last-write-wins is benign. if (!surfaceCache.TryGetValue(palCode, out var surf)) { surf = TerrainBlending.BuildSurface(palCode, ctx); From c5f98b276ed757c417f2dfd2966d4b3c3478d96b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:25:07 +0200 Subject: [PATCH 15/45] =?UTF-8?q?fix(A.5=20T7-T9):=20migrate=20entity.Posi?= =?UTF-8?q?tion=3D=20=E2=86=92=20SetPosition;=20add=20Promoted=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on commits 295bce9/a0741bd/4be392b flagged 1 Important + 3 Minor issues. Apply the actionable two: Important: 6 sites in GameWindow.cs (lines 3900, 4017-4024, 4138, 4270, 4315) wrote entity.Position = X directly, bypassing T8's SetPosition mutator and therefore never marking AabbDirty. When T18 lands the dispatcher's "if AabbDirty refresh" cull gate, these direct writes would silently leave AABB stale (frustum culls dynamic entities at their previous positions). Migrated all 6 sites to SetPosition(). Minor: Added a silent case LandblockStreamResult.Promoted arm in StreamingController.Tick with a TODO(A.5 T13) marker. Today the streamer never produces Promoted, so the arm is unreachable; the explicit case prevents a future reader from wondering why the case is missing. Deferred Minor: surfaceCache thread-safety XML doc comment + style consistency on System.Collections.Generic using directive — non- load-bearing cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 14 +++++++------- src/AcDream.App/Streaming/StreamingController.cs | 6 ++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7c53a03..2e3e849 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3897,7 +3897,7 @@ public sealed class GameWindow : IDisposable // position by adding the residual back (so the visual doesn't jerk // for one frame before the residual decay kicks in on the next tick). System.Numerics.Vector3 preSnapPos = entity.Position; - entity.Position = worldPos; + entity.SetPosition(worldPos); entity.Rotation = rot; // Commit B 2026-04-29 — keep the shadow registry in sync with @@ -4017,11 +4017,11 @@ public sealed class GameWindow : IDisposable if (!update.IsGrounded) { // Undo the unconditional entity hard-snap at the top of the - // function (entity.Position = worldPos): the body is mid-arc + // function (entity.SetPosition(worldPos)): the body is mid-arc // and TickAnimations will write entity = body next frame // anyway. Setting entity = body now prevents a 1-frame // teleport-to-server-then-yank-back rubber-band. - entity.Position = rmState.Body.Position; + entity.SetPosition(rmState.Body.Position); return; } @@ -4130,12 +4130,12 @@ public sealed class GameWindow : IDisposable } // Sync the visible entity to the body — overrides the unconditional - // entity.Position = worldPos snap at the top of this function. + // entity.SetPosition(worldPos) snap at the top of this function. // For the far-snap branch this is a no-op (body == worldPos); for // the near-enqueue branch this prevents a 1-frame teleport-then- // yank-back rubber-band as TickAnimations chases worldPos via the // queue. - entity.Position = rmState.Body.Position; + entity.SetPosition(rmState.Body.Position); return; } @@ -4267,7 +4267,7 @@ public sealed class GameWindow : IDisposable rmState.ServerVelocity); } - entity.Position = rmState.Body.Position; + entity.SetPosition(rmState.Body.Position); entity.Rotation = rmState.Body.Orientation; } @@ -4312,7 +4312,7 @@ public sealed class GameWindow : IDisposable resolved.Position.X, resolved.Position.Y, resolved.Position.Z); // 3. Snap player entity + controller. - entity.Position = snappedPos; + entity.SetPosition(snappedPos); entity.Rotation = rot; _playerController.SetPosition(snappedPos, resolved.CellId); diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index c320429..53b0030 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -107,6 +107,12 @@ public sealed class StreamingController Console.WriteLine( $"streaming: worker CRASHED: {crashed.Error}"); break; + case LandblockStreamResult.Promoted: + // TODO(A.5 T13): merge promoted entities into existing + // GpuWorldState entry via AddEntitiesToExistingLandblock. + // Today the streamer never produces Promoted (only LoadNear / + // LoadFar), so this arm is unreachable and silently consumed. + break; } } } From 0cf86bb12669fcdd9c8ae424d45173e9abf84586 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:32:23 +0200 Subject: [PATCH 16/45] fix(A.5 T10): serialize DatCollection access via _datLock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A.5 T11 activates the LandblockStreamer worker thread, making concurrent dat reads possible. DatReaderWriter's DatBinReader uses a shared buffer position internally — concurrent _dats.Get calls from worker + render thread corrupt that state and produce half-populated LandBlock.Height[] arrays (renders as wildly distorted terrain). The _datLock field already existed from the Phase A.1 hotfix, and the high-traffic worker-facing paths (BuildLandblockForStreaming, ApplyLoadedTerrain, OnLiveEntitySpawned) already hold it. This commit updates the field comment to precisely document the T10 contract: all worker-thread dat reads enter via factory closures that acquire _datLock; render-thread paths are already covered by their outer lock wrappers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 35 ++++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e3e849..332abdb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -97,13 +97,24 @@ public sealed class GameWindow : IDisposable // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); - // Phase A.1 hotfix: DatCollection is NOT thread-safe. The streaming worker - // thread and the render thread both read dats (BuildLandblockForStreaming - // on the worker; ApplyLoadedTerrain + live-spawn handlers on the render - // thread). Concurrent reads corrupt internal caches and produce - // half-populated LandBlock.Height[] arrays, which caused terrain to render - // as "a giant ball with spikes" before this lock was added. All _dats.Get - // calls that can race with the worker thread MUST acquire this lock. + // Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe. + // DatReaderWriter's DatBinReader uses a shared buffer position internally — + // concurrent _dats.Get calls from the streaming worker thread (T11+) and + // the render thread (BuildLandblockForStreaming on the worker; + // ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render + // thread) corrupt that state and produce half-populated LandBlock.Height[] + // arrays, rendering as "a giant ball with spikes". All _dats.Get call + // sites that can race with the streaming worker MUST hold this lock. + // + // Worker-thread dat reads enter via the factory closures passed to + // LandblockStreamer at construction (loadLandblock + buildMeshOrNull). + // Those closures already acquire _datLock, so no additional wrapping is + // needed for reads inside BuildLandblockForStreamingLocked / + // BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming. + // Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already + // hold this lock via their outer wrappers; all remaining render-thread + // _dats.Get calls run only when no worker dat read can be in flight (during + // initialization or within the same lock scope). private readonly object _datLock = new(); // Terrain build context shared across all streamed landblocks. Stored as @@ -1572,14 +1583,18 @@ public sealed class GameWindow : IDisposable _streamingRadius = r; Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); - // The streamer's load delegate wraps LandblockLoader.Load + stab - // hydration. Scenery + interior will land in Task 8. + // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. + // loadLandblock and buildMeshOrNull are called on the worker; both + // closures acquire _datLock (T10) before touching DatCollection. + // T12 wires the real mesh-build factory below. _streamer = new AcDream.App.Streaming.LandblockStreamer( loadLandblock: id => BuildLandblockForStreaming(id)); _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: _streamer.EnqueueLoad, + // Use a lambda so the Action delegate matches the method + // signature (EnqueueLoad has an optional 'kind' parameter). + enqueueLoad: id => _streamer.EnqueueLoad(id, AcDream.App.Streaming.LandblockStreamJobKind.LoadNear), enqueueUnload: _streamer.EnqueueUnload, drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, From 00bb030c9f933e244d642db9c6fcddf892f3bb87 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:32:35 +0200 Subject: [PATCH 17/45] feat(A.5 T11): activate LandblockStreamer worker thread Phase A.1 reverted to synchronous mode due to DatCollection thread- safety; T10 documented the lock that makes concurrent reads safe. T11 activates the dedicated worker thread and switches enqueue methods to non-blocking Channel.Writer.TryWrite. EnqueueLoad now takes LandblockStreamJobKind (default: LoadNear from all callers, matching previous full-load semantics). T13/T16 will route by kind per TwoTierDiff. Constructor gains optional buildMeshOrNull param (defaults to null- returning stub); T12 wires the real LandblockMesh.Build factory. GameWindow construction site updated: Action enqueueLoad delegate now wraps a lambda (method group won't bind to Action when the method has an optional second param). LandblockStreamerTests updated: the synchronous-thread-pinning test replaced by Load_ExecutesLoaderOnWorkerThread which asserts the loader runs on a different thread; Load_FollowedByDrain now supplies a stubMesh so the worker can produce Loaded (not Failed) results. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/LandblockStreamer.cs | 118 ++++++++++-------- .../Streaming/LandblockStreamerTests.cs | 53 +++++--- 2 files changed, 102 insertions(+), 69 deletions(-) diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index 4f41486..6b08095 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -8,28 +8,27 @@ using AcDream.Core.World; namespace AcDream.App.Streaming; /// -/// Services landblock load/unload requests by invoking a caller-supplied -/// load delegate (the production instance wraps -/// ) and posting results to an outbox -/// the render thread drains once per OnUpdate. +/// Services landblock load/unload requests by invoking caller-supplied +/// factory delegates (the production instance wraps +/// for loading and +/// for the terrain +/// mesh) and posting results to an outbox the render thread drains once +/// per OnUpdate. /// /// -/// Currently runs synchronously on the calling thread. The original -/// Phase A.1 design ran loads on a dedicated worker thread, but DatReaderWriter's -/// DatCollection is not thread-safe — concurrent reads from a worker -/// and the render thread (animation tick, live spawn handlers) corrupt -/// internal buffer state and produce half-populated LandBlock.Height[] -/// arrays which render as wildly distorted terrain. Until Phase A.3 introduces -/// a thread-safe dat wrapper, loads are synchronous: -/// invokes the load delegate inline and writes the result to the outbox in -/// a single call. This causes a frame hitch when crossing landblock -/// boundaries, but the rendering is correct. +/// Thread model (Phase A.5 T11+): spawns a +/// dedicated background worker thread. and +/// write non-blocking to the inbox +/// ; the worker drains it and posts +/// records to the outbox. /// /// /// -/// The Channel-based outbox + API is -/// preserved so the move back to async loading is a single-class change -/// when DatCollection thread safety lands. +/// DatCollection thread safety is provided by the caller: +/// GameWindow's _datLock (Phase A.5 T10) serialises all +/// DatCollection.Get<T> calls. Both factory closures passed at +/// construction acquire that lock before reading dats. The worker never +/// touches DatCollection directly — it only calls the factories. /// /// /// @@ -39,8 +38,9 @@ namespace AcDream.App.Streaming; /// /// /// -/// Threading: synchronous mode means all methods must be called from the -/// same thread (the render thread in production). +/// Threading: must be called from a single +/// consumer thread (the render thread in production). All other public +/// methods are thread-safe. /// /// public sealed class LandblockStreamer : IDisposable @@ -53,49 +53,65 @@ public sealed class LandblockStreamer : IDisposable public const int DefaultDrainBatchSize = 4; private readonly Func _loadLandblock; + private readonly Func _buildMeshOrNull; private readonly Channel _inbox; private readonly Channel _outbox; private readonly CancellationTokenSource _cancel = new(); -#pragma warning disable CS0649 // _worker stays declared for the future async path; unused in synchronous mode. private Thread? _worker; -#pragma warning restore CS0649 private int _disposed; - public LandblockStreamer(Func loadLandblock) + public LandblockStreamer( + Func loadLandblock, + Func? buildMeshOrNull = null) { _loadLandblock = loadLandblock; - _inbox = Channel.CreateUnbounded( + // Default: no mesh build (returns null → Failed result). Production + // wires in LandblockMesh.Build via the T12 construction site. + _buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null); + _inbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); _outbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); } /// - /// No-op in synchronous mode. Preserved on the API surface so callers - /// don't need to change when async loading returns in Phase A.3. + /// Activate the dedicated background worker thread. Idempotent: calling + /// more than once has no effect. /// public void Start() { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - // No worker thread in synchronous mode. + if (_worker != null) return; + _worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "acdream.streaming.worker", + }; + _worker.Start(); } - public void EnqueueLoad(uint landblockId) + /// + /// Non-blocking enqueue. The worker drains the inbox and posts a + /// (or + /// ) to the outbox. + /// + public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - // Synchronous mode: invoke the load delegate inline. The result lands - // in the outbox and DrainCompletions picks it up later in the same - // (or next) frame. - HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear)); + _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind)); } + /// + /// Non-blocking enqueue. The worker posts a + /// to the outbox. + /// public void EnqueueUnload(uint landblockId) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - HandleJob(new LandblockStreamJob.Unload(landblockId)); + _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); } /// @@ -118,17 +134,14 @@ public sealed class LandblockStreamer : IDisposable { try { - // Synchronous read loop via .WaitToReadAsync + ReadAllAsync - // would be idiomatic but requires async; the blocking reader - // is simpler and the thread is dedicated anyway. + // Safe to block: this is a dedicated worker thread with no + // SynchronizationContext, so .Result/.GetResult cannot deadlock + // against any captured continuation. Using the sync pattern + // here keeps the loop linear; an async-enumerable alternative + // would force WorkerLoop to be async Task and lose the + // simple thread-start shape. while (!_cancel.Token.IsCancellationRequested) { - // Safe to block: this is a dedicated worker thread with no - // SynchronizationContext, so .Result/.GetResult cannot deadlock - // against any captured continuation. Using the sync pattern - // here keeps the loop linear; an async-enumerable alternative - // would force WorkerLoop to be async Task and lose the - // simple thread-start shape. if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult()) break; @@ -157,7 +170,7 @@ public sealed class LandblockStreamer : IDisposable switch (job) { case LandblockStreamJob.Load load: - // TODO(A.5 T11/T16): route by load.Kind. LoadFar will skip + // TODO(A.5 T16): route by load.Kind. LoadFar will skip // LandBlockInfo + scenery generation; PromoteToNear will skip // mesh build (terrain already on GPU). Today every Kind takes // the full-load path via _loadLandblock, which matches today's @@ -166,15 +179,22 @@ public sealed class LandblockStreamer : IDisposable { var lb = _loadLandblock(load.LandblockId); if (lb is null) + { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( load.LandblockId, "LandblockLoader.Load returned null")); - else - // 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) */)); + break; + } + var mesh = _buildMeshOrNull(load.LandblockId, lb); + if (mesh is null) + { + _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( + load.LandblockId, "buildMeshOrNull 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) { diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index e058f81..2e11804 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -19,9 +19,13 @@ public class LandblockStreamerTests 0xA9B4FFFEu, new LandBlock(), System.Array.Empty()); + var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( + System.Array.Empty(), + System.Array.Empty()); using var streamer = new LandblockStreamer( - loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null); + loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null, + buildMeshOrNull: (_, _) => stubMesh); streamer.Start(); streamer.EnqueueLoad(0xA9B4FFFEu); @@ -104,37 +108,46 @@ public class LandblockStreamerTests } [Fact] - public void Load_ExecutesLoaderSynchronously_OnCallingThread() + public async Task Load_ExecutesLoaderOnWorkerThread() { - // Streamer was made synchronous after Phase A.1 visual verification - // exposed concurrent dat reads as the cause of "ball of spikes" - // terrain corruption — DatReaderWriter's DatCollection isn't - // thread-safe and locking around every dat read on every render- - // thread code path was too invasive. Until Phase A.3 introduces a - // thread-safe dat wrapper, the load delegate runs on the calling - // thread and the result is in the outbox by the time EnqueueLoad - // returns. This test pins that contract. + // Phase A.5 T11: the load delegate now runs on the dedicated worker + // thread (not the calling/render thread). This test verifies the + // async hand-off: EnqueueLoad returns immediately and the result + // appears in the outbox only after the worker processes the inbox. int testThreadId = System.Environment.CurrentManagedThreadId; int? loaderThreadId = null; var stubLandblock = new LoadedLandblock( 0x77770FFEu, new LandBlock(), System.Array.Empty()); + var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( + System.Array.Empty(), + System.Array.Empty()); - using var streamer = new LandblockStreamer(loadLandblock: id => - { - loaderThreadId = System.Environment.CurrentManagedThreadId; - return stubLandblock; - }); + using var streamer = new LandblockStreamer( + loadLandblock: id => + { + loaderThreadId = System.Environment.CurrentManagedThreadId; + return stubLandblock; + }, + buildMeshOrNull: (_, _) => stubMesh); streamer.Start(); streamer.EnqueueLoad(0x77770FFEu); - // Result is already in the outbox — no spinning needed. - var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); + // Spin until the worker produces a completion. + LandblockStreamResult? result = null; + for (int i = 0; i < SpinMaxIterations && result is null; i++) + { + var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); + if (drained.Count > 0) result = drained[0]; + else await Task.Delay(SpinStepMs); + } - Assert.Single(drained); - Assert.IsType(drained[0]); - Assert.Equal(testThreadId, loaderThreadId); + Assert.NotNull(result); + Assert.IsType(result); + // The loader MUST have run on a different thread than the test thread. + Assert.NotNull(loaderThreadId); + Assert.NotEqual(testThreadId, loaderThreadId.Value); } } From 0405947bace6f5104b9b36f3c670e409de335e5d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:35:45 +0200 Subject: [PATCH 18/45] feat(A.5 T12): inject mesh-build dependency into LandblockStreamer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the T7-temporary default! MeshData placeholder. Streamer now takes Func at construction; the worker calls it after _loadLandblock succeeds and passes the pre-built mesh into LandblockStreamResult.Loaded. GameWindow's buildMeshOrNull factory takes the already-loaded LoadedLandblock (lb.Heightmap is the LandBlock dat object), so no additional dat read is needed — _heightTable and _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). Zero dat lock needed inside the mesh-build closure. StreamingController._applyTerrain delegate signature widened to Action so the pre-built mesh flows render-thread-side via the Loaded result. ApplyLoadedTerrainLocked now accepts meshData and calls _terrain.AddLandblock directly, skipping the per-frame LandblockMesh.Build that previously ran on the render thread (~5ms per LB at radius=12 first traversal). StreamingControllerTests updated: all four applyTerrain lambdas adapted to the two-arg Action signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 55 ++++++++++++------- .../Streaming/StreamingController.cs | 7 ++- .../Streaming/StreamingControllerTests.cs | 8 +-- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 332abdb..741b2a9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1584,11 +1584,24 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. - // loadLandblock and buildMeshOrNull are called on the worker; both - // closures acquire _datLock (T10) before touching DatCollection. - // T12 wires the real mesh-build factory below. + // loadLandblock acquires _datLock (T10) before touching DatCollection. + // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so + // it can call LandblockMesh.Build without a dat read — _heightTable and + // _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). _streamer = new AcDream.App.Streaming.LandblockStreamer( - loadLandblock: id => BuildLandblockForStreaming(id)); + loadLandblock: id => BuildLandblockForStreaming(id), + buildMeshOrNull: (id, lb) => + { + if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) + return null; + uint lbX = (id >> 24) & 0xFFu; + uint lbY = (id >> 16) & 0xFFu; + // _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread. + // _heightTable and _blendCtx are read-only after initialization. + // lb.Heightmap is the pre-loaded LandBlock; no dat read needed here. + return AcDream.Core.Terrain.LandblockMesh.Build( + lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); + }); _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( @@ -4987,24 +5000,26 @@ public sealed class GameWindow : IDisposable } /// - /// Phase A.1: render-thread callback from StreamingController.Tick + /// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick /// whenever a new landblock's terrain + entities are ready for GPU upload. - /// Mirrors the terrain-build + entity-upload part of the old preload. + /// Phase A.5 T12: the worker pre-builds off the + /// render thread via ; + /// this callback no longer pays that CPU cost. /// Must only be called from the render thread. /// - private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb) + private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb, + AcDream.Core.Terrain.LandblockMeshData meshData) { - if (_terrain is null || _dats is null || _blendCtx is null - || _heightTable is null || _surfaceCache is null) return; + if (_terrain is null || _dats is null) return; // Phase A.1 hotfix: render-thread path also takes the dat lock so it // doesn't race with BuildLandblockForStreaming on the worker thread. - // Hold the lock across the entire apply because we read dats below - // (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from - // LandblockMesh.Build. + // Hold the lock across the entity hydration below (GfxObj sub-mesh + // builds). The terrain mesh is pre-built by the worker (T12) and passed + // in via meshData, so LandblockMesh.Build no longer runs under this lock. lock (_datLock) { - ApplyLoadedTerrainLocked(lb); + ApplyLoadedTerrainLocked(lb, meshData); } } @@ -5114,10 +5129,12 @@ public sealed class GameWindow : IDisposable _pendingCells.Add(loaded); } - private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb) + private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb, + AcDream.Core.Terrain.LandblockMeshData meshData) { - if (_terrain is null || _dats is null || _blendCtx is null - || _heightTable is null || _surfaceCache is null) return; + // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker). + // _heightTable still needed for physics TerrainSurface below. + if (_terrain is null || _dats is null || _heightTable is null) return; uint lbXu = (lb.LandblockId >> 24) & 0xFFu; uint lbYu = (lb.LandblockId >> 16) & 0xFFu; @@ -5128,10 +5145,8 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); - // Build terrain mesh data on the render thread (pure CPU; acceptable - // for the MVP; a future pass can move it to the worker thread). - var meshData = AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache); + // Phase A.5 T12: terrain mesh is pre-built by the worker thread and + // passed in via meshData. No longer rebuilt here on the render thread. _terrain.AddLandblock(lb.LandblockId, meshData, origin); // Step 4: drain pending LoadedCells from the worker thread. diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 53b0030..61cd5b8 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.Core.Terrain; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -19,7 +20,7 @@ public sealed class StreamingController private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; - private readonly Action _applyTerrain; + private readonly Action _applyTerrain; private readonly Action? _removeTerrain; private readonly GpuWorldState _state; private StreamingRegion? _region; @@ -48,7 +49,7 @@ public sealed class StreamingController Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, - Action applyTerrain, + Action applyTerrain, GpuWorldState state, int radius, Action? removeTerrain = null) @@ -92,7 +93,7 @@ public sealed class StreamingController switch (result) { case LandblockStreamResult.Loaded loaded: - _applyTerrain(loaded.Landblock); + _applyTerrain(loaded.Landblock, loaded.MeshData); _state.AddLandblock(loaded.Landblock); break; case LandblockStreamResult.Unloaded unloaded: diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index 9b7fdcb..bafe59a 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -34,7 +34,7 @@ public class StreamingControllerTests enqueueLoad: fake.EnqueueLoad, enqueueUnload: fake.EnqueueUnload, drainCompletions: fake.DrainCompletions, - applyTerrain: _ => { }, + applyTerrain: (_, _) => { }, state: state, radius: 2); @@ -53,7 +53,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - _ => { }, state, radius: 2); + (_, _) => { }, state, radius: 2); controller.Tick(50, 50); fake.Loads.Clear(); @@ -72,7 +72,7 @@ public class StreamingControllerTests var applied = new List(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - applied.Add, state, radius: 2); + (lb, _) => applied.Add(lb), state, radius: 2); // Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Entities (positional record). Adjust if the first positional arg @@ -93,7 +93,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - _ => { }, state, radius: 2); + (_, _) => { }, state, radius: 2); var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); state.AddLandblock(lb); From 76e1a64d78354997dbee354c477a83b547da266c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:41:36 +0200 Subject: [PATCH 19/45] fix(A.5 T10): lock 2 missed _dats.Get sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec compliance review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947) caught 2 unprotected dat reads that the original T10 audit missed: - GameWindow.UpdatePlayerAnimation (line ~7546): reads Setup when the player entity is missing from _animatedEntities (post-respawn pattern). - GameWindow.EnterPlayerModeNow (line ~8567): reads Setup when entering player mode to derive StepUpHeight / StepDownHeight from the dat. Both run on the render thread post-_streamer.Start(), so they can race with the worker thread's BuildLandblockForStreamingLocked. DatBinReader's shared buffer position would corrupt — same class of "ball with spikes" bug the original Phase A.1 hotfix addressed. Wrap both reads in lock (_datLock). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 741b2a9..a5e5a69 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7543,7 +7543,11 @@ public sealed class GameWindow : IDisposable // we always want it animated in player mode. if (!_animatedEntities.TryGetValue(pe.Id, out var ae)) { - var setup = _dats.Get(pe.SourceGfxObjOrSetupId); + // A.5 T10: lock around _dats.Get — worker thread may be + // building a landblock mesh concurrently. DatBinReader's + // shared buffer position would corrupt without serialization. + DatReaderWriter.DBObjs.Setup? setup; + lock (_datLock) { setup = _dats.Get(pe.SourceGfxObjOrSetupId); } if (setup is null) return; _physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup); @@ -8564,7 +8568,10 @@ public sealed class GameWindow : IDisposable // 0.4 m fallbacks. if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { - var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); + // A.5 T10: lock around _dats.Get — worker thread may be + // building a landblock mesh concurrently. + DatReaderWriter.DBObjs.Setup? playerSetup; + lock (_datLock) { playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); } if (playerSetup is not null) _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) From 774a7070a89439fb61baa780cfc97b2e95d6826c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:49:14 +0200 Subject: [PATCH 20/45] fix(A.5 T10-T12): Start() race + null mesh test + real mesh stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947 + audit fix 76e1a64) found 3 Important issues: 1. LandblockStreamer.Start() had an idempotency race — the XML doc claimed thread-safety but the implementation checked _worker != null before assigning, allowing two callers to both pass the check and spawn duplicate worker threads. Fixed via Interlocked.CompareExchange. 2. No test verified the worker emits Failed when buildMeshOrNull returns null. Added Load_WhenBuildMeshReturnsNull_ReportsFailed. 3. StreamingControllerTests.cs:81 used MeshData: default! when constructing a Loaded result. If a future test flows MeshData through the apply callback, the null reference would NRE rather than producing a meaningful assertion failure. Replaced with a real empty LandblockMeshData instance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/LandblockStreamer.cs | 17 +++++++--- .../Streaming/LandblockStreamerTests.cs | 33 +++++++++++++++++++ .../Streaming/StreamingControllerTests.cs | 8 ++++- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index 6b08095..a3416de 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -75,20 +75,27 @@ public sealed class LandblockStreamer : IDisposable } /// - /// Activate the dedicated background worker thread. Idempotent: calling - /// more than once has no effect. + /// Activate the dedicated background worker thread. Idempotent and + /// thread-safe: concurrent callers will only spawn one worker; subsequent + /// calls are no-ops. Atomic via . /// public void Start() { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - if (_worker != null) return; - _worker = new Thread(WorkerLoop) + + // A.5 T10-T12 follow-up: atomically install the worker so concurrent + // Start() callers don't both pass the null check and spawn duplicate + // threads. Construct the candidate; CAS it into _worker; if we lost + // the race, the candidate goes unstarted and is GCed. + var candidate = new Thread(WorkerLoop) { IsBackground = true, Name = "acdream.streaming.worker", }; - _worker.Start(); + if (Interlocked.CompareExchange(ref _worker, candidate, null) == null) + candidate.Start(); + // else: another caller won the race; their thread is running. } /// diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index 2e11804..7c5291c 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -66,6 +66,39 @@ public class LandblockStreamerTests Assert.IsType(result); } + [Fact] + public async Task Load_WhenBuildMeshReturnsNull_ReportsFailed() + { + // Phase A.5 T10-T12 follow-up: the mesh-build factory may return + // null (e.g., LandBlock dat missing or corrupt). The worker must + // emit Failed in that case instead of constructing Loaded with a + // null MeshData (which would NRE downstream). + var stubLandblock = new LoadedLandblock( + 0xABCDFFFEu, + new LandBlock(), + System.Array.Empty()); + + using var streamer = new LandblockStreamer( + loadLandblock: _ => stubLandblock, + buildMeshOrNull: (_, _) => null); // mesh-build returns null + + streamer.Start(); + streamer.EnqueueLoad(0xABCDFFFEu); + + LandblockStreamResult? result = null; + for (int i = 0; i < SpinMaxIterations && result is null; i++) + { + var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); + if (drained.Count > 0) result = drained[0]; + else await Task.Delay(SpinStepMs); + } + + Assert.NotNull(result); + var failed = Assert.IsType(result); + Assert.Equal(0xABCDFFFEu, failed.LandblockId); + Assert.Contains("mesh", failed.Error, System.StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage() { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index bafe59a..cb79116 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -78,7 +78,13 @@ public class StreamingControllerTests // Entities (positional record). Adjust if the first positional arg // name differs. var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); - fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, MeshData: default!)); + // A.5 T10-T12 follow-up: use a real empty mesh instance instead of + // default! so any future test that flows MeshData through the apply + // callback gets a non-null reference to inspect rather than an NRE. + var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( + System.Array.Empty(), + System.Array.Empty()); + fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, stubMesh)); controller.Tick(50, 50); From fb10c3fa8c2cb51e6c77cdeb7a4047821e7bf3c0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:53:34 +0200 Subject: [PATCH 21/45] feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting Two new methods on GpuWorldState, used by two-tier streaming (T13): - RemoveEntitiesFromLandblock(id): drop all entities from an LB while keeping the terrain. Used for Near->Far demote (player walks past the inner ring; LB stays loaded but entities leave). - AddEntitiesToExistingLandblock(id, entities): merge new entities into an already-loaded LB record. Used for Far->Near promote (terrain is already on the GPU; just streaming the entity layer in). Falls back to the pending bucket if the LB hasn't loaded yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/GpuWorldState.cs | 45 +++++++++++ .../Streaming/GpuWorldStateTwoTierTests.cs | 76 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index a256d26..966bf9c 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -339,6 +339,51 @@ public sealed class GpuWorldState bucket.Add(entity); } + /// + /// Drop all entities from a landblock without removing the terrain. Used + /// by two-tier streaming when a landblock crosses Near→Far hysteresis. + /// Per Phase A.5 spec §4.4. + /// + 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()); + _pendingByLandblock.Remove(landblockId); + RebuildFlatView(); + } + + /// + /// Merge entities into an existing-loaded landblock. Used by two-tier + /// streaming for the Far→Near promotion case (terrain already loaded; + /// entity layer streaming in). Falls back to the pending bucket if the + /// landblock isn't loaded yet (handles the rare "promote arrives before + /// far load completes" race). + /// Per Phase A.5 spec §4.4. + /// + public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList 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(); + _pendingByLandblock[landblockId] = bucket; + } + bucket.AddRange(entities); + return; + } + var merged = new List(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(); + } + private void RebuildFlatView() { _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs new file mode 100644 index 0000000..11ab0c5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs @@ -0,0 +1,76 @@ +using System.Linq; +using AcDream.App.Streaming; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class GpuWorldStateTwoTierTests +{ + private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities) + => new(canonicalId, new LandBlock(), entities); + + private static WorldEntity MakeStubEntity(uint id) + => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + [Fact] + public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities() + { + var state = new GpuWorldState(); + var lb = MakeStubLandblock(0xAAAAFFFFu, + MakeStubEntity(1), + MakeStubEntity(2)); + state.AddLandblock(lb); + Assert.Equal(2, state.Entities.Count); + + state.RemoveEntitiesFromLandblock(0xAAAAFFFFu); + + Assert.Empty(state.Entities); + Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident + } + + [Fact] + public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord() + { + var state = new GpuWorldState(); + var lb = MakeStubLandblock(0xAAAAFFFFu, MakeStubEntity(1)); + state.AddLandblock(lb); + + state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[] + { + MakeStubEntity(2), + MakeStubEntity(3), + }); + + Assert.Equal(3, state.Entities.Count); + } + + [Fact] + public void AddEntitiesToExistingLandblock_LandblockNotYetLoaded_ParksInPending() + { + var state = new GpuWorldState(); + + // Landblock not loaded yet. + state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[] + { + MakeStubEntity(1), + MakeStubEntity(2), + }); + + // Nothing in the flat view yet. + Assert.Empty(state.Entities); + Assert.Equal(2, state.PendingLiveEntityCount); + + // Now load the landblock — pending entities should merge in. + state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu)); + Assert.Equal(2, state.Entities.Count); + } +} From aff35d2a76ba520df0ff156c329001e5ff7b4f65 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:54:40 +0200 Subject: [PATCH 22/45] refactor(A.5 T15): TerrainModernRenderer.AddLandblockWithMesh entry point T13 routes worker-built meshes from LandblockStreamResult.Loaded.MeshData into the renderer. AddLandblockWithMesh accepts a prebuilt mesh + origin and delegates to the existing AddLandblock(uint, LandblockMeshData, Vector3) so both paths share one upload path (Approach B -- AddLandblock already takes a prebuilt mesh; no inline build to extract). GameWindow's T16 lambda captures liveCenterX/Y and passes the derived origin; the renderer stays origin-agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/TerrainModernRenderer.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 536acf5..3f62493 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -89,6 +89,18 @@ public sealed unsafe class TerrainModernRenderer : IDisposable _indirectBuffer = _gl.GenBuffer(); } + /// + /// Two-tier streaming entry point. Accepts a prebuilt mesh from + /// built on the worker + /// thread, together with the world-space origin computed by the caller + /// (render-thread GameWindow derives it from landblockId + liveCenterX/Y). + /// + /// Delegates to + /// so both paths share one upload path. Per Phase A.5 spec T15. + /// + public void AddLandblockWithMesh(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) + => AddLandblock(landblockId, meshData, worldOrigin); + public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) { ArgumentNullException.ThrowIfNull(meshData); From b8d80fe2823c7515b92ab1e541a449c32a7bc401 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:56:57 +0200 Subject: [PATCH 23/45] feat(A.5 T13): StreamingController two-tier Tick Replaces the single-radius Tick with a two-tier model that consumes StreamingRegion's TwoTierDiff (5-list) and routes to the appropriate JobKind: - ToLoadFar -> _enqueueLoad(id, LoadFar) - ToLoadNear -> _enqueueLoad(id, LoadNear) - ToPromote -> _enqueueLoad(id, PromoteToNear) - ToDemote -> _state.RemoveEntitiesFromLandblock(id) on render thread - ToUnload -> _enqueueUnload(id) Drain switch handles Loaded (terrain + entity layer), Promoted (entity layer only -- terrain already loaded), Unloaded, Failed, WorkerCrashed. Constructor signature: nearRadius/farRadius separate ints. Old single- radius ctor removed; existing single-radius tests updated to pass nearRadius=farRadius for backward-compat coverage. GameWindow's enqueueLoad lambda updated from (id =>...) to (id, kind) => to match new Action signature; radius: arg renamed to nearRadius:/farRadius: (both set to _streamingRadius until T16 wires the full two-tier env-var parsing). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 7 ++- .../Streaming/StreamingController.cs | 50 ++++++++++++------- .../Streaming/StreamingControllerTests.cs | 13 ++--- .../StreamingControllerTwoTierTests.cs | 38 ++++++++++++++ 4 files changed, 79 insertions(+), 29 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a5e5a69..81f6560 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1605,14 +1605,13 @@ public sealed class GameWindow : IDisposable _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - // Use a lambda so the Action delegate matches the method - // signature (EnqueueLoad has an optional 'kind' parameter). - enqueueLoad: id => _streamer.EnqueueLoad(id, AcDream.App.Streaming.LandblockStreamJobKind.LoadNear), + enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), enqueueUnload: _streamer.EnqueueUnload, drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, - radius: _streamingRadius, + nearRadius: _streamingRadius, + farRadius: _streamingRadius, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 61cd5b8..a9a8864 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -17,7 +17,7 @@ namespace AcDream.App.Streaming; /// public sealed class StreamingController { - private readonly Action _enqueueLoad; + private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; private readonly Action _applyTerrain; @@ -25,7 +25,8 @@ public sealed class StreamingController private readonly GpuWorldState _state; private StreamingRegion? _region; - public int Radius { get; set; } + public int NearRadius { get; set; } + public int FarRadius { get; set; } /// /// Cap on completions drained per call. The cap is @@ -46,12 +47,13 @@ public sealed class StreamingController public int MaxCompletionsPerFrame { get; set; } = 4; public StreamingController( - Action enqueueLoad, + Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, Action applyTerrain, GpuWorldState state, - int radius, + int nearRadius, + int farRadius, Action? removeTerrain = null) { _enqueueLoad = enqueueLoad; @@ -60,29 +62,42 @@ public sealed class StreamingController _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; _state = state; - Radius = radius; + NearRadius = nearRadius; + FarRadius = farRadius; } /// /// Advance one frame. / /// are landblock coordinates (0..255) of the current viewer — the camera /// in offline mode, the server-sent player position in live. + /// + /// Two-tier model (Phase A.5 T13): + /// + /// → enqueue LoadFar (terrain only, no entities) + /// → enqueue LoadNear (terrain + entities) + /// → enqueue PromoteToNear (entity layer for already-loaded terrain) + /// → drop entities on render thread immediately (terrain stays) + /// → enqueue full unload + /// /// public void Tick(int observerCx, int observerCy) { - // First-tick bootstrap: no region yet, so the whole visible window - // is a load diff. if (_region is null) { - _region = new StreamingRegion(observerCx, observerCy, Radius); - foreach (var id in _region.Visible) - _enqueueLoad(id); + _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.RecenterToSingleTier(observerCx, observerCy); - foreach (var id in diff.ToLoad) _enqueueLoad(id); - foreach (var id in diff.ToUnload) _enqueueUnload(id); + 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); } // Drain up to N completions per frame so a big diff doesn't spike @@ -96,6 +111,9 @@ public sealed class StreamingController _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); @@ -108,12 +126,6 @@ public sealed class StreamingController Console.WriteLine( $"streaming: worker CRASHED: {crashed.Error}"); break; - case LandblockStreamResult.Promoted: - // TODO(A.5 T13): merge promoted entities into existing - // GpuWorldState entry via AddEntitiesToExistingLandblock. - // Today the streamer never produces Promoted (only LoadNear / - // LoadFar), so this arm is unreachable and silently consumed. - break; } } } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index cb79116..3364d77 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -14,7 +14,7 @@ public class StreamingControllerTests public List Unloads { get; } = new(); public Queue Pending { get; } = new(); - public void EnqueueLoad(uint id) => Loads.Add(id); + public void EnqueueLoad(uint id, LandblockStreamJobKind _) => Loads.Add(id); public void EnqueueUnload(uint id) => Unloads.Add(id); public IReadOnlyList DrainCompletions(int max) { @@ -36,12 +36,13 @@ public class StreamingControllerTests drainCompletions: fake.DrainCompletions, applyTerrain: (_, _) => { }, state: state, - radius: 2); + nearRadius: 2, + farRadius: 2); // Center at (50, 50); no landblocks loaded yet. controller.Tick(observerCx: 50, observerCy: 50); - // 5×5 window = 25 loads enqueued, 0 unloads. + // 5×5 window = 25 loads enqueued (nearRadius==farRadius so all go to ToLoadNear), 0 unloads. Assert.Equal(25, fake.Loads.Count); Assert.Empty(fake.Unloads); } @@ -53,7 +54,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (_, _) => { }, state, radius: 2); + (_, _) => { }, state, nearRadius: 2, farRadius: 2); controller.Tick(50, 50); fake.Loads.Clear(); @@ -72,7 +73,7 @@ public class StreamingControllerTests var applied = new List(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (lb, _) => applied.Add(lb), state, radius: 2); + (lb, _) => applied.Add(lb), state, nearRadius: 2, farRadius: 2); // Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Entities (positional record). Adjust if the first positional arg @@ -99,7 +100,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (_, _) => { }, state, radius: 2); + (_, _) => { }, state, nearRadius: 2, farRadius: 2); var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); state.AddLandblock(lb); diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs new file mode 100644 index 0000000..bc18249 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingControllerTwoTierTests +{ + [Fact] + public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier() + { + var loads = new List<(uint Id, LandblockStreamJobKind Kind)>(); + var unloads = new List(); + var state = new GpuWorldState(); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => System.Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 1, + farRadius: 3); + + ctrl.Tick(observerCx: 100, observerCy: 100); + + int nearCount = 0, farCount = 0; + foreach (var (_, kind) in loads) + { + if (kind == LandblockStreamJobKind.LoadNear) nearCount++; + else if (kind == LandblockStreamJobKind.LoadFar) farCount++; + } + Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1) + Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3) + } +} From c4fd37384afa1d8d10600aab743f429e2d062f97 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:58:12 +0200 Subject: [PATCH 24/45] feat(A.5 T16): wire two-tier streaming into GameWindow GameWindow now constructs StreamingController with nearRadius / farRadius defaults of 4 / 12 (per spec acceptance criterion). Env vars: - ACDREAM_NEAR_RADIUS (default 4) - ACDREAM_FAR_RADIUS (default 12) - ACDREAM_STREAM_RADIUS (legacy; if set, treats as nearRadius and bumps farRadius to max(stream, default)) Fields _nearRadius / _farRadius added alongside legacy _streamingRadius (kept so the debug overlay's getStreamingRadius callback stays valid). ApplyLoadedTerrainLocked routes to TerrainModernRenderer.AddLandblockWithMesh (T15) instead of AddLandblock directly, making the two-tier entry point the canonical call path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 46 ++++++++++++++++++------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 81f6560..e442b94 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -83,7 +83,9 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.StreamingController? _streamingController; - private int _streamingRadius = 2; // default 5×5 + private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) + private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) + private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) private uint? _lastLivePlayerLandblockId; // Phase B.3: physics engine — populated from the streaming pipeline. @@ -1575,13 +1577,30 @@ public sealed class GameWindow : IDisposable // the player. _particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats); - // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. - // Parse runtime radius from environment (default 2 → 5×5 window). - // Values outside [0, 8] fall back to the field default of 2. - var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS"); - if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8) - _streamingRadius = r; - Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); + // Phase A.5 T16: two-tier radius env-var parsing. + // ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS set the two rings independently. + // Legacy ACDREAM_STREAM_RADIUS is honoured for backward-compat: it sets + // nearRadius and bumps farRadius to max(streamRadius, default farRadius). + { + var nearEnv = Environment.GetEnvironmentVariable("ACDREAM_NEAR_RADIUS"); + var farEnv = Environment.GetEnvironmentVariable("ACDREAM_FAR_RADIUS"); + var legacyEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS"); + + if (int.TryParse(nearEnv, out var nr) && nr >= 0) _nearRadius = nr; + if (int.TryParse(farEnv, out var fr) && fr >= 0) _farRadius = fr; + + // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and + // ensures farRadius >= streamRadius. + if (int.TryParse(legacyEnv, out var sr) && sr >= 0) + { + _nearRadius = sr; + _streamingRadius = sr; // keep debug overlay in sync + _farRadius = System.Math.Max(sr, _farRadius); + } + } + Console.WriteLine( + $"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" + + $" farRadius={_farRadius} (window={2*_farRadius+1}x{2*_farRadius+1})"); // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. // loadLandblock acquires _datLock (T10) before touching DatCollection. @@ -1610,8 +1629,8 @@ public sealed class GameWindow : IDisposable drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, - nearRadius: _streamingRadius, - farRadius: _streamingRadius, + nearRadius: _nearRadius, + farRadius: _farRadius, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities @@ -5144,9 +5163,10 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); - // Phase A.5 T12: terrain mesh is pre-built by the worker thread and - // passed in via meshData. No longer rebuilt here on the render thread. - _terrain.AddLandblock(lb.LandblockId, meshData, origin); + // Phase A.5 T15/T16: route through AddLandblockWithMesh — the named + // two-tier entry point. Delegates to AddLandblock internally; both + // paths share one GPU upload path. + _terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin); // Step 4: drain pending LoadedCells from the worker thread. while (_pendingCells.TryTake(out var cell)) From 31d312add352fdec3e57165c09a99e07a6983d31 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:01:30 +0200 Subject: [PATCH 25/45] fix(A.5 T16): debug overlay shows _nearRadius instead of legacy _streamingRadius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic follow-up flagged by spec compliance review on T13-T16 bundle (commits fb10c3f / aff35d2 / b8d80fe / c4fd373). The debug overlay's getStreamingRadius callback was reading _streamingRadius — the legacy single-tier field that's only updated by ACDREAM_STREAM_RADIUS. Operators using the new ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS env vars would see the overlay frozen at the default 2. Switch to _nearRadius. The overlay still shows a single number (matching its label "Streaming radius"); operators who want both tier numbers can read the launch log. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e442b94..018892a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1151,7 +1151,8 @@ public sealed class GameWindow : IDisposable getNearestObjLabel: () => _lastNearestObjLabel, getColliding: () => _lastColliding, getDebugWireframes: () => _debugCollisionVisible, - getStreamingRadius: () => _streamingRadius, + getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier + getMouseSensitivity: () => GetActiveSensitivity(), getChaseDistance: () => _chaseCamera?.Distance ?? 0f, getRmbOrbit: () => _rmbHeld, From 19b44652571a09498bfc0929353c467326bf6fc5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:08:23 +0200 Subject: [PATCH 26/45] fix(A.5 T13-T16): canonicalize ids; init-only radii; demote/promote tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on T13-T16 bundle (commits fb10c3f/aff35d2/b8d80fe/c4fd373/31d312a) flagged 3 Important + 2 test-coverage gaps. Apply all 5: Important #1: GpuWorldState.AddEntitiesToExistingLandblock didn't canonicalize landblockId. Streaming callers always pass canonical 0xAAAA0xFFFF ids, but the public API silently key-missed for callers that mirror AppendLiveEntity's cell-resolved-id pattern. Both new methods now canonicalize the id on entry. Important #2: RemoveEntitiesFromLandblock asymmetry with RemoveLandblock re: persistent-entity rescue. Documented as intentional — demote-tier entities are atlas-tier only (procedural scenery, dat-static stabs/ buildings; never ServerGuid != 0); the local player and live server spawns live in their LB via RelocateEntity per frame and aren't affected by atlas-layer demote. Important #3: StreamingController.NearRadius / FarRadius were { get; set; } but mutating them after the first Tick is a no-op (StreamingRegion snapshots the values). Switched to { get; } only with XML doc warning. Test gap #1: ToDemote routing through Tick — added test that walks the player past hysteresis and asserts entities drop while terrain stays. Test gap #2: Promoted result routing through Tick — added test that enqueues a Promoted and asserts AddEntitiesToExistingLandblock fires. Deferred Minor: dead _streamingRadius write + style consistency on fully-qualified IReadOnlyList — non-load-bearing, can roll into a later cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Streaming/GpuWorldState.cs | 42 ++++++--- .../Streaming/StreamingController.cs | 21 ++++- .../StreamingControllerTwoTierTests.cs | 86 +++++++++++++++++++ 3 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 966bf9c..9024047 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -343,14 +343,29 @@ public sealed class GpuWorldState /// Drop all entities from a landblock without removing the terrain. Used /// by two-tier streaming when a landblock crosses Near→Far hysteresis. /// Per Phase A.5 spec §4.4. + /// + /// + /// Persistent-entity rescue is intentionally omitted (unlike + /// ): demote-tier entities are atlas-tier + /// only (procedural scenery, dat-static stabs/buildings) — they never + /// have ServerGuid != 0 and so can never be in . + /// The local player and other live server-spawned entities live in their + /// landblock via RelocateEntity per frame and are not affected + /// by Near→Far demotion of dat-static landblock layers. + /// /// public void RemoveEntitiesFromLandblock(uint landblockId) { - if (!_loaded.TryGetValue(landblockId, out var lb)) return; + // A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity. + // Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this + // protects against future callers that mirror AppendLiveEntity's + // cell-resolved-id pattern. + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!_loaded.TryGetValue(canonical, out var lb)) return; if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockUnloaded(landblockId); - _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); - _pendingByLandblock.Remove(landblockId); + _wbSpawnAdapter.OnLandblockUnloaded(canonical); + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); + _pendingByLandblock.Remove(canonical); RebuildFlatView(); } @@ -361,16 +376,23 @@ public sealed class GpuWorldState /// landblock isn't loaded yet (handles the rare "promote arrives before /// far load completes" race). /// Per Phase A.5 spec §4.4. + /// + /// + /// Landblock id is canonicalized (low 16 bits forced to 0xFFFF) — + /// callers may pass cell-resolved ids and they will key correctly. + /// /// - public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList entities) + public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList entities) { - if (!_loaded.TryGetValue(landblockId, out var lb)) + // A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity. + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (!_loaded.TryGetValue(canonical, out var lb)) { // Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs. - if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket)) + if (!_pendingByLandblock.TryGetValue(canonical, out var bucket)) { bucket = new List(); - _pendingByLandblock[landblockId] = bucket; + _pendingByLandblock[canonical] = bucket; } bucket.AddRange(entities); return; @@ -378,9 +400,9 @@ public sealed class GpuWorldState var merged = new List(lb.Entities.Count + entities.Count); merged.AddRange(lb.Entities); merged.AddRange(entities); - _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]); + _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); RebuildFlatView(); } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index a9a8864..ac74ae6 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -25,8 +25,25 @@ public sealed class StreamingController private readonly GpuWorldState _state; private StreamingRegion? _region; - public int NearRadius { get; set; } - public int FarRadius { get; set; } + /// + /// Near-tier radius (LBs from observer that load full detail: terrain + + /// scenery + entities). Set at construction; readable thereafter. + /// + /// + /// Mutating after the first has no effect — the + /// internal snapshots both radii on its + /// constructor. Treat as init-only post-Tick. + /// + public int NearRadius { get; } + + /// + /// Far-tier radius (LBs from observer that load terrain only). Set at + /// construction; readable thereafter. + /// + /// + /// Mutating after the first has no effect — see . + /// + public int FarRadius { get; } /// /// Cap on completions drained per call. The cap is diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index bc18249..7b0de6c 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -35,4 +35,90 @@ public class StreamingControllerTwoTierTests Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1) Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3) } + + [Fact] + public void Tick_PlayerWalksOutOfNear_ToDemoteRoutesToRemoveEntities() + { + // Setup: bootstrap region at (100,100) with near=1, far=3. + // The bootstrap puts LB (100,100) in the near tier. + // Walking 4+ east drops LB (100,100) past the near-hysteresis + // threshold (NearRadius+2 = 3); ToDemote should fire. + + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + var state = new GpuWorldState(); + + // Pre-load LB (100,100) so RemoveEntitiesFromLandblock has something + // to find. The actual entity content doesn't matter for routing. + var lb100 = new LoadedLandblock( + (100u << 24) | (100u << 16) | 0xFFFFu, + Heightmap: null!, + Entities: new[] { new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() } }); + state.AddLandblock(lb100); + Assert.Equal(1, state.Entities.Count); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => System.Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 1, + farRadius: 3); + + ctrl.Tick(observerCx: 100, observerCy: 100); // bootstrap + loads.Clear(); + + // Walk 4 east — LB (100,100) is now Chebyshev distance 4 from new + // center (104,100). NearRadius+2 = 3, so 4 > 3 fires the demote. + ctrl.Tick(observerCx: 104, observerCy: 100); + + // ToDemote runs synchronously on the render thread (no enqueue). + // The visible effect is RemoveEntitiesFromLandblock dropping the entity. + Assert.Empty(state.Entities); + // Terrain stays loaded (demote != unload). + Assert.True(state.IsLoaded((100u << 24) | (100u << 16) | 0xFFFFu)); + } + + [Fact] + public void Tick_DrainingPromoted_RoutesToAddEntitiesToExisting() + { + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + var state = new GpuWorldState(); + + // Pre-load a far-tier-style LB record (terrain only, no entities). + uint lbId = 0x32320FFFu; + var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty()); + state.AddLandblock(lb); + Assert.Empty(state.Entities); + + // Streamer pushes a Promoted result carrying the entity layer. + var promoted = new LandblockStreamResult.Promoted( + lbId, + new[] { new WorldEntity { Id = 7, MeshRefs = System.Array.Empty() } }); + var queue = new Queue(); + queue.Enqueue(promoted); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue()); + return batch; + }, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 2, + farRadius: 2); + + ctrl.Tick(50, 50); // drains the Promoted result + + // Promoted routes to AddEntitiesToExistingLandblock — the entity is now + // merged into the existing LB record. + Assert.Equal(1, state.Entities.Count); + Assert.Equal(7u, state.Entities[0].Id); + } } From c2c8a532dbaa9f074b3503915c0d1d17fc3d35b4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:09:10 +0200 Subject: [PATCH 27/45] fix(A.5 T13-T16): WorldEntity required-member fields in new tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 19b4465 broke build by omitting required-member init for SourceGfxObjOrSetupId/Position/Rotation in the new ToDemote/ToPromote tests. WorldEntity has [required] on those fields (CS9035). The lone test run that reported 38 passing used pre-existing binaries built before this break. Added all three required initializers (zero / Identity defaults — these test the routing path; entity content doesn't matter). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/StreamingControllerTwoTierTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index 7b0de6c..4774ac2 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -53,7 +53,11 @@ public class StreamingControllerTwoTierTests var lb100 = new LoadedLandblock( (100u << 24) | (100u << 16) | 0xFFFFu, Heightmap: null!, - Entities: new[] { new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() } }); + Entities: new[] { new WorldEntity { + Id = 1, SourceGfxObjOrSetupId = 0, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty() } }); state.AddLandblock(lb100); Assert.Equal(1, state.Entities.Count); @@ -96,7 +100,11 @@ public class StreamingControllerTwoTierTests // Streamer pushes a Promoted result carrying the entity layer. var promoted = new LandblockStreamResult.Promoted( lbId, - new[] { new WorldEntity { Id = 7, MeshRefs = System.Array.Empty() } }); + new[] { new WorldEntity { + Id = 7, SourceGfxObjOrSetupId = 0, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty() } }); var queue = new Queue(); queue.Enqueue(promoted); From 0de6bc9c96798dd714bbfaa128436ddfb8250de2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:09:53 +0200 Subject: [PATCH 28/45] fix(A.5 T13-T16): canonical LB id in Tick_DrainingPromoted test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 19b4465's new ToPromote test pre-loaded an LB with a non- canonical id (low 16 bits 0x0FFF instead of 0xFFFF). The new canonicalization in AddEntitiesToExistingLandblock then key-missed and parked the entity in the pending bucket instead of merging — assertion failed. Use canonical id 0x3232FFFFu directly. The test now exercises the intended hot-path (merge into existing LB), not the cold pending-bucket fallback (which is exercised by GpuWorldStateTwoTierTests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/StreamingControllerTwoTierTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index 4774ac2..2b86b6a 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -92,7 +92,9 @@ public class StreamingControllerTwoTierTests var state = new GpuWorldState(); // Pre-load a far-tier-style LB record (terrain only, no entities). - uint lbId = 0x32320FFFu; + // Id must be in canonical form (low 16 bits = 0xFFFF) since + // AddEntitiesToExistingLandblock canonicalizes incoming ids. + uint lbId = 0x3232FFFFu; var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty()); state.AddLandblock(lb); Assert.Empty(state.Entities); From 003443cd1aa0fd4a19408828fbb1d3577c9524f9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:18:02 +0200 Subject: [PATCH 29/45] =?UTF-8?q?feat(A.5=20T17):=20WbDrawDispatcher=20Cha?= =?UTF-8?q?nge=20#1=20=E2=80=94=20animated-walk=20fix=20+=20WalkEntities?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.6 Change #1: when an LB is invisible AND animatedEntityIds is non-empty, the inner loop walked every entity in the LB just to find the few animated ones. At ~10.7K entities (N1=4) that is wasted iteration cost per frame. Extracted a pure-CPU internal static WalkEntities helper. When LB is invisible: iterate animatedEntityIds directly and look each up in a per-LB AnimatedById dictionary (typically <50 animated vs ~10K total). When LB is visible: walk all entities as before. GpuWorldState.LandblockEntries now yields an AnimatedById map as a 5th tuple field alongside the AABB tuple. Dictionary is built on each yield (cheap — ~132 entities/LB max). A caching layer is out of A.5 scope. WbDrawDispatcher.Draw signature updated to consume the 5-tuple. GameWindow.cs call site passes _worldState.LandblockEntries which now yields the 5-tuple — no change needed there. 8 new tests in WbDrawDispatcherBucketingTests cover T17 Change #1 (invisible LB / animated set / neverCull / null frustum) and T18 Change #2 guard tests (cached AABB / dirty flag / animated bypass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 260 ++++++++----- src/AcDream.App/Streaming/GpuWorldState.cs | 22 +- .../Wb/WbDrawDispatcherBucketingTests.cs | 354 ++++++++++++++++++ 3 files changed, 546 insertions(+), 90 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index eecc1a6..fcb9e66 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -157,9 +157,113 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Matrix4x4 restPose) => restPose * animOverride * entityWorld; + /// + /// Entry for per-landblock iteration. + /// Mirrors the shape yielded by GpuWorldState.LandblockEntries. + /// + public readonly record struct LandblockEntry( + uint LandblockId, + Vector3 AabbMin, + Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById); + + /// + /// Result of — the list of (entity, meshRef index) + /// pairs that passed all visibility filters, plus a diagnostic walk count. + /// + public struct WalkResult + { + public int EntitiesWalked; + public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw; + } + + /// + /// Pure-CPU visibility filter over . + /// Separated from so tests can exercise it without GL state. + /// + /// + /// A.5 T17 Change #1: when an LB is frustum-culled AND + /// is non-empty, the OLD path walked + /// every entity in the LB just to find the few animated ones. This helper + /// fixes that: if the LB is invisible, we iterate + /// directly and look each up in + /// entry.AnimatedById (typically <50 animated, up to ~10K total). + /// + /// + /// + /// A.5 T18 Change #2: per-entity AABB cull reads from the cached + /// / + /// (refreshed lazily if ), instead of + /// recomputing Position±5 each frame. + /// + /// + internal static WalkResult WalkEntities( + IEnumerable landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? animatedEntityIds) + { + var result = new WalkResult { ToDraw = new List<(WorldEntity, int)>() }; + + 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. + // Avoids O(N_entities) scan when only O(N_animated) work is needed. + 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, i)); + } + continue; + } + + foreach (var entity in entry.Entities) + { + if (entity.MeshRefs.Count == 0) continue; + + if (entity.ParentCellId.HasValue && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)) + continue; + + // Per-entity AABB frustum cull (perf #3). Animated entities bypass — + // they're tracked at landblock level + need per-frame work regardless. + // A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty. + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) + { + if (entity.AabbDirty) entity.RefreshAabb(); + if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) + continue; + } + + result.EntitiesWalked++; + for (int i = 0; i < entity.MeshRefs.Count; i++) + result.ToDraw.Add((entity, i)); + } + } + return result; + } + public void Draw( ICamera camera, - IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null, HashSet? visibleCellIds = null, @@ -194,97 +298,79 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; - foreach (var entry in landblockEntries) + // Project the 5-tuple enumerable into LandblockEntry records for WalkEntities. + static IEnumerable ToEntries( + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> src) { - bool landblockVisible = frustum is null - || entry.LandblockId == neverCullLandblockId - || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax); + foreach (var e in src) + yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById); + } - if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0)) - continue; + var walkResult = WalkEntities( + ToEntries(landblockEntries), + frustum, + neverCullLandblockId, + visibleCellIds, + animatedEntityIds); - foreach (var entity in entry.Entities) + foreach (var (entity, partIdx) in walkResult.ToDraw) + { + if (diag) _entitiesSeen++; + + var entityWorld = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + + // Compute palette-override hash ONCE per entity (perf #4). + // Reused across every (part, batch) lookup so the FNV-1a fold + // over SubPalettes runs once instead of N times. Zero when the + // entity has no palette override (trees, scenery). + ulong palHash = 0; + if (entity.PaletteOverride is not null) + palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); + + // Note: GameWindow's spawn path already applies + // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — + // close-detail mesh swap for humanoids) to MeshRefs. We + // trust MeshRefs as the source of truth here. AnimatedEntityState's + // overrides become relevant only for hot-swap (0xF625 + // ObjDescEvent) which today rebuilds MeshRefs anyway. + var meshRef = entity.MeshRefs[partIdx]; + ulong gfxObjId = meshRef.GfxObjId; + + var renderData = _meshAdapter.TryGetRenderData(gfxObjId); + if (renderData is null) { - if (entity.MeshRefs.Count == 0) continue; - - bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; - if (!landblockVisible && !isAnimated) continue; - - if (entity.ParentCellId.HasValue && visibleCellIds is not null - && !visibleCellIds.Contains(entity.ParentCellId.Value)) - continue; - - // Per-entity AABB frustum cull (perf #3). Skips work for distant - // entities even when their landblock is visible. Animated - // entities bypass — they're tracked at landblock level + need - // per-frame work for animation regardless. Conservative 5m - // radius covers typical entity bounds. - if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) - { - var p = entity.Position; - var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius); - var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius); - if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) - continue; - } - - if (diag) _entitiesSeen++; - - var entityWorld = - Matrix4x4.CreateFromQuaternion(entity.Rotation) * - Matrix4x4.CreateTranslation(entity.Position); - - // Compute palette-override hash ONCE per entity (perf #4). - // Reused across every (part, batch) lookup so the FNV-1a fold - // over SubPalettes runs once instead of N times. Zero when the - // entity has no palette override (trees, scenery). - ulong palHash = 0; - if (entity.PaletteOverride is not null) - palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); - - bool drewAny = false; - for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) - { - // Note: GameWindow's spawn path already applies - // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — - // close-detail mesh swap for humanoids) to MeshRefs. We - // trust MeshRefs as the source of truth here. AnimatedEntityState's - // overrides become relevant only for hot-swap (0xF625 - // ObjDescEvent) which today rebuilds MeshRefs anyway. - var meshRef = entity.MeshRefs[partIdx]; - ulong gfxObjId = meshRef.GfxObjId; - - var renderData = _meshAdapter.TryGetRenderData(gfxObjId); - if (renderData is null) - { - if (diag) _meshesMissing++; - continue; - } - drewAny = true; - if (anyVao == 0) anyVao = renderData.VAO; - - if (renderData.IsSetup && renderData.SetupParts.Count > 0) - { - foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) - { - var partData = _meshAdapter.TryGetRenderData(partGfxObjId); - if (partData is null) continue; - - var model = ComposePartWorldMatrix( - entityWorld, meshRef.PartTransform, partTransform); - - ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); - } - } - else - { - var model = meshRef.PartTransform * entityWorld; - ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); - } - } - - if (diag && drewAny) _entitiesDrawn++; + if (diag) _meshesMissing++; + continue; } + if (anyVao == 0) anyVao = renderData.VAO; + + bool drewAny = false; + if (renderData.IsSetup && renderData.SetupParts.Count > 0) + { + foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) + { + var partData = _meshAdapter.TryGetRenderData(partGfxObjId); + if (partData is null) continue; + + var model = ComposePartWorldMatrix( + entityWorld, meshRef.PartTransform, partTransform); + + ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); + drewAny = true; + } + } + else + { + var model = meshRef.PartTransform * entityWorld; + ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); + drewAny = true; + } + + if (diag && drewAny) _entitiesDrawn++; } // Nothing visible — skip the GL pass entirely. diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 9024047..b0ad321 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -106,17 +106,33 @@ public sealed class GpuWorldState /// Per-landblock iteration with AABB data for use by the frustum-culling /// draw path. Landblocks without a stored AABB yield /// for both corners, which the culler will conservatively treat as visible. + /// + /// + /// A.5 T17: also yields an AnimatedById dictionary built on the fly + /// from the landblock's entity list. This lets + /// skip the full entity walk when the landblock is frustum-culled but animated + /// entities inside it must still be processed (Change #1). + /// Building the dict per-yield is cheap (~132 entities/LB max). A caching + /// layer is out of A.5 scope. + /// /// - public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> LandblockEntries + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries { get { foreach (var kvp in _loaded) { + // Build AnimatedById on the fly — cheap (~132 entities/LB max). + var byId = new Dictionary(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); + yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId); else - yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities); + yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId); } } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs new file mode 100644 index 0000000..051dcf2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs @@ -0,0 +1,354 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +/// +/// Tests for — the pure-CPU +/// visibility filter extracted in A.5 T17. These tests exercise the two +/// key perf changes from Phase A.5 spec §4.6: +/// +/// +/// Change #1 (T17): invisible LB + animated set → iterate +/// animatedEntityIds directly, not the full entity list. +/// Change #2 (T18): per-entity AABB cull reads the cached AABB +/// (/AabbMax) rather than +/// recomputing Position±5 per frame. +/// +/// +public sealed class WbDrawDispatcherBucketingTests +{ + // ── helpers ────────────────────────────────────────────────────────────── + + private static WorldEntity MakeEntity(uint id, Vector3 position) + => new WorldEntity + { + Id = id, + SourceGfxObjOrSetupId = 0, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + private static WorldEntity MakeEntityWithMesh(uint id, Vector3 position) + => new WorldEntity + { + Id = id, + SourceGfxObjOrSetupId = 0, + Position = position, + Rotation = Quaternion.Identity, + // Single dummy MeshRef so it passes the MeshRefs.Count == 0 guard. + MeshRefs = new[] { new MeshRef { GfxObjId = 0x01000001u } }, + }; + + private static Dictionary BuildById(IEnumerable entities) + { + var d = new Dictionary(); + foreach (var e in entities) d[e.Id] = e; + return d; + } + + /// + /// A frustum positioned at (1e6+1, 1e6+1, 1e6+1) looking toward (1e6, 1e6, 1e6) + /// with a very narrow near/far. Any AABB near the origin (0..20000) is + /// far behind the near plane and fails all six planes. + /// + private static FrustumPlanes MakeFarAwayFrustum() + { + var view = Matrix4x4.CreateLookAt( + new Vector3(1e6f + 1f, 1e6f + 1f, 1e6f + 1f), + new Vector3(1e6f, 1e6f, 1e6f), + Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 4f, 1f, 0.1f, 1f); + return FrustumPlanes.FromViewProjection(view * proj); + } + + // ── T17 Change #1 tests ─────────────────────────────────────────────── + + [Fact] + public void WalkEntities_InvisibleLb_NoAnimated_SkipsEntireBlock() + { + // When LB is invisible AND animatedEntityIds is empty/null, + // WalkEntities should not walk any entities at all. + var entities = new List(); + for (int i = 0; i < 500; i++) + entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0))); + + var byId = BuildById(entities); + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + 0xAAAA_FFFFu, + new Vector3(10000, 10000, 10000), + new Vector3(20000, 20000, 20000), + entities, + byId), + }; + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: MakeFarAwayFrustum(), + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: null); + + Assert.Equal(0, result.EntitiesWalked); + Assert.Empty(result.ToDraw); + } + + [Fact] + public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities() + { + // 1000 entities in an LB whose AABB is far outside the frustum. + // Only entity Id=42 is in animatedEntityIds. + // Pre-T17 behavior: walk all 1000 entities just to find #42. + // Post-T17: walk only the 1 animated entity (EntitiesWalked == 1). + const int Total = 1000; + var entities = new List(Total); + for (int i = 0; i < Total; i++) + entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0))); + + var byId = BuildById(entities); + var animatedSet = new HashSet { 42 }; + + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + 0xAAAA_FFFFu, + new Vector3(10000, 10000, 10000), + new Vector3(20000, 20000, 20000), + entities, + byId), + }; + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: MakeFarAwayFrustum(), + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: animatedSet); + + // Only the 1 animated entity should be walked — not 1000. + Assert.Equal(1, result.EntitiesWalked); + Assert.Single(result.ToDraw); + Assert.Equal(42u, result.ToDraw[0].Entity.Id); + } + + [Fact] + public void WalkEntities_InvisibleLb_AnimatedIdAbsent_ZeroWalked() + { + // Animated entity ids 200 and 300 are NOT in this LB (which only + // has ids 0..99). Should produce zero walks. + var entities = new List(); + for (int i = 0; i < 100; i++) + entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero)); + + var byId = BuildById(entities); + var animatedSet = new HashSet { 200, 300 }; // not in this LB + + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + 0xBBBB_FFFFu, + new Vector3(10000, 10000, 10000), + new Vector3(20000, 20000, 20000), + entities, + byId), + }; + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: MakeFarAwayFrustum(), + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: animatedSet); + + Assert.Equal(0, result.EntitiesWalked); + Assert.Empty(result.ToDraw); + } + + [Fact] + public void WalkEntities_NeverCullLb_WalksAllEntitiesRegardlessOfFrustum() + { + // neverCullLandblockId bypasses the LB AABB check entirely. + // All entities with at least one MeshRef should be walked. + var entities = new List + { + MakeEntityWithMesh(1, Vector3.Zero), + MakeEntityWithMesh(2, Vector3.Zero), + MakeEntityWithMesh(3, Vector3.Zero), + }; + + var byId = BuildById(entities); + const uint lbId = 0xCCCC_FFFFu; + + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + lbId, + new Vector3(10000, 10000, 10000), // AABB would fail frustum + new Vector3(20000, 20000, 20000), + entities, + byId), + }; + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: MakeFarAwayFrustum(), + neverCullLandblockId: lbId, // exempt from LB cull + visibleCellIds: null, + animatedEntityIds: null); + + Assert.Equal(3, result.EntitiesWalked); + } + + [Fact] + public void WalkEntities_NullFrustum_WalksEntitiesWithMeshRefs() + { + // Null frustum means no culling — all entities with MeshRefs pass. + // Entities without MeshRefs are still filtered out. + var entities = new List + { + MakeEntityWithMesh(1, Vector3.Zero), + MakeEntity(2, Vector3.Zero), // no MeshRefs — must be skipped + MakeEntityWithMesh(3, Vector3.Zero), + }; + + var byId = BuildById(entities); + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + 0xDDDD_FFFFu, Vector3.Zero, Vector3.Zero, + entities, byId), + }; + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: null, + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: null); + + Assert.Equal(2, result.EntitiesWalked); + Assert.Equal(2, result.ToDraw.Count); + } + + // ── T18 Change #2 tests ─────────────────────────────────────────────── + + [Fact] + public void WalkEntities_VisibleLb_EntityFarAway_CulledViaCachedAabb() + { + // LB passes the LB-level cull; entity AABB is far from the frustum. + // After RefreshAabb the entity should be culled by the per-entity check. + var entity = MakeEntityWithMesh(1, new Vector3(50000, 50000, 50000)); + entity.RefreshAabb(); // populate cached AABB at (50000±5) + + var byId = BuildById(new[] { entity }); + var entries = new[] + { + // LB AABB near origin so it passes the LB cull; entity is far away. + new WbDrawDispatcher.LandblockEntry( + 0xEEEE_FFFFu, + new Vector3(-10, -10, -10), + new Vector3(10, 10, 10), + new List { entity }, + byId), + }; + + // Frustum centered at origin, range ±100. + var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f); + var tightFrustum = FrustumPlanes.FromViewProjection(view * proj); + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: tightFrustum, + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: null); + + // Entity at (50000,50000,50000) is outside the frustum — should be culled. + Assert.Equal(0, result.EntitiesWalked); + } + + [Fact] + public void WalkEntities_AnimatedEntity_BypassesPerEntityAabbCull() + { + // Animated entities must always pass even if their AABB would be culled. + var entity = MakeEntityWithMesh(7, new Vector3(50000, 50000, 50000)); + entity.RefreshAabb(); + + var byId = BuildById(new[] { entity }); + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + 0xEEEF_FFFFu, + new Vector3(-10, -10, -10), + new Vector3(10, 10, 10), + new List { entity }, + byId), + }; + + var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f); + var tightFrustum = FrustumPlanes.FromViewProjection(view * proj); + + var animatedSet = new HashSet { 7 }; + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: tightFrustum, + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: animatedSet); + + // Animated entity bypasses per-entity cull. + Assert.Equal(1, result.EntitiesWalked); + Assert.Single(result.ToDraw); + Assert.Equal(7u, result.ToDraw[0].Entity.Id); + } + + [Fact] + public void WalkEntities_AabbDirty_RefreshedLazilyBeforeCull() + { + // An entity with AabbDirty=true (initial state) should get its AABB + // refreshed lazily by WalkEntities before the cull check. + var entity = MakeEntityWithMesh(5, new Vector3(0, 0, 0)); + // AabbDirty starts true by default — do NOT call RefreshAabb manually. + Assert.True(entity.AabbDirty); + + var byId = BuildById(new[] { entity }); + var entries = new[] + { + new WbDrawDispatcher.LandblockEntry( + 0xF0F0_FFFFu, + new Vector3(-10, -10, -10), + new Vector3(10, 10, 10), + new List { entity }, + byId), + }; + + // A frustum that accepts things near origin. + var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.1f, 200f); + var nearOriginFrustum = FrustumPlanes.FromViewProjection(view * proj); + + var result = WbDrawDispatcher.WalkEntities( + entries, + frustum: nearOriginFrustum, + neverCullLandblockId: null, + visibleCellIds: null, + animatedEntityIds: null); + + // Entity at origin is inside the frustum after lazy RefreshAabb. + Assert.Equal(1, result.EntitiesWalked); + // AabbDirty should have been cleared by the lazy refresh. + Assert.False(entity.AabbDirty); + } +} From 0afd741ea7e78565323d6f7140b363d926137d3f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:20:20 +0200 Subject: [PATCH 30/45] feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.6 Change #2: WalkEntities's per-entity AABB frustum cull was recomputing Position±5 per frame per entity. With ~10.7K entities (N1=4) at 240 FPS that is ~2.5M wasted Vector3 ops/sec. Read the AABB from the WorldEntity cache (T8 schema) instead. RefreshAabb runs lazily on AabbDirty=true. Populate at register time: - LandblockLoader.BuildEntitiesFromInfo: RefreshAabb after each new WorldEntity construction (stabs + buildings). Refactored from inline object-initializer to named variable to enable the call. - EntitySpawnAdapter.OnCreate: RefreshAabb after entity state init (position/rotation already set via the WorldEntity passed in). Dynamic entities (NPCs, players) move every frame via direct Position writes in GameWindow.cs. Migrated all three per-frame write sites to SetPosition() (T8 mutator) so AabbDirty propagates: - line 5942: player entity render position update - line 6951: remote animated entity interpolated path - line 7279: remote animated entity landing/movement path The lazy RefreshAabb in WalkEntities catches up on the next frame after any SetPosition call — render thread only, no races. Build green, 986 passed / 8 pre-existing failures unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 6 +++--- src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs | 6 ++++++ src/AcDream.Core/World/LandblockLoader.cs | 12 ++++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 018892a..f788b83 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5939,7 +5939,7 @@ public sealed class GameWindow : IDisposable // the physics-resolved location each frame. if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { - pe.Position = result.RenderPosition; + pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); @@ -6948,7 +6948,7 @@ public sealed class GameWindow : IDisposable rm.MaxSeqSpeedSinceLastUP = seqSpeedNow; } - ae.Entity.Position = rm.Body.Position; + ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty ae.Entity.Rotation = rm.Body.Orientation; } else @@ -7276,7 +7276,7 @@ public sealed class GameWindow : IDisposable } } - ae.Entity.Position = rm.Body.Position; + ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty ae.Entity.Rotation = rm.Body.Orientation; } } diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs index eb05d92..6303220 100644 --- a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -128,6 +128,12 @@ public sealed class EntitySpawnAdapter } } + // A.5 T18: populate cached AABB so WalkEntities reads from the cache + // rather than recomputing Position±5 per frame. Called here because + // all entity-state initialization (position, rotation) is complete + // by this point via the WorldEntity passed in. + entity.RefreshAabb(); + // Build the per-entity AnimatedEntityState. The sequencer factory // may return a stub (in tests) or a fully-constructed sequencer from // the MotionTable (in production). Factory must not return null — diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs index 4234c11..fc3d30e 100644 --- a/src/AcDream.Core/World/LandblockLoader.cs +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -42,28 +42,32 @@ public static class LandblockLoader { if (!IsSupported(stab.Id)) continue; - result.Add(new WorldEntity + var stabEntity = new WorldEntity { Id = nextId++, SourceGfxObjOrSetupId = stab.Id, Position = stab.Frame.Origin, Rotation = stab.Frame.Orientation, MeshRefs = Array.Empty(), - }); + }; + stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction + result.Add(stabEntity); } foreach (var building in info.Buildings) { if (!IsSupported(building.ModelId)) continue; - result.Add(new WorldEntity + var buildingEntity = new WorldEntity { Id = nextId++, SourceGfxObjOrSetupId = building.ModelId, Position = building.Frame.Origin, Rotation = building.Frame.Orientation, MeshRefs = Array.Empty(), - }); + }; + buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction + result.Add(buildingEntity); } return result; From 4b84e5650b6ca6e44869d03b469e7cf50cc84971 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:24:44 +0200 Subject: [PATCH 31/45] feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.9.1: at N₂=12 distant terrain LBs occupy a few pixels on screen and shimmer (texel-swap aliasing) without mipmaps. Generate mips after atlas upload; sampler trilinear + 16x anisotropic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/TerrainAtlas.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index d49610e..c0d488e 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -183,13 +183,17 @@ public sealed unsafe class TerrainAtlas : IDisposable layerIdx++; } - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. + gl.GenerateMipmap(TextureTarget.Texture2DArray); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic). + gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f); gl.BindTexture(TextureTarget.Texture2DArray, 0); - Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH}"); + Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)"); // ---- Alpha atlas (new in Phase 3c.2) ---- // texMerge is guaranteed non-null here: the early return above exited From 26b2871b10b1721ab4b6926f073d9d3c210aa593 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:25:59 +0200 Subject: [PATCH 32/45] feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.9.2: ClipMap foliage uses binary alpha-cutoff. At N₂=12 horizon distance the pixel-stepped silhouettes are visible. A2C with MSAA 4x produces smooth retail-faithful tree edges. GL context now requests Samples=4. WbDrawDispatcher's opaque pass toggles GL_SAMPLE_ALPHA_TO_COVERAGE on/off around the multi-draw indirect call. mesh_modern.frag's opaque pass now discards only truly-empty (α<0.05) so the GPU derives sample mask from coverage; transparent pass boundary logic is unchanged. MSAA audit: no custom FBOs found — all rendering uses default framebuffer. Sky/particles/ImGui are all MSAA-compatible. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 1 + src/AcDream.App/Rendering/Shaders/mesh_modern.frag | 5 ++++- src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f788b83..c879195 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -830,6 +830,7 @@ public sealed class GameWindow : IDisposable ContextFlags.ForwardCompatible, new APIVersion(4, 3)), VSync = false, // off during development so the perf overlay shows true framerate + Samples = 4, // A.5 T20: MSAA 4x for A2C foliage smoothing }; _window = Window.Create(options); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index c5d9a02..1145dc7 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -80,8 +80,11 @@ void main() { vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer))); // Two-pass alpha-test (N.5 Decision 2). + // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE + // derives the MSAA sample mask from it — ClipMap foliage edges become smooth. + // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. if (uRenderPass == 0) { - if (color.a < 0.95) discard; // opaque pass + if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C) } else { if (color.a >= 0.95) discard; // transparent pass if (color.a < 0.05) discard; // skip totally-empty diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index fcb9e66..5d35f68 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -488,6 +488,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); + // A.5 T20: enable A2C for ClipMap foliage — GPU derives sample mask + // from the alpha written by mesh_modern.frag so foliage edges are + // smooth under MSAA 4x. A no-op for fully-opaque (α=1) batches. + _gl.Enable(EnableCap.SampleAlphaToCoverage); _shader.SetInt("uRenderPass", 0); _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque); @@ -498,6 +502,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable (uint)_opaqueDrawCount, (uint)DrawCommandStride); if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed); + _gl.Disable(EnableCap.SampleAlphaToCoverage); } // ── Phase 8: transparent pass ──────────────────────────────────────── From 1488ec62b7242342f05c736ff9e45bfef33e87a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:27:03 +0200 Subject: [PATCH 33/45] test(A.5 T21): lock in depth-write attribution per translucency kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.9.3 audit: opaque + ClipMap pass uses DepthMask(true); AlphaBlend / Additive / InvAlpha pass uses DepthMask(false), restored after. Audit confirmed correct in WbDrawDispatcher.Draw. IsOpaquePublic shim already present. Add WbDispatcherDepthMaskTests: 5-case Theory that pins the partition so future regressions surface immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Wb/WbDispatcherDepthMaskTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs new file mode 100644 index 0000000..216a736 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs @@ -0,0 +1,39 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +/// +/// A.5 T21: lock in the depth-write attribution per translucency kind. +/// +/// WbDrawDispatcher.Draw uses a two-pass structure: +/// +/// Opaque pass — DepthMask(true): writes depth so that +/// later transparent geometry sorts correctly against solid surfaces. +/// Transparent pass — DepthMask(false): reads depth but +/// does NOT write it, so alpha-blended surfaces don't occlude each +/// other by Z-fighting. +/// +/// The partition that decides which pass a batch enters is +/// : +/// Opaque and ClipMap go to the opaque pass (depth write); +/// AlphaBlend, Additive, InvAlpha go to the +/// transparent pass (no depth write). +/// +/// +public sealed class WbDispatcherDepthMaskTests +{ + [Theory] + [InlineData(TranslucencyKind.Opaque, true)] // opaque pass — depth write + [InlineData(TranslucencyKind.ClipMap, true)] // foliage — depth write (binary alpha / A2C) + [InlineData(TranslucencyKind.AlphaBlend, false)] // transparent — no depth write + [InlineData(TranslucencyKind.Additive, false)] + [InlineData(TranslucencyKind.InvAlpha, false)] + public void IsOpaquePartition_ImpliesDepthWriteAttribution( + TranslucencyKind kind, bool expectsDepthWrite) + { + bool isOpaque = WbDrawDispatcher.IsOpaquePublic(kind); + Assert.Equal(expectsDepthWrite, isOpaque); + } +} From 3b684db0f19c29ca9e6798fa10b2e6308eba340d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:27:55 +0200 Subject: [PATCH 34/45] =?UTF-8?q?feat(A.5=20T22):=20fog=20wired=20from=20N?= =?UTF-8?q?=E2=82=81/N=E2=82=82=20+=20ACDREAM=5FFOG=5F*=5FMULT=20env=20var?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §4.8: fog ramp is tuned to mask the N₁ scenery boundary. FogStart = N₁ × 192m × 0.7 ≈ 538m at default radii (4/12). FogEnd = N₂ × 192m × 0.95 ≈ 2188m. Multipliers exposed as env vars for fast iteration during visual gate. Override is injected into the UBO after SceneLightingUbo.Build() so fog color, lightning flash and mode still come from the sky keyframe. Adds ParseEnvFloat helper (InvariantCulture) for float env-var parsing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c879195..2938753 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6344,6 +6344,28 @@ public sealed class GameWindow : IDisposable Lighting.Tick(camPos); var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); + + // A.5 T22: override fog ramp with N₁/N₂-derived distances so the + // horizon fog masks the N₁ scenery boundary. Sky keyframe fog is + // retail-accurate at normal view distances but far too short for + // the extended N₂=12 (25×25 LB) streaming window. + // FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12). + // FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults. + // Multipliers exposed as env vars for fast iteration at visual gate. + { + const float LandblockSize = 192.0f; + float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f); + float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f); + float fogStart = _nearRadius * LandblockSize * startMult; + float fogEnd = _farRadius * LandblockSize * endMult; + // Preserve fog color (xyz), lightning flash (z), and mode (w). + ubo.FogParams = new System.Numerics.Vector4( + fogStart, + fogEnd, + ubo.FogParams.Z, // lightning flash — unchanged + ubo.FogParams.W); // fog mode — unchanged + } + _sceneLightingUbo?.Upload(ubo); // Never cull the landblock the player is currently on. @@ -8862,6 +8884,17 @@ public sealed class GameWindow : IDisposable return copy[copy.Length - 1 - offset]; } + /// A.5 T22: parse a float environment variable, returning + /// when the variable is absent or unparseable. + private static float ParseEnvFloat(string name, float defaultValue) + { + var s = System.Environment.GetEnvironmentVariable(name); + if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return v; + return defaultValue; + } + private void OnClosing() { // Phase A.1: join the streamer worker thread before tearing down GL From c473feedb3726d23938d0decad1a0f47ca80a2a5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:28:45 +0200 Subject: [PATCH 35/45] feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] / [TERRAIN-DIAG] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A.5 spec §2 acceptance criterion 6: entity dispatcher median ≤ 2.0ms; terrain dispatcher median ≤ 1.0ms at standstill. When the median exceeds the budget, prefix the DIAG line with " BUDGET_OVER" so the regression is grep-friendly during perf testing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 6 +++++- src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2938753..4927cf0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -8847,8 +8847,12 @@ public sealed class GameWindow : IDisposable long cpuP95HundredthsUs = TerrainDiagPercentile95Micros(_terrainCpuSamples); double cpuMedUs = cpuMedHundredthsUs / 100.0; double cpuP95Us = cpuP95HundredthsUs / 100.0; + // A.5 T23: flag when terrain dispatcher median exceeds 1.0ms budget + // (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix. + const double TerrainBudgetUs = 1000.0; + string terrainBudgetFlag = cpuMedUs > TerrainBudgetUs ? " BUDGET_OVER" : ""; Console.WriteLine( - $"[TERRAIN-DIAG] cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " + + $"[TERRAIN-DIAG]{terrainBudgetFlag} cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " + $"draws={_terrain?.VisibleSlots ?? 0}/frame " + $"visible={_terrain?.VisibleSlots ?? 0} " + $"loaded={_terrain?.LoadedSlots ?? 0} " + diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 5d35f68..3a4db8c 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -583,8 +583,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable long cpuP95 = Percentile95Micros(_cpuSamples); long gpuMed = MedianMicros(_gpuSamples); long gpuP95 = Percentile95Micros(_gpuSamples); + // A.5 T23: flag when entity dispatcher median exceeds 2.0ms budget + // (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix. + const long BudgetUs = 2000; + string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : ""; Console.WriteLine( - $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " + + $"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " + $"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95"); _entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0; _lastLogTick = now; From afa42001077e856bc5a4006af1a034386ba89acc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:37:17 +0200 Subject: [PATCH 36/45] feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2) Add QualityPreset enum + QualitySettings readonly record struct with From(preset) table and WithEnvOverrides() env-var override layer. Four presets (Low/Medium/High/Ultra) drive NearRadius, FarRadius, MsaaSamples, AnisotropicLevel, AlphaToCoverage, MaxCompletionsPerFrame. Env vars (ACDREAM_NEAR_RADIUS, ACDREAM_FAR_RADIUS, ACDREAM_MSAA_SAMPLES, ACDREAM_ANISOTROPIC, ACDREAM_A2C, ACDREAM_MAX_COMPLETIONS_PER_FRAME) override individual preset fields for dev spot-testing. DisplaySettings gains a Quality: QualityPreset field (default High); SettingsStore persists/loads it under display."quality" as an enum name string with Enum.TryParse fallback. 12 new QualityPresetTests cover the preset table (radii, msaa, aniso, a2c, completions) and all six env-var override paths. 415 UI.Abstractions tests passing. Wiring into GameWindow / WbDrawDispatcher / TerrainAtlas follows in commit 2 of this task. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Panels/Settings/DisplaySettings.cs | 7 +- .../Panels/Settings/SettingsStore.cs | 23 ++- .../Settings/QualityPreset.cs | 67 +++++++ .../Panels/Settings/QualityPresetTests.cs | 181 ++++++++++++++++++ .../Panels/Settings/SettingsStoreTests.cs | 3 +- 5 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Settings/QualityPreset.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/QualityPresetTests.cs diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index 05438b0..3b5a2b6 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; @@ -20,7 +21,8 @@ public sealed record DisplaySettings( bool VSync, float FieldOfView, float Gamma, - bool ShowFps) + bool ShowFps, + QualityPreset Quality) { /// Values used on first launch / when settings.json is absent. /// All defaults pinned to the pre-L.0 runtime state — Resolution @@ -35,7 +37,8 @@ public sealed record DisplaySettings( VSync: false, FieldOfView: 60f, Gamma: 1.0f, - ShowFps: true); + ShowFps: true, + Quality: QualityPreset.High); /// 16:9 resolution presets offered in the dropdown. public static IReadOnlyList AvailableResolutions { get; } = new[] diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 11264fc..5cb20e6 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; +using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; @@ -62,12 +63,13 @@ public sealed class SettingsStore var d = DisplaySettings.Default; return new DisplaySettings( - Resolution: ReadString (disp, "resolution", d.Resolution), - Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), - VSync: ReadBool (disp, "vsync", d.VSync), - FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), - Gamma: ReadFloat (disp, "gamma", d.Gamma), - ShowFps: ReadBool (disp, "showFps", d.ShowFps)); + Resolution: ReadString (disp, "resolution", d.Resolution), + Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), + VSync: ReadBool (disp, "vsync", d.VSync), + FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), + Gamma: ReadFloat (disp, "gamma", d.Gamma), + ShowFps: ReadBool (disp, "showFps", d.ShowFps), + Quality: ReadQuality (disp, "quality", d.Quality)); } catch (Exception ex) { @@ -327,6 +329,7 @@ public sealed class SettingsStore ["fieldOfView"] = d.FieldOfView, ["fullscreen"] = d.Fullscreen, ["gamma"] = d.Gamma, + ["quality"] = d.Quality.ToString(), ["resolution"] = d.Resolution, ["showFps"] = d.ShowFps, ["vsync"] = d.VSync, @@ -405,4 +408,12 @@ public sealed class SettingsStore private static float ReadFloat(JsonElement obj, string name, float fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetSingle() : fallback; + + private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback) + { + if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) + return fallback; + var s = el.GetString(); + return Enum.TryParse(s, ignoreCase: true, out var v) ? v : fallback; + } } diff --git a/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs b/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs new file mode 100644 index 0000000..e215d66 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs @@ -0,0 +1,67 @@ +namespace AcDream.UI.Abstractions.Settings; + +/// +/// A.5 T22.5: single user-facing quality knob that drives streaming radii, +/// MSAA samples, anisotropic level, alpha-to-coverage, and max completions +/// per frame in a single setting. Individual fields can still be overridden +/// by env vars (see ). +/// +public enum QualityPreset { Low, Medium, High, Ultra } + +/// +/// Resolved per-preset quality parameters. Constructed via +/// then optionally overridden with +/// before applying to the +/// renderer and streaming controller. +/// +public readonly record struct QualitySettings( + int NearRadius, + int FarRadius, + int MsaaSamples, // 0 = off, 2, 4, 8 + int AnisotropicLevel, // 1 = off, 4, 8, 16 + bool AlphaToCoverage, + int MaxCompletionsPerFrame) +{ + /// + /// Return the default for . + /// Unknown enum values fall back to . + /// + public static QualitySettings From(QualityPreset preset) => preset switch + { + QualityPreset.Low => new(NearRadius: 2, FarRadius: 5, MsaaSamples: 0, AnisotropicLevel: 4, AlphaToCoverage: false, MaxCompletionsPerFrame: 2), + QualityPreset.Medium => new(NearRadius: 3, FarRadius: 8, MsaaSamples: 2, AnisotropicLevel: 8, AlphaToCoverage: false, MaxCompletionsPerFrame: 3), + QualityPreset.High => new(NearRadius: 4, FarRadius: 12, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 4), + QualityPreset.Ultra => new(NearRadius: 5, FarRadius: 15, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 6), + _ => From(QualityPreset.High), + }; + + /// + /// Apply env-var overrides to a preset's resolved settings. Per-field + /// env vars beat the preset (so devs can spot-test a single dimension). + /// Unset or empty env vars leave the preset default unchanged. + /// + public static QualitySettings WithEnvOverrides(QualitySettings baseSettings) + { + int nearRadius = TryParseEnvInt("ACDREAM_NEAR_RADIUS", baseSettings.NearRadius); + int farRadius = TryParseEnvInt("ACDREAM_FAR_RADIUS", baseSettings.FarRadius); + int msaa = TryParseEnvInt("ACDREAM_MSAA_SAMPLES", baseSettings.MsaaSamples); + int aniso = TryParseEnvInt("ACDREAM_ANISOTROPIC", baseSettings.AnisotropicLevel); + // Bool override: any non-empty value other than "0"/"false" enables A2C. + // Empty / unset → keep preset default. + var a2cEnv = System.Environment.GetEnvironmentVariable("ACDREAM_A2C"); + bool a2c = a2cEnv switch + { + null or "" => baseSettings.AlphaToCoverage, + "0" or "false" or "False" or "FALSE" => false, + _ => true, + }; + int completions = TryParseEnvInt("ACDREAM_MAX_COMPLETIONS_PER_FRAME", baseSettings.MaxCompletionsPerFrame); + return new QualitySettings(nearRadius, farRadius, msaa, aniso, a2c, completions); + } + + private static int TryParseEnvInt(string name, int defaultValue) + { + var s = System.Environment.GetEnvironmentVariable(name); + return s is not null && int.TryParse(s, out var v) ? v : defaultValue; + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/QualityPresetTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/QualityPresetTests.cs new file mode 100644 index 0000000..754cba9 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/QualityPresetTests.cs @@ -0,0 +1,181 @@ +using AcDream.UI.Abstractions.Settings; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// A.5 T22.5: preset table + env-var override +/// coverage. Env-var tests clear their variables in finally blocks so +/// parallel runners cannot bleed state between tests. +/// +public class QualityPresetTests +{ + [Theory] + [InlineData(QualityPreset.Low, 2, 5, 0)] + [InlineData(QualityPreset.Medium, 3, 8, 2)] + [InlineData(QualityPreset.High, 4, 12, 4)] + [InlineData(QualityPreset.Ultra, 5, 15, 4)] + public void From_Preset_ProducesExpectedRadiiAndMsaa( + QualityPreset preset, int n1, int n2, int msaa) + { + var s = QualitySettings.From(preset); + Assert.Equal(n1, s.NearRadius); + Assert.Equal(n2, s.FarRadius); + Assert.Equal(msaa, s.MsaaSamples); + } + + [Theory] + [InlineData(QualityPreset.Low, 4, false)] + [InlineData(QualityPreset.Medium, 8, false)] + [InlineData(QualityPreset.High, 16, true)] + [InlineData(QualityPreset.Ultra, 16, true)] + public void From_Preset_ProducesExpectedAnisoAndA2C( + QualityPreset preset, int aniso, bool a2c) + { + var s = QualitySettings.From(preset); + Assert.Equal(aniso, s.AnisotropicLevel); + Assert.Equal(a2c, s.AlphaToCoverage); + } + + [Theory] + [InlineData(QualityPreset.Low, 2)] + [InlineData(QualityPreset.Medium, 3)] + [InlineData(QualityPreset.High, 4)] + [InlineData(QualityPreset.Ultra, 6)] + public void From_Preset_ProducesExpectedMaxCompletions( + QualityPreset preset, int expected) + { + var s = QualitySettings.From(preset); + Assert.Equal(expected, s.MaxCompletionsPerFrame); + } + + [Fact] + public void EnvVar_NearRadius_OverridesPreset() + { + System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", "2"); + try + { + var s = QualitySettings.From(QualityPreset.High); // High = NearRadius=4 normally + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.Equal(2, resolved.NearRadius); + Assert.Equal(12, resolved.FarRadius); // FarRadius unaffected + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null); } + } + + [Fact] + public void EnvVar_FarRadius_OverridesPreset() + { + System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", "20"); + try + { + var s = QualitySettings.From(QualityPreset.High); + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.Equal(4, resolved.NearRadius); // NearRadius unaffected + Assert.Equal(20, resolved.FarRadius); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null); } + } + + [Fact] + public void EnvVar_AlphaToCoverage_BooleanParsing() + { + // Ensure "0" and "false" disable; other values enable. + System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "0"); + try + { + var s = QualitySettings.From(QualityPreset.High); // High has A2C=true + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.False(resolved.AlphaToCoverage); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); } + } + + [Fact] + public void EnvVar_AlphaToCoverage_FalseString_Disables() + { + System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "false"); + try + { + var s = QualitySettings.From(QualityPreset.High); + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.False(resolved.AlphaToCoverage); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); } + } + + [Fact] + public void EnvVar_AlphaToCoverage_NonZeroEnables() + { + System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "1"); + try + { + var s = QualitySettings.From(QualityPreset.Low); // Low has A2C=false + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.True(resolved.AlphaToCoverage); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); } + } + + [Fact] + public void EnvVar_Unset_LeavesPresetDefault() + { + // Ensure no env vars are set for this test's fields. + System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null); + System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null); + System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); + + var s = QualitySettings.From(QualityPreset.High); + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.Equal(s, resolved); + } + + [Fact] + public void From_UndefinedPreset_FallsBackToHigh() + { + var s = QualitySettings.From((QualityPreset)99); + Assert.Equal(4, s.NearRadius); // High default + Assert.Equal(12, s.FarRadius); + Assert.Equal(4, s.MsaaSamples); + Assert.True(s.AlphaToCoverage); + } + + [Fact] + public void EnvVar_MaxCompletionsPerFrame_OverridesPreset() + { + System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", "8"); + try + { + var s = QualitySettings.From(QualityPreset.High); // High = 4 + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.Equal(8, resolved.MaxCompletionsPerFrame); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", null); } + } + + [Fact] + public void EnvVar_MsaaSamples_OverridesPreset() + { + System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", "8"); + try + { + var s = QualitySettings.From(QualityPreset.High); // High = 4 + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.Equal(8, resolved.MsaaSamples); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", null); } + } + + [Fact] + public void EnvVar_Anisotropic_OverridesPreset() + { + System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", "4"); + try + { + var s = QualitySettings.From(QualityPreset.High); // High = 16 + var resolved = QualitySettings.WithEnvOverrides(s); + Assert.Equal(4, resolved.AnisotropicLevel); + } + finally { System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", null); } + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index edc24b2..b54d0f0 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -44,7 +44,8 @@ public sealed class SettingsStoreTests : System.IDisposable VSync: false, FieldOfView: 100f, Gamma: 1.4f, - ShowFps: true); + ShowFps: true, + Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra); store.SaveDisplay(original); var loaded = store.LoadDisplay(); From 28d2c6018ef4bc0be0bad39b697c06ad6d23a17c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 08:43:06 +0200 Subject: [PATCH 37/45] feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2) GameWindow.OnLoad resolves QualitySettings.From(_persistedDisplay.Quality) + WithEnvOverrides() immediately after LoadAndApplyPersistedSettings, stores result in _resolvedQuality field. All six quality dimensions applied: - NearRadius / FarRadius: replace old T16 env-var-only block; preset drives the radii, legacy ACDREAM_STREAM_RADIUS override still honoured. - MsaaSamples: WindowOptions.Samples reads from startup quality resolution in Run() (pre-window-create read from SettingsStore). MSAA cannot change at runtime; ReapplyQualityPreset logs a restart-required warning if the new preset would change it. - AnisotropicLevel: TerrainAtlas.SetAnisotropic() called after Build() and again in ReapplyQualityPreset. Temporarily removes bindless residency before the GL TexParameter call, re-makes resident after. - AlphaToCoverage: WbDrawDispatcher.AlphaToCoverage property gates the glEnable/glDisable(SampleAlphaToCoverage) pair around the opaque pass. - MaxCompletionsPerFrame: set on StreamingController after construction and after each mid-session restart. ReapplyQualityPreset(QualityPreset) method handles mid-session changes (Settings panel Quality dropdown Save): rebuilds streamer + controller for radius changes, toggles A2C and aniso immediately, logs MSAA restart caveat. onSaveDisplay callback updated to call ReapplyQualityPreset when Quality field changes. TerrainModernRenderer.Atlas property added to expose the atlas for mid-session aniso updates. 991 tests passing, 8 pre-existing failures unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 175 ++++++++++++++++-- src/AcDream.App/Rendering/TerrainAtlas.cs | 37 ++++ .../Rendering/TerrainModernRenderer.cs | 4 + .../Rendering/Wb/WbDrawDispatcher.cs | 15 +- .../Panels/Settings/SettingsPanel.cs | 21 ++- 5 files changed, 236 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4927cf0..5226921 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -86,6 +86,13 @@ public sealed class GameWindow : IDisposable private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) + // A.5 T22.5: resolved quality settings (preset + env-var overrides). + // Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on + // ReapplyQualityPreset(). Default matches QualityPreset.High so the field + // is valid before OnLoad fires (no GL calls are made before OnLoad anyway). + private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality = + AcDream.UI.Abstractions.Settings.QualitySettings.From( + AcDream.UI.Abstractions.Settings.QualityPreset.High); private uint? _lastLivePlayerLandblockId; // Phase B.3: physics engine — populated from the streaming pipeline. @@ -820,6 +827,16 @@ public sealed class GameWindow : IDisposable public void Run() { + // A.5 T22.5: resolve quality preset BEFORE creating the window so + // Samples (MSAA) is baked into WindowOptions correctly. GL context + // sample count cannot change at runtime; all other quality fields are + // applied again in OnLoad after the full settings load. + var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + var startupDisplay = startupStore.LoadDisplay(); + var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); + var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -830,7 +847,11 @@ public sealed class GameWindow : IDisposable ContextFlags.ForwardCompatible, new APIVersion(4, 3)), VSync = false, // off during development so the perf overlay shows true framerate - Samples = 4, // A.5 T20: MSAA 4x for A2C foliage smoothing + // A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample). + // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). + // Cannot be changed at runtime; Quality changes mid-session that would + // alter MsaaSamples are logged as a restart-required warning. + Samples = startupQuality.MsaaSamples, }; _window = Window.Create(options); @@ -1094,6 +1115,18 @@ public sealed class GameWindow : IDisposable // without re-loading. LoadAndApplyPersistedSettings(); + // A.5 T22.5: resolve quality preset immediately after settings load so + // _resolvedQuality is available for TerrainAtlas.SetAnisotropic, + // WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below. + { + var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality); + _resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase); + if (!_resolvedQuality.Equals(qBase)) + Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}"); + else + Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}"); + } + // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. @@ -1227,6 +1260,12 @@ public sealed class GameWindow : IDisposable // already track DisplayDraft via the // per-frame push. ApplyDisplayWindowState(display); + // A.5 T22.5: apply quality preset if it changed. + // MSAA changes log a restart-required warning + // inside ReapplyQualityPreset; all other fields + // apply immediately. + _persistedDisplay = display; + ReapplyQualityPreset(display.Quality); } catch (Exception ex) { @@ -1453,6 +1492,10 @@ public sealed class GameWindow : IDisposable // atlas exposes bindless handles for the modern terrain path, so // BindlessSupport is threaded through. var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport); + // A.5 T22.5: apply anisotropic level from quality preset. Build() + // hard-codes 16x; override here to match the resolved quality so Low + // (4x) and Medium (8x) actually take effect. + terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel); _terrain = new TerrainModernRenderer(_gl, _bindlessSupport, _terrainModernShader!, terrainAtlas); @@ -1562,6 +1605,8 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!); + // A.5 T22.5: apply A2C gate from quality preset. + _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -1579,20 +1624,17 @@ public sealed class GameWindow : IDisposable // the player. _particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats); - // Phase A.5 T16: two-tier radius env-var parsing. - // ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS set the two rings independently. - // Legacy ACDREAM_STREAM_RADIUS is honoured for backward-compat: it sets - // nearRadius and bumps farRadius to max(streamRadius, default farRadius). + // A.5 T22.5: apply radii from the already-resolved _resolvedQuality. + // _resolvedQuality was set by the quality block immediately after + // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. + // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. + _nearRadius = _resolvedQuality.NearRadius; + _farRadius = _resolvedQuality.FarRadius; + + // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and + // ensures farRadius >= streamRadius. { - var nearEnv = Environment.GetEnvironmentVariable("ACDREAM_NEAR_RADIUS"); - var farEnv = Environment.GetEnvironmentVariable("ACDREAM_FAR_RADIUS"); var legacyEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS"); - - if (int.TryParse(nearEnv, out var nr) && nr >= 0) _nearRadius = nr; - if (int.TryParse(farEnv, out var fr) && fr >= 0) _farRadius = fr; - - // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and - // ensures farRadius >= streamRadius. if (int.TryParse(legacyEnv, out var sr) && sr >= 0) { _nearRadius = sr; @@ -1649,6 +1691,8 @@ public sealed class GameWindow : IDisposable _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); }); + // A.5 T22.5: apply max-completions from resolved quality. + _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream @@ -8048,6 +8092,111 @@ public sealed class GameWindow : IDisposable } } + /// + /// A.5 T22.5: apply a new quality preset mid-session (called from the + /// Settings panel Save path when + /// changes). + /// + /// + /// What changes immediately: + /// + /// Streaming radii: disposes the old + /// + + /// and constructs new ones with the new radii. + /// Anisotropic filtering: calls + /// TerrainAtlas.SetAnisotropic. + /// Alpha-to-coverage gate: sets + /// WbDrawDispatcher.AlphaToCoverage. + /// Max completions per frame: updates + /// StreamingController.MaxCompletionsPerFrame. + /// + /// + /// + /// + /// What requires a restart: + /// MSAA samples are baked into the GL context via WindowOptions.Samples + /// at window creation time and cannot change at runtime. If the new preset + /// would change MsaaSamples, a warning is logged and MSAA is left + /// at its current level until the next launch. + /// + /// + public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset) + { + var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); + var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase); + + Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}"); + + // MSAA samples cannot change at runtime — warn if preset would differ. + if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples) + { + Console.WriteLine( + $"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " + + $"{newResolved.MsaaSamples}) requires a restart — skipped for this session."); + } + + _resolvedQuality = newResolved; + + // A2C gate — immediate toggle, no GL context restart needed. + if (_wbDrawDispatcher is not null) + _wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage; + + // Anisotropic — immediate GL TexParameter call on the terrain atlas. + _terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel); + + // Streaming radii — requires tearing down + rebuilding the controller + // (radii are constructor-time on StreamingController, not live-mutable). + // The ~1-2s hitch while the worker drains is acceptable for a settings change. + if (_streamer is not null && _streamingController is not null) + { + _nearRadius = newResolved.NearRadius; + _farRadius = newResolved.FarRadius; + + // StreamingController is stateless (no Dispose needed); dispose + // only the LandblockStreamer worker thread. + _streamer.Dispose(); + + _streamer = new AcDream.App.Streaming.LandblockStreamer( + loadLandblock: id => BuildLandblockForStreaming(id), + buildMeshOrNull: (id, lb) => + { + if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) + return null; + uint lbX = (id >> 24) & 0xFFu; + uint lbY = (id >> 16) & 0xFFu; + return AcDream.Core.Terrain.LandblockMesh.Build( + lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); + }); + _streamer.Start(); + + _streamingController = new AcDream.App.Streaming.StreamingController( + enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), + enqueueUnload: _streamer.EnqueueUnload, + drainCompletions: _streamer.DrainCompletions, + applyTerrain: ApplyLoadedTerrain, + state: _worldState, + nearRadius: _nearRadius, + farRadius: _farRadius, + removeTerrain: id => + { + if (_lightingSink is not null && + _worldState.TryGetLandblock(id, out var lb)) + { + foreach (var ent in lb!.Entities) + _lightingSink.UnregisterOwner(ent.Id); + } + _terrain?.RemoveLandblock(id); + _physicsEngine.RemoveLandblock(id); + _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); + }); + _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; + + Console.WriteLine( + $"[QUALITY] Streaming restarted: nearRadius={_nearRadius}, " + + $"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}"); + } + } + /// /// L.0 Display tab: framebuffer-resize handler — update GL viewport /// + camera aspect when the window is resized (by the user dragging diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index c0d488e..03f66f6 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -415,6 +415,43 @@ public sealed unsafe class TerrainAtlas : IDisposable Array.Empty(), Array.Empty(), Array.Empty()); } + /// + /// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at + /// runtime (called by when + /// the user changes Quality preset mid-session). Idempotent — calling with + /// the same level as the current setting is safe and produces no visual + /// change. The texture must not be resident-bindless when its parameters + /// are mutated; we temporarily make it non-resident if needed. + /// + public void SetAnisotropic(int level) + { + // If bindless handles are live we must make them non-resident before + // mutating texture state, then re-resident after. + bool wasResident = _handlesGenerated && _bindless is not null; + if (wasResident) + { + _bindless!.MakeNonResident(_terrainHandle); + // Alpha texture is not affected by anisotropic but we must keep + // residency symmetric — re-generate both handles after. + _bindless.MakeNonResident(_alphaHandle); + _handlesGenerated = false; + } + + _gl.BindTexture(TextureTarget.Texture2DArray, GlTexture); + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE + _gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level); + _gl.BindTexture(TextureTarget.Texture2DArray, 0); + + // Re-generate bindless handles if they were live before. + if (wasResident) + { + // GetBindlessHandles regenerates and makes resident. + _ = GetBindlessHandles(); + } + + Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x"); + } + public void Dispose() { // Phase 1: release bindless residency BEFORE deleting textures. diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 3f62493..0145ce9 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -35,6 +35,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable private readonly Shader _shader; private readonly TerrainAtlas _atlas; + /// A.5 T22.5: exposes the terrain atlas so callers can update + /// anisotropic level mid-session via . + public TerrainAtlas Atlas => _atlas; + private readonly TerrainSlotAllocator _alloc; // Per-slot live data (index by slot integer; null entries are unused slots). diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 3a4db8c..b72490e 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -68,6 +68,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly BindlessSupport _bindless; + /// + /// A.5 T22.5: gate for GL_SAMPLE_ALPHA_TO_COVERAGE around the opaque pass. + /// Default true matches T20 behavior. Set false for Low/Medium presets that + /// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off + /// avoids the unnecessary GL state thrash and is cleaner diagnostics). + /// Can be toggled mid-session via . + /// + public bool AlphaToCoverage { get; set; } = true; + // SSBO buffer ids private uint _instanceSsbo; private uint _batchSsbo; @@ -491,7 +500,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // A.5 T20: enable A2C for ClipMap foliage — GPU derives sample mask // from the alpha written by mesh_modern.frag so foliage edges are // smooth under MSAA 4x. A no-op for fully-opaque (α=1) batches. - _gl.Enable(EnableCap.SampleAlphaToCoverage); + // A.5 T22.5: gated by AlphaToCoverage property so Low/Medium presets + // (no MSAA) skip the unnecessary GL state change. + if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage); _shader.SetInt("uRenderPass", 0); _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque); @@ -502,7 +513,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable (uint)_opaqueDrawCount, (uint)DrawCommandStride); if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed); - _gl.Disable(EnableCap.SampleAlphaToCoverage); + if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage); } // ── Phase 8: transparent pass ──────────────────────────────────────── diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index a8a8034..698eee1 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; @@ -219,10 +220,23 @@ public sealed class SettingsPanel : IPanel if (renderer.Checkbox("Show FPS", ref showFps)) _vm.SetDisplay(d with { ShowFps = showFps }); + // A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA, + // anisotropic level, A2C, and max completions-per-frame as a unit. + // Resolution + anisotropic + A2C + completions apply immediately via + // ReapplyQualityPreset; MSAA samples require a restart (GL context + // cannot change sample count at runtime). + var presets = s_qualityPresetNames; + int qIdx = (int)d.Quality; + if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High; + if (renderer.Combo("Quality", ref qIdx, presets)) + _vm.SetDisplay(d with { Quality = (QualityPreset)qIdx }); + renderer.Spacing(); renderer.TextWrapped( "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " - + "preview live as you drag; Cancel reverts to the saved value."); + + "preview live as you drag; Cancel reverts to the saved value. " + + "Quality preset applies streaming radius, anisotropic, and A2C " + + "immediately on Save; MSAA sample count requires a restart."); } /// @@ -446,6 +460,11 @@ public sealed class SettingsPanel : IPanel + "round-trip lands."); } + // A.5 T22.5: preset label array parallel to QualityPreset enum values. + // Order must match the enum (Low=0, Medium=1, High=2, Ultra=3). + private static readonly string[] s_qualityPresetNames = + { "Low", "Medium", "High", "Ultra" }; + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. From 9217fd93cd3335a355cd8b0578e7c696a9f79704 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 09:10:42 +0200 Subject: [PATCH 38/45] =?UTF-8?q?fix(A.5):=20strip=20far-tier=20entities?= =?UTF-8?q?=20in=20worker=20(Bug=20A=20=E2=80=94=20far=20tier=20optimizati?= =?UTF-8?q?on)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A.5's two-tier streaming spec promised that far-tier landblocks ship terrain ONLY — no entities, no scenery, no interior cells. T13/T16 wired the controller side (RecenterTo emits ToLoadFar/ToLoadNear/ToPromote; controller passes JobKind to the worker), but the worker's HandleJob never branched on Kind: every load called BuildLandblockForStreaming which runs the full hydration + scenery generation + interior cell path. Result: at default radii (N₁=4 / N₂=12), 540 far-tier LBs each loaded their full entity layer (~132 entities/LB → ~71K entities total) into GpuWorldState. The dispatcher then walked all ~54K entities per frame (post-frustum-cull), driving the entity dispatcher cpu_us from ~3.6ms median (T24 baseline) to ~18-21ms (post-T22.5 horizon-test). User- observed: 40 FPS / 25ms frame time at horizon-safe settings; system crash at full High preset. Minimum-diff fix: in LandblockStreamer.HandleJob, after _loadLandblock returns, strip Entities to empty for LoadFar before posting Loaded. Worker still does wasted hydration CPU (off the render thread, harmless). Render-side dispatcher walk drops from ~54K to ~10K entities/frame. Math: post-fix entity dispatcher should drop to ~3-4ms median at N₁=4 / N₂=12 (matches T24's 3.6ms at radius=5 single-tier, since N₁=4 has 33% fewer near entities than N₁=5). Future optimization (N.6 / A.6): plumb JobKind through BuildLandblockForStreaming so the worker also skips the wasted CPU. Out of A.5 scope. Bug B (T17 WalkEntities allocation) is a smaller perf hit — defer if post-Bug-A FPS is acceptable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/LandblockStreamer.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index a3416de..0811c8e 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -177,11 +177,19 @@ public sealed class LandblockStreamer : IDisposable switch (job) { case LandblockStreamJob.Load load: - // TODO(A.5 T16): route by load.Kind. LoadFar will skip - // LandBlockInfo + scenery generation; PromoteToNear will skip - // mesh build (terrain already on GPU). Today every Kind takes - // the full-load path via _loadLandblock, which matches today's - // single-tier semantics. + // A.5 T26 follow-up (Bug A): far-tier LBs must NOT contribute + // entities to GpuWorldState — that defeats the whole purpose of + // the two-tier split. The factory still builds the full entity + // layer (LandblockLoader + scenery generation + interior cells) + // regardless of Kind because it doesn't know about JobKind today. + // We strip Entities here for far-tier results so the render- + // thread dispatcher walks only near-tier (~10K) entities, not + // all (~71K) entities at radius=12. + // + // Wasted worker-thread CPU is acceptable (it's off the render + // thread). A future optimization (TODO N.6 or A.6) plumbs Kind + // through BuildLandblockForStreaming so the dat read + scenery + // generation are skipped entirely for far-tier. try { var lb = _loadLandblock(load.LandblockId); @@ -200,6 +208,14 @@ public sealed class LandblockStreamer : IDisposable } var tier = load.Kind == LandblockStreamJobKind.LoadFar ? LandblockStreamTier.Far : LandblockStreamTier.Near; + if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0) + { + // Strip entities — far-tier ships terrain only. + lb = new LoadedLandblock( + lb.LandblockId, + lb.Heightmap, + System.Array.Empty()); + } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( load.LandblockId, tier, lb, mesh)); } From 0ad8c99c375c4885b1f4fbf5cc6db6cc2128b12c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 09:13:20 +0200 Subject: [PATCH 39/45] =?UTF-8?q?fix(A.5):=20WalkEntities=20scratch-list?= =?UTF-8?q?=20pattern=20(Bug=20B=20=E2=80=94=20T17=20GC=20pressure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T17's WalkEntities helper allocated a fresh List<(WorldEntity, int)> per frame to hold the (entity, meshRefIndex) pairs that pass visibility filters. At ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame of GC pressure on the render thread. The implementer's self-review flagged this as a future N.6 optimization; the post-T26 diagnostic showed it materially contributing to the perf regression (though Bug A — far-tier entity load — was the dominant factor). Refactor: split WalkEntities into two overloads. - WalkEntities(...) — test-friendly, allocates a fresh ToDraw list per call. Tests keep using this signature unchanged. - WalkEntitiesInto(..., scratch, ref result) — no-alloc, clears + populates a caller-provided scratch list. Draw uses this with a per-dispatcher _walkScratch field reused across frames. Test count unchanged (40 streaming + 8 bucketing tests still pass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index b72490e..6cd34f0 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -109,6 +109,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly Dictionary _groups = new(); private readonly List _opaqueDraws = new(); private readonly List _translucentDraws = new(); + // A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list + // instead of allocating a fresh List<(WorldEntity, int)> per frame. At + // ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame + // of GC pressure on the render thread under the original T17 shape. + private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new(); // Per-entity-cull AABB radius. Conservative — covers most entities; large // outliers (long banners, tall columns) are still landblock-culled. @@ -207,6 +212,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// recomputing Position±5 each frame. /// /// + /// + /// Test-friendly overload that allocates a fresh ToDraw list per call. + /// Production code () uses the no-alloc overload below + /// with a caller-provided scratch list. + /// internal static WalkResult WalkEntities( IEnumerable landblockEntries, FrustumPlanes? frustum, @@ -214,7 +224,32 @@ public sealed unsafe class WbDrawDispatcher : IDisposable HashSet? visibleCellIds, HashSet? animatedEntityIds) { - var result = new WalkResult { ToDraw = new List<(WorldEntity, int)>() }; + var scratch = new List<(WorldEntity Entity, int MeshRefIndex)>(); + var result = new WalkResult { ToDraw = scratch }; + WalkEntitiesInto( + landblockEntries, frustum, neverCullLandblockId, + visibleCellIds, animatedEntityIds, scratch, ref result); + return result; + } + + /// + /// No-alloc overload: clears + populates the caller-provided + /// list. reuses a per-dispatcher scratch field across frames to + /// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs. + /// Returns walk count via 's EntitiesWalked field. + /// + internal static void WalkEntitiesInto( + IEnumerable landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? animatedEntityIds, + List<(WorldEntity Entity, int MeshRefIndex)> scratch, + ref WalkResult result) + { + scratch.Clear(); + result.EntitiesWalked = 0; + result.ToDraw = scratch; foreach (var entry in landblockEntries) { @@ -236,7 +271,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue; result.EntitiesWalked++; for (int i = 0; i < entity.MeshRefs.Count; i++) - result.ToDraw.Add((entity, i)); + scratch.Add((entity, i)); } continue; } @@ -262,10 +297,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable result.EntitiesWalked++; for (int i = 0; i < entity.MeshRefs.Count; i++) - result.ToDraw.Add((entity, i)); + scratch.Add((entity, i)); } } - return result; } public void Draw( @@ -317,14 +351,20 @@ public sealed unsafe class WbDrawDispatcher : IDisposable yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById); } - var walkResult = WalkEntities( + // A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload + // that populates _walkScratch (a per-dispatcher field reused across frames) + // instead of allocating a fresh List<(WorldEntity, int)> per frame. + var walkResult = default(WalkResult); + WalkEntitiesInto( ToEntries(landblockEntries), frustum, neverCullLandblockId, visibleCellIds, - animatedEntityIds); + animatedEntityIds, + _walkScratch, + ref walkResult); - foreach (var (entity, partIdx) in walkResult.ToDraw) + foreach (var (entity, partIdx) in _walkScratch) { if (diag) _entitiesSeen++; From 462f9d63773c0ac51b5cb9580683018a2162065f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 09:38:38 +0200 Subject: [PATCH 40/45] docs(perf): roadmap for Tier 2 + Tier 3 entity-dispatcher optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured 2026-05-10 during Phase A.5 polish discussion. User asked why the 9070 XT @ 1440p doesn't hit Unreal-level FPS for an old game like AC. Answer: architectural — we rebuild the entire draw plan from scratch every frame instead of caching pre-baked static-world data. Tier 1 (entity-classification cache) lands as A.5 polish (separate commit). Tiers 2 + 3 documented here for future scheduling: - Tier 2 — Static/dynamic split with persistent groups ~2-week phase. Static entities (~95% of world) get permanent GPU- resident matrix slots, populated at spawn, dirty-tracked for delta upload. Per-frame CPU cost for static = LB-cull + dirty-flag check only. Estimated entity dispatcher: 3.5ms → 0.5-1ms median. 400-600 FPS at standstill, radius=12. - Tier 3 — GPU-side culling (compute pre-pass) ~1-month phase. Per-instance frustum cull moves to GPU compute shader. Compute writes draw-indirect buffer; rasterizer reads it. Estimated CPU dispatcher: ~0.05ms (essentially free). 600-1000+ FPS at standstill, radius=12. Doc captures effort estimates, sub-decisions, risks, mitigations, and scheduling triggers for each tier. Also notes the architectural ceiling (~800-1500 FPS for a C# + GL client; reaching native engine performance requires becoming a different engine). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-10-perf-tiers-2-3-roadmap.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md diff --git a/docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md b/docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md new file mode 100644 index 0000000..c7d9883 --- /dev/null +++ b/docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md @@ -0,0 +1,195 @@ +# Performance Tiers 2 + 3 — Future Roadmap + +**Created:** 2026-05-10 during Phase A.5 polish. +**Status:** Future planning — not for current execution. +**Context:** A.5 shipped two-tier streaming with the entity dispatcher landing at ~3.5ms median (post-Bug-A and Bug-B fixes). Tier 1 (entity-classification cache) lands as A.5 polish and brings the dispatcher inside the 2.0ms spec budget. Tiers 2 + 3 are the "next big perf wins" beyond Tier 1. + +--- + +## Background — why this exists + +Discussion captured 2026-05-10: user observed 200-240 FPS at radius=12 on a Radeon 9070 XT @ 1440p and asked why an "old game like AC" doesn't deliver Unreal-level (1000+ FPS) on this hardware. + +The honest answer: the bottleneck is *architectural*, not hardware. The CPU is single-threaded and rebuilds the entire draw plan from scratch every frame. Modern engines pre-bake static-world batches at content-cook time and rebuild only what changes. + +AC's design — server-spawned per-entity world streamed at runtime — doesn't naturally batch the way Unreal's pre-cooked content does. Closing the gap requires backporting modern techniques while preserving AC's data model. Tiers 2 and 3 are that backporting work. + +--- + +## Tier 2 — Static/dynamic split with persistent groups + +**Estimated effort:** ~10-15 days (2-week phase). +**Estimated win:** entity dispatcher ~3.5ms → **~0.5-1ms median** at radius=12. +**Total frame time:** ~4-5ms → **~2-3ms = 400-600 FPS at standstill.** + +### The core idea + +Today, `WbDrawDispatcher._groups` (the dictionary of "(mesh + texture + blend) → list of instances to draw") is cleared and rebuilt from scratch every frame. + +For trees, rocks, buildings, and other static entities (~95% of the world), the answer is identical every frame forever. Tier 2 makes the static-group instance buffers **persistent GPU-resident data**, just like Unreal's pre-baked world. The CPU only orchestrates "which groups are visible" per frame. + +### Architectural shift + +```csharp +class StaticInstancedGroup +{ + public GroupKey Key; + public Matrix4x4[] Matrices; // grown as entities spawn + public BitArray ActiveSlots; // for free-list reuse + public bool NeedsGpuUpload; // dirty flag for delta upload + public Dictionary EntityToSlot; // for despawn lookup + public uint InstanceBufferOffset; // start of group's slice in global SSBO +} +``` + +**On entity spawn (atlas-tier static):** allocate a slot in each relevant group, write the matrix, mark dirty. + +**On entity despawn:** free the slot, mark dirty. + +**Per frame:** +- Static groups: LB-cull each group (cheap). For visible groups, flag for draw. **No matrix copy. No list rebuild.** +- Dynamic entities (~50 NPCs/players): today's per-frame walk-and-classify. Keeps the existing slow path for things that legitimately change every frame. +- Upload only the dirty groups' matrix slices (delta upload, not full reupload). +- Issue 2 multi-draw-indirect calls. + +### Sub-decisions + +**Frustum cull granularity at the group level:** at group level you can't reject individual instances; you draw the whole group or none of it. Two strategies: + +- **Per-LB subgroups:** split each group into per-landblock subgroups. LB-frustum-culls reject subgroups whose LB is invisible. ~2K groups × ~5 LBs per group on average = ~10K subgroups. Each subgroup AABB cull is ~0.3 µs → ~3 ms per frame. Roughly a wash with today's per-entity cull. +- **Per-instance GPU cull (Tier 3):** compute pre-pass on the GPU writes which instances are visible to a draw-indirect buffer. ~0.05ms CPU. The right long-term answer. + +For Tier 2 alone, per-LB subgroups are the recommended approach — keep CPU culling, just at coarser granularity than per-entity. + +**Dynamic entities crossing LB boundaries:** when an NPC walks across a landblock boundary, it stays in the same group key but its "spatial bucket" changes. Solution: dynamic entities are tracked in a single global "dynamic group" outside the per-LB structure; they don't need spatial bucketing because there are only ~50 of them. + +**Palette override invalidation:** server event swaps an NPC's clothing color → group key changes. Treat as despawn-from-old + spawn-into-new. NPCs are dynamic so this just rebuckets them. + +**Animation overrides on static entities:** static entities don't animate. Trees don't bend (foliage wave is a vertex shader effect, not a group-key change). Buildings don't move. So the static path never invalidates. + +**EnvCell visibility:** dungeon entities are gated by per-cell visibility state. Need to track which group instances are tied to which cell, and during visibility cull, gate per-cell. Keep using existing `ParentCellId` field on WorldEntity. + +**Streaming load/unload integration:** when an LB unloads, all its static entity matrices need to be removed from their groups. Free-list management. Matches existing `LandblockSpawnAdapter` lifecycle. + +### Effort breakdown + +| Task | Days | +|---|---| +| Design + invariants document | 2 | +| Spawn-time slot allocator + free-list | 3 | +| Per-frame visibility + dirty-flag delta upload | 2 | +| Dynamic entity path (NPCs, projectiles) | 2 | +| Invalidation (palette/ObjDesc events) | 2 | +| EnvCell visibility integration | 1 | +| Streaming load/unload integration | 1 | +| Conformance testing | 2-3 | +| **Total** | **~10-15 days** | + +### Risks + +- **Slot management bugs** = double-frees or leaks (entities draw at random positions — visible). +- **Invalidation bugs** = stale matrices (entity teleports back to spawn point when palette changes). +- **Dynamic entity tracking** adds complexity around the static/dynamic boundary. + +### Mitigations + +- **Conformance test:** render a fixed scene through both pipelines, compare draw output. Adds CI infrastructure. +- **Per-frame validation in debug:** walk all groups, assert no orphan slots. +- **Hash invariant test:** static entities should produce stable group keys frame-over-frame. Add a debug assertion that fires once per frame in Debug builds. + +--- + +## Tier 3 — GPU-side culling (compute pre-pass) + +**Estimated effort:** ~1 month (longer phase). +**Estimated win:** entity dispatcher ~0.5-1ms (post-Tier-2) → **~0.05ms median.** +**Total frame time:** ~2-3ms → **~1.5-2ms = 600-1000+ FPS at standstill.** + +### The core idea + +Today (and after Tier 2), the CPU does per-LB or per-subgroup frustum culling and tells the GPU which groups to draw. + +Tier 3 moves per-instance frustum cull to the GPU via a compute shader pre-pass. The CPU just uploads "here are all 1M instance matrices" once; the GPU compute shader writes which ones are visible to a draw-indirect buffer; the rasterizer draws only those. + +This is the level Unreal is at. With this, per-frame CPU work for the entity dispatcher becomes essentially "tell the GPU what to do" + a tiny scratch upload. + +### Why Tier 3 needs Tier 2 first + +Without Tier 2's persistent group structure, GPU culling has nothing stable to operate on. The compute shader needs an addressable "here are the static instances" buffer to read from; that buffer only exists after Tier 2. + +### Sub-decisions to be made + +**Compute shader API:** OpenGL 4.3+ compute shaders are sufficient. We're already at GL 4.3+ for bindless. No additional capability requirement. + +**Indirect draw command generation:** the compute shader writes a `DrawElementsIndirectCommand[]` buffer per pass. Render thread issues `glMultiDrawElementsIndirect` reading from that buffer. No CPU readback. + +**LOD selection:** opportunity to add per-instance LOD selection in the compute shader (distance-based mesh detail). Not needed for A.5's scope; could be a Tier 4 follow-up. + +**Per-light shadow map culling:** if shadows ship, GPU culling extends naturally to per-light frustum cull. Significant win for shadow rendering. + +### Effort breakdown + +| Task | Days | +|---|---| +| Compute shader design + GLSL implementation | 4 | +| Buffer layout coordination with Tier 2 | 2 | +| Silk.NET compute dispatch integration | 3 | +| Indirect command compaction logic | 4 | +| LOD selection (optional, ~stretch) | 4 | +| Validation: per-instance cull matches CPU cull within epsilon | 3 | +| Conformance + regression testing | 5 | +| **Total** | **~21-25 days, ~1 month** | + +### Risks + +- **GPU stalls** if the compute shader takes longer than expected (esp. on lower-end GPUs). +- **Sync overhead** between compute pre-pass and rasterizer pass. +- **Debugging difficulty** — GPU compute bugs are harder to diagnose than CPU bugs. + +### Mitigations + +- **Profile-driven design:** measure compute shader runtime on target hardware before committing. +- **Fallback path:** keep CPU cull as a runtime-toggleable option (env var) so we can A/B compare. +- **GPU debugging tools:** RenderDoc captures + frame-by-frame compute shader inspection. + +--- + +## When to schedule these + +**Tier 2:** +- Best fit: dedicated 2-week phase after a SHIP cycle. Treat it like a Phase B/C/N (i.e., name it Phase A.6 or N.7). +- Trigger: user wants to push radius beyond 12 (e.g., to 15 or 20 for true continent-scale horizon). +- Trigger: user wants to add 100+ active NPCs in a city without dropping below 240Hz. + +**Tier 3:** +- Best fit: after Tier 2 has been live and stable for at least one cycle. +- Trigger: shadow map work begins (GPU cull + shadow cull share the same compute pre-pass infrastructure). +- Trigger: user wants 500+ FPS sustained for very-high-refresh scenarios (360Hz monitors, future hardware). + +**Both:** +- Don't bundle with other phases. These are dedicated perf phases with their own brainstorm + spec + plan + SHIP cycles. + +--- + +## What's "free" or smaller (out of Tier 1/2/3 scope but worth noting) + +- **Plumb `JobKind` properly through `BuildLandblockForStreaming`** (~30 min). Today's Bug A patch wastes worker-thread CPU on hydration that gets thrown away for far-tier. Cleaner code, slight CPU savings on worker. +- **Eliminate `ToEntries` adapter allocation in `Draw`** (~15 min). Tiny win (~25 KB / frame). Could fold into Tier 1. +- **Persistent-mapped indirect buffer** (~2 days). Today's `glBufferData` per frame becomes a pre-mapped persistent buffer. Marginal win on RDNA 4; meaningful on lower-end GPUs. +- **Multi-thread mesh-build worker pool** (~1 day). 2.7s first-traversal horizon-fill drops to 0.7s with 4 workers. UX win on first walk-into-region. + +These are good candidates for a "perf polish" mini-phase or to backfill into Tier 2. + +--- + +## The architectural ceiling + +Even with all three tiers, **a faithful AC client written in C# with bindless OpenGL tops out around 800-1500 FPS at radius=12 on RDNA 4 hardware**. Beyond that requires: + +- Native C++ rendering core (eliminate .NET GC + JIT overhead) +- DX12/Vulkan API (eliminate driver state validation) +- Offline content cooking (eliminate runtime mesh/texture decode) + +Each of those is a several-month undertaking and represents "becoming a different engine." The realistic target for acdream is 240-500 FPS at the user's monitor refresh, comfortably ahead of the visible-stutter threshold. Tier 1 + Tier 2 alone should deliver that for radius=12-15. + +For "Unreal-level FPS at full quality," that's a different project. From 3639a6f4ac4fe87b5e303c7e564c2d1926feb839 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 09:45:18 +0200 Subject: [PATCH 41/45] feat(perf): Tier 1 entity classification cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md Tier 1: cache the per-(entity, meshRef, batch) classification (TextureCache lookup, GroupKey hash, _groups dict insert) so the per-frame Draw inner loop becomes "look up cache → walk assignments → append matrix to group's Matrices list." For static entities (~95% of world: trees, rocks, buildings, scenery), the answer never changes between frames. Cache once at first visit; reuse permanently. Per-frame work for static drops from 4 expensive operations per (meshRef, batch) to 1 list-append. Estimated entity dispatcher: 3.5ms → ~1-1.5ms median at radius=12. Should land inside the 2.0ms spec budget. Implementation: - New EntityClassificationCache class (per-meshRef list of cached (group ref, baked-PartTransform) tuples) keyed by entity.Id. - ClassifyEntity does the one-time work; result populates _groups and the cache. - Draw inner loop: cache lookup → for each assignment, model = PartTransform × entityWorld; group.Matrices.Add(model). - Cache miss when ClassifyEntity finds NO mesh loaded yet (Vao == 0) → don't store; retry next frame. Avoids cache thrash during the streaming-in window. - Public InvalidateEntity(uint id) + ClearEntityCache() for explicit invalidation hooks. Wiring (palette swap on ObjDescEvent, MeshRefs hot-swap) is post-A.5 follow-up — for now, cache-stale entities show their pre-swap appearance until next respawn. Tier 2 (static/dynamic split with persistent groups) and Tier 3 (GPU compute culling) tracked in the roadmap doc. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 251 ++++++++++++++---- 1 file changed, 206 insertions(+), 45 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 6cd34f0..e8292b3 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -115,6 +115,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // of GC pressure on the render thread under the original T17 shape. private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new(); + // A.5 Tier 1 perf — entity classification cache (post-T26 SHIP polish). + // For static entities (~95% of world: trees, rocks, buildings, scenery), + // the per-(meshRef, batch) classification (TextureCache lookup, GroupKey + // hash, _groups dict insert) produces the same answer every frame + // forever. Cache it at first visit; per-frame work becomes "look up + // cache → walk assignments → append matrix to group's list." + // + // Invalidation today: cache is cleared on entity removal via + // InvalidateEntity. Mid-life mutations that change the entity's + // GroupKey (palette override change via ObjDescEvent, MeshRefs hot- + // swap) must call InvalidateEntity explicitly — those wiring points + // are post-A.5 follow-ups (cache-stale visual is muted: NPC clothes + // don't change color until next respawn). + private readonly Dictionary _entityCache = new(); + + private struct CachedBatchAssignment + { + public InstanceGroup Group; + public Matrix4x4 PartTransform; // baked: meshRef.PartTransform × setupPart, entityWorld at draw time + } + + private sealed class EntityClassificationCache + { + public uint Vao; + // AssignmentsByMeshRef[meshRefIndex] = list of (group, partTransform) for that meshRef. + // Length = entity.MeshRefs.Count at build time. + public List[] AssignmentsByMeshRef = + System.Array.Empty>(); + public bool DrewAny; + } + // Per-entity-cull AABB radius. Conservative — covers most entities; large // outliers (long banners, tall columns) are still landblock-culled. private const float PerEntityCullRadius = 5.0f; @@ -368,58 +399,48 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { if (diag) _entitiesSeen++; + // A.5 Tier 1 perf: look up or build the entity's classification + // cache. Static entities (~95% of world) hit the cache after frame 1. + // We don't cache entries where no mesh data was found at classify + // time — that would prevent the retry when streaming finishes loading + // the mesh on a later frame. + if (!_entityCache.TryGetValue(entity.Id, out var cache)) + { + cache = ClassifyEntity(entity, metaTable); + if (cache.Vao == 0) + { + // No mesh data loaded yet for any meshRef — retry next frame. + if (diag) _meshesMissing++; + continue; + } + _entityCache[entity.Id] = cache; + } + + var assignmentsByMeshRef = cache.AssignmentsByMeshRef; + if (partIdx >= assignmentsByMeshRef.Length) continue; + var assignments = assignmentsByMeshRef[partIdx]; + if (assignments.Count == 0) + { + // Specific meshRef missing at classify time but other meshRefs + // succeeded. Edge case: partial mesh load. Skip this part. + if (diag) _meshesMissing++; + continue; + } + + if (anyVao == 0) anyVao = cache.Vao; + var entityWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); - // Compute palette-override hash ONCE per entity (perf #4). - // Reused across every (part, batch) lookup so the FNV-1a fold - // over SubPalettes runs once instead of N times. Zero when the - // entity has no palette override (trees, scenery). - ulong palHash = 0; - if (entity.PaletteOverride is not null) - palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); - - // Note: GameWindow's spawn path already applies - // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — - // close-detail mesh swap for humanoids) to MeshRefs. We - // trust MeshRefs as the source of truth here. AnimatedEntityState's - // overrides become relevant only for hot-swap (0xF625 - // ObjDescEvent) which today rebuilds MeshRefs anyway. - var meshRef = entity.MeshRefs[partIdx]; - ulong gfxObjId = meshRef.GfxObjId; - - var renderData = _meshAdapter.TryGetRenderData(gfxObjId); - if (renderData is null) + for (int i = 0; i < assignments.Count; i++) { - if (diag) _meshesMissing++; - continue; - } - if (anyVao == 0) anyVao = renderData.VAO; - - bool drewAny = false; - if (renderData.IsSetup && renderData.SetupParts.Count > 0) - { - foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) - { - var partData = _meshAdapter.TryGetRenderData(partGfxObjId); - if (partData is null) continue; - - var model = ComposePartWorldMatrix( - entityWorld, meshRef.PartTransform, partTransform); - - ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); - drewAny = true; - } - } - else - { - var model = meshRef.PartTransform * entityWorld; - ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); - drewAny = true; + var c = assignments[i]; + var model = c.PartTransform * entityWorld; + c.Group.Matrices.Add(model); } - if (diag && drewAny) _entitiesDrawn++; + if (diag) _entitiesDrawn++; } // Nothing visible — skip the GL pass entirely. @@ -669,6 +690,146 @@ public sealed unsafe class WbDrawDispatcher : IDisposable return copy[idx]; } + /// + /// A.5 Tier 1 perf — classify all (meshRef, batch) tuples for an entity + /// once, return the cache. Per-frame Draw walks the cache + appends matrices, + /// skipping the per-batch TextureCache lookup, GroupKey hash, and _groups + /// dict insert. Static entities (~95% of world) hit the cache permanently + /// after first build; dynamic entities (palette swaps, ObjDesc events) need + /// explicit InvalidateEntity to rebuild. + /// + private EntityClassificationCache ClassifyEntity(WorldEntity entity, AcSurfaceMetadataTable metaTable) + { + var cache = new EntityClassificationCache + { + AssignmentsByMeshRef = new List[entity.MeshRefs.Count], + }; + for (int i = 0; i < cache.AssignmentsByMeshRef.Length; i++) + cache.AssignmentsByMeshRef[i] = new List(); + + // Compute palette-override hash ONCE per entity. Reused across every + // (part, batch) lookup. Zero when the entity has no palette override + // (trees, scenery, dat-static stabs/buildings). + ulong palHash = 0; + if (entity.PaletteOverride is not null) + palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); + + for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) + { + var meshRef = entity.MeshRefs[partIdx]; + ulong gfxObjId = meshRef.GfxObjId; + + var renderData = _meshAdapter.TryGetRenderData(gfxObjId); + if (renderData is null) continue; // mesh missing — caller retries next frame + if (cache.Vao == 0) cache.Vao = renderData.VAO; + + var assignments = cache.AssignmentsByMeshRef[partIdx]; + + if (renderData.IsSetup && renderData.SetupParts.Count > 0) + { + foreach (var (partGfxObjId, setupPartTransform) in renderData.SetupParts) + { + var partData = _meshAdapter.TryGetRenderData(partGfxObjId); + if (partData is null) continue; + // Bake (setupPartTransform * meshRef.PartTransform) into the + // assignment's PartTransform. entityWorld is applied per-frame. + // Matches ComposePartWorldMatrix's (restPose * animOverride * entityWorld) + // composition order: setupPartTransform = restPose, + // meshRef.PartTransform = animOverride. + var bakedPart = setupPartTransform * meshRef.PartTransform; + ClassifyBatchesIntoCache(partData, partGfxObjId, entity, meshRef, palHash, bakedPart, metaTable, assignments); + cache.DrewAny = true; + } + } + else + { + ClassifyBatchesIntoCache(renderData, gfxObjId, entity, meshRef, palHash, meshRef.PartTransform, metaTable, assignments); + cache.DrewAny = true; + } + } + return cache; + } + + /// + /// A.5 Tier 1 perf — same per-batch logic as + /// but stores results into instead of mutating + /// _groups[*].Matrices directly. _groups still gets populated (for new keys); + /// the cache stores stable references into _groups for per-frame Matrices.Add. + /// + private void ClassifyBatchesIntoCache( + ObjectRenderData renderData, + ulong gfxObjId, + WorldEntity entity, + MeshRef meshRef, + ulong palHash, + Matrix4x4 partTransform, + AcSurfaceMetadataTable metaTable, + List assignments) + { + for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) + { + var batch = renderData.Batches[batchIdx]; + + TranslucencyKind translucency; + if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta)) + translucency = meta.Translucency; + else + translucency = batch.IsAdditive ? TranslucencyKind.Additive + : batch.IsTransparent ? TranslucencyKind.AlphaBlend + : TranslucencyKind.Opaque; + + ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash); + if (texHandle == 0) continue; + + uint texLayer = 0; + var key = new GroupKey( + batch.IBO, batch.FirstIndex, (int)batch.BaseVertex, + batch.IndexCount, texHandle, texLayer, translucency); + + if (!_groups.TryGetValue(key, out var grp)) + { + grp = new InstanceGroup + { + Ibo = batch.IBO, + FirstIndex = batch.FirstIndex, + BaseVertex = (int)batch.BaseVertex, + IndexCount = batch.IndexCount, + BindlessTextureHandle = texHandle, + TextureLayer = texLayer, + Translucency = translucency, + }; + _groups[key] = grp; + } + + assignments.Add(new CachedBatchAssignment + { + Group = grp, + PartTransform = partTransform, + }); + } + } + + /// + /// A.5 Tier 1 perf — invalidate the classification cache for an entity. + /// Call when an entity's MeshRefs, PaletteOverride, or SurfaceOverrides + /// change (e.g. ObjDescEvent 0xF625, equip-slot updates, transmute). + /// Next frame's Draw will rebuild on demand. + /// + public void InvalidateEntity(uint entityId) + { + _entityCache.Remove(entityId); + } + + /// + /// A.5 Tier 1 perf — clear the entire entity classification cache. + /// Call on world reset (post-character-load, region change). The next + /// frame's Draw will rebuild on demand. + /// + public void ClearEntityCache() + { + _entityCache.Clear(); + } + private void ClassifyBatches( ObjectRenderData renderData, ulong gfxObjId, From 9b49009dd542234bca5e5a9d43e4194c34f65b79 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 09:53:26 +0200 Subject: [PATCH 42/45] Revert "feat(perf): Tier 1 entity classification cache" This reverts commit 3639a6f4ac4fe87b5e303c7e564c2d1926feb839. --- .../Rendering/Wb/WbDrawDispatcher.cs | 251 ++++-------------- 1 file changed, 45 insertions(+), 206 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e8292b3..6cd34f0 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -115,37 +115,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // of GC pressure on the render thread under the original T17 shape. private readonly List<(WorldEntity Entity, int MeshRefIndex)> _walkScratch = new(); - // A.5 Tier 1 perf — entity classification cache (post-T26 SHIP polish). - // For static entities (~95% of world: trees, rocks, buildings, scenery), - // the per-(meshRef, batch) classification (TextureCache lookup, GroupKey - // hash, _groups dict insert) produces the same answer every frame - // forever. Cache it at first visit; per-frame work becomes "look up - // cache → walk assignments → append matrix to group's list." - // - // Invalidation today: cache is cleared on entity removal via - // InvalidateEntity. Mid-life mutations that change the entity's - // GroupKey (palette override change via ObjDescEvent, MeshRefs hot- - // swap) must call InvalidateEntity explicitly — those wiring points - // are post-A.5 follow-ups (cache-stale visual is muted: NPC clothes - // don't change color until next respawn). - private readonly Dictionary _entityCache = new(); - - private struct CachedBatchAssignment - { - public InstanceGroup Group; - public Matrix4x4 PartTransform; // baked: meshRef.PartTransform × setupPart, entityWorld at draw time - } - - private sealed class EntityClassificationCache - { - public uint Vao; - // AssignmentsByMeshRef[meshRefIndex] = list of (group, partTransform) for that meshRef. - // Length = entity.MeshRefs.Count at build time. - public List[] AssignmentsByMeshRef = - System.Array.Empty>(); - public bool DrewAny; - } - // Per-entity-cull AABB radius. Conservative — covers most entities; large // outliers (long banners, tall columns) are still landblock-culled. private const float PerEntityCullRadius = 5.0f; @@ -399,48 +368,58 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { if (diag) _entitiesSeen++; - // A.5 Tier 1 perf: look up or build the entity's classification - // cache. Static entities (~95% of world) hit the cache after frame 1. - // We don't cache entries where no mesh data was found at classify - // time — that would prevent the retry when streaming finishes loading - // the mesh on a later frame. - if (!_entityCache.TryGetValue(entity.Id, out var cache)) - { - cache = ClassifyEntity(entity, metaTable); - if (cache.Vao == 0) - { - // No mesh data loaded yet for any meshRef — retry next frame. - if (diag) _meshesMissing++; - continue; - } - _entityCache[entity.Id] = cache; - } - - var assignmentsByMeshRef = cache.AssignmentsByMeshRef; - if (partIdx >= assignmentsByMeshRef.Length) continue; - var assignments = assignmentsByMeshRef[partIdx]; - if (assignments.Count == 0) - { - // Specific meshRef missing at classify time but other meshRefs - // succeeded. Edge case: partial mesh load. Skip this part. - if (diag) _meshesMissing++; - continue; - } - - if (anyVao == 0) anyVao = cache.Vao; - var entityWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); - for (int i = 0; i < assignments.Count; i++) + // Compute palette-override hash ONCE per entity (perf #4). + // Reused across every (part, batch) lookup so the FNV-1a fold + // over SubPalettes runs once instead of N times. Zero when the + // entity has no palette override (trees, scenery). + ulong palHash = 0; + if (entity.PaletteOverride is not null) + palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); + + // Note: GameWindow's spawn path already applies + // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — + // close-detail mesh swap for humanoids) to MeshRefs. We + // trust MeshRefs as the source of truth here. AnimatedEntityState's + // overrides become relevant only for hot-swap (0xF625 + // ObjDescEvent) which today rebuilds MeshRefs anyway. + var meshRef = entity.MeshRefs[partIdx]; + ulong gfxObjId = meshRef.GfxObjId; + + var renderData = _meshAdapter.TryGetRenderData(gfxObjId); + if (renderData is null) { - var c = assignments[i]; - var model = c.PartTransform * entityWorld; - c.Group.Matrices.Add(model); + if (diag) _meshesMissing++; + continue; + } + if (anyVao == 0) anyVao = renderData.VAO; + + bool drewAny = false; + if (renderData.IsSetup && renderData.SetupParts.Count > 0) + { + foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) + { + var partData = _meshAdapter.TryGetRenderData(partGfxObjId); + if (partData is null) continue; + + var model = ComposePartWorldMatrix( + entityWorld, meshRef.PartTransform, partTransform); + + ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); + drewAny = true; + } + } + else + { + var model = meshRef.PartTransform * entityWorld; + ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); + drewAny = true; } - if (diag) _entitiesDrawn++; + if (diag && drewAny) _entitiesDrawn++; } // Nothing visible — skip the GL pass entirely. @@ -690,146 +669,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable return copy[idx]; } - /// - /// A.5 Tier 1 perf — classify all (meshRef, batch) tuples for an entity - /// once, return the cache. Per-frame Draw walks the cache + appends matrices, - /// skipping the per-batch TextureCache lookup, GroupKey hash, and _groups - /// dict insert. Static entities (~95% of world) hit the cache permanently - /// after first build; dynamic entities (palette swaps, ObjDesc events) need - /// explicit InvalidateEntity to rebuild. - /// - private EntityClassificationCache ClassifyEntity(WorldEntity entity, AcSurfaceMetadataTable metaTable) - { - var cache = new EntityClassificationCache - { - AssignmentsByMeshRef = new List[entity.MeshRefs.Count], - }; - for (int i = 0; i < cache.AssignmentsByMeshRef.Length; i++) - cache.AssignmentsByMeshRef[i] = new List(); - - // Compute palette-override hash ONCE per entity. Reused across every - // (part, batch) lookup. Zero when the entity has no palette override - // (trees, scenery, dat-static stabs/buildings). - ulong palHash = 0; - if (entity.PaletteOverride is not null) - palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); - - for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) - { - var meshRef = entity.MeshRefs[partIdx]; - ulong gfxObjId = meshRef.GfxObjId; - - var renderData = _meshAdapter.TryGetRenderData(gfxObjId); - if (renderData is null) continue; // mesh missing — caller retries next frame - if (cache.Vao == 0) cache.Vao = renderData.VAO; - - var assignments = cache.AssignmentsByMeshRef[partIdx]; - - if (renderData.IsSetup && renderData.SetupParts.Count > 0) - { - foreach (var (partGfxObjId, setupPartTransform) in renderData.SetupParts) - { - var partData = _meshAdapter.TryGetRenderData(partGfxObjId); - if (partData is null) continue; - // Bake (setupPartTransform * meshRef.PartTransform) into the - // assignment's PartTransform. entityWorld is applied per-frame. - // Matches ComposePartWorldMatrix's (restPose * animOverride * entityWorld) - // composition order: setupPartTransform = restPose, - // meshRef.PartTransform = animOverride. - var bakedPart = setupPartTransform * meshRef.PartTransform; - ClassifyBatchesIntoCache(partData, partGfxObjId, entity, meshRef, palHash, bakedPart, metaTable, assignments); - cache.DrewAny = true; - } - } - else - { - ClassifyBatchesIntoCache(renderData, gfxObjId, entity, meshRef, palHash, meshRef.PartTransform, metaTable, assignments); - cache.DrewAny = true; - } - } - return cache; - } - - /// - /// A.5 Tier 1 perf — same per-batch logic as - /// but stores results into instead of mutating - /// _groups[*].Matrices directly. _groups still gets populated (for new keys); - /// the cache stores stable references into _groups for per-frame Matrices.Add. - /// - private void ClassifyBatchesIntoCache( - ObjectRenderData renderData, - ulong gfxObjId, - WorldEntity entity, - MeshRef meshRef, - ulong palHash, - Matrix4x4 partTransform, - AcSurfaceMetadataTable metaTable, - List assignments) - { - for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) - { - var batch = renderData.Batches[batchIdx]; - - TranslucencyKind translucency; - if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta)) - translucency = meta.Translucency; - else - translucency = batch.IsAdditive ? TranslucencyKind.Additive - : batch.IsTransparent ? TranslucencyKind.AlphaBlend - : TranslucencyKind.Opaque; - - ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash); - if (texHandle == 0) continue; - - uint texLayer = 0; - var key = new GroupKey( - batch.IBO, batch.FirstIndex, (int)batch.BaseVertex, - batch.IndexCount, texHandle, texLayer, translucency); - - if (!_groups.TryGetValue(key, out var grp)) - { - grp = new InstanceGroup - { - Ibo = batch.IBO, - FirstIndex = batch.FirstIndex, - BaseVertex = (int)batch.BaseVertex, - IndexCount = batch.IndexCount, - BindlessTextureHandle = texHandle, - TextureLayer = texLayer, - Translucency = translucency, - }; - _groups[key] = grp; - } - - assignments.Add(new CachedBatchAssignment - { - Group = grp, - PartTransform = partTransform, - }); - } - } - - /// - /// A.5 Tier 1 perf — invalidate the classification cache for an entity. - /// Call when an entity's MeshRefs, PaletteOverride, or SurfaceOverrides - /// change (e.g. ObjDescEvent 0xF625, equip-slot updates, transmute). - /// Next frame's Draw will rebuild on demand. - /// - public void InvalidateEntity(uint entityId) - { - _entityCache.Remove(entityId); - } - - /// - /// A.5 Tier 1 perf — clear the entire entity classification cache. - /// Call on world reset (post-character-load, region change). The next - /// frame's Draw will rebuild on demand. - /// - public void ClearEntityCache() - { - _entityCache.Clear(); - } - private void ClassifyBatches( ObjectRenderData renderData, ulong gfxObjId, From a28a5b75832fbdd09f30411d70ad8ddc5fddbca2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 10:06:26 +0200 Subject: [PATCH 43/45] docs(A.5 T27): spec + plan amendments for T22.5 + ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-09-phase-a5-two-tier-streaming.md | 70 ++++++++ ...5-09-phase-a5-two-tier-streaming-design.md | 166 ++++++++++++++++-- 2 files changed, 224 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md index 275b0cf..a53d596 100644 --- a/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md +++ b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md @@ -2046,6 +2046,75 @@ git commit -m "feat(A.5 T22): fog params wired from N₁/N₂ + ACDREAM_FOG_*_MU --- +## 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:** @@ -2429,6 +2498,7 @@ Spec coverage cross-check: | §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 | diff --git a/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md b/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md index 44ed02a..eaf92ca 100644 --- a/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md +++ b/docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md @@ -37,21 +37,21 @@ The headline win: walking around Holtburg, the user sees a real horizon - 240 Hz @ 2560×1440 (verified via `Get-CimInstance Win32_VideoController`). - Frame budget: **4.166 ms** at vsync. -### Acceptance metrics (Q9 Option B — tiered) +### Acceptance metrics (as shipped — revised with Quality Preset system) 1. **Build green; existing tests still green.** N.5b conformance sentinel passes (visual mesh Z = TerrainSurface.SampleZ within 1 mm). -2. **Standstill at Holtburg dueling field, 30 s with `[WB-DIAG]` and `[TERRAIN-DIAG]`:** - - Median frame time ≤ 4.166 ms (240 FPS sustained). - - p99 ≤ 4.5 ms (no vsync misses). -3. **Walking Holtburg → North Yanshi at run speed, 60 s trace:** - - Median ≥ 144 FPS (≤ 6.94 ms). - - p95 ≥ 120 FPS (≤ 8.33 ms). +2. **Standstill at user's selected preset on user's hardware:** + - 95% of frames hit ≤ (1000ms / monitor refresh rate). + - No absolute FPS number is required — the Quality Preset system (§4.10) + is the user's knob for trading quality vs frame budget. +3. **Walking at user's selected preset:** + - 95% of frames hit ≤ 1.5× (1000ms / monitor refresh rate). 4. **First traversal into virgin region (cold mesh cache):** - - Render thread frame time stays ≤ 8.33 ms throughout while the worker - fills the far-tier horizon (~2.7 s of "horizon filling in" is OK). -5. **Visual gate (user-driven):** user launches the client, walks - Holtburg → North Yanshi, and confirms: + - Render thread frame time stays within 2× the standstill budget while + the worker fills the far-tier horizon (~2.7 s of "horizon filling in" is OK). +5. **Visual gate (user-driven, same on all presets):** user launches the + client, walks Holtburg → North Yanshi, and confirms: - Horizon visible at ~2.3 km. - Fog blend at N₁ smooths the scenery boundary (no harsh cliff). - Distant terrain does not shimmer (mipmaps work). @@ -433,6 +433,106 @@ pass (it is, per `IsOpaque` returning true for ClipMap at line 738). If audit finds nothing wrong, ship a comment + a unit test that locks in the partition. Cheap insurance against future regression. +### 4.10 Quality Preset System (T22.5 — added mid-execution) + +**Background:** Added between T22 (fog wiring) and T23 (DIAG budgets) at +user's direction. The original spec had no preset concept; §2 was written +against absolute 240 FPS on fixed N₁/N₂. T22.5 makes both radii and every +quality knob user-controllable via a single enum. §2 was amended above to +reflect the per-preset, refresh-rate-relative acceptance criteria. + +#### 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 the canonical values: + +| 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 (see §4.10.3). + +#### Persistence and UI + +`DisplaySettings.Quality` (type `QualityPreset`) persists via the existing +`settings.json` infrastructure (Phase L.0). The Settings panel (F11) exposes +a Quality dropdown in its Display tab (`SettingsPanel.RenderDisplayTab`). + +#### Wiring (GameWindow.OnLoad + ReapplyQualityPreset) + +1. `GameWindow.OnLoad` resolves the active `QualitySettings`: + `QualitySettings.From(displaySettings.Quality).WithEnvOverrides(...)`. +2. `StreamingController` and `LandblockStreamer` are built with the preset's + `NearRadius` / `FarRadius`. +3. `TerrainAtlas.SetAnisotropic(settings.AnisotropicLevel)` called once at + load and again on reapply. +4. `WindowOptions.Samples = settings.MsaaSamples` applied at window creation + time only (MSAA mid-session change is structurally unsupported by OpenGL). +5. `WbDrawDispatcher.AlphaToCoverage = settings.AlphaToCoverage`. +6. `StreamingController.MaxCompletionsPerFrame = settings.MaxCompletionsPerFrame`. + +Mid-session quality change (F11 dropdown change → Save): + +- `GameWindow.ReapplyQualityPreset` rebuilds `StreamingController` + + `LandblockStreamer` with the new radii, re-applies anisotropic and + AlphaToCoverage. +- If `MsaaSamples` changed, logs a warning that MSAA sample count cannot be + changed mid-session; requires restart. + +#### Env-var overrides (§4.10.3) + +Applied by `QualitySettings.WithEnvOverrides` after the base preset is resolved. +Each field has one env var; all are optional. Logged at startup. + +| Env var | Field overridden | +|---|---| +| `ACDREAM_NEAR_RADIUS` | `NearRadius` | +| `ACDREAM_FAR_RADIUS` | `FarRadius` | +| `ACDREAM_MSAA_SAMPLES` | `MsaaSamples` | +| `ACDREAM_ANISOTROPIC` | `AnisotropicLevel` | +| `ACDREAM_A2C` | `AlphaToCoverage` (1/0/true/false) | +| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `MaxCompletionsPerFrame` | + +#### Tests + +12 tests in `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs` +cover: canonical preset values per enum member; `WithEnvOverrides` no-op when +no env vars set; `WithEnvOverrides` each override individually; invalid env-var +value falls back to base setting. + +#### Files + +- `src/AcDream.UI.Abstractions/Settings/QualityPreset.cs` — new +- `src/AcDream.UI.Abstractions/Settings/DisplaySettings.cs` — `Quality` field added +- `src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs` — Display tab + Quality dropdown (`RenderDisplayTab` method) +- `src/AcDream.App/Rendering/GameWindow.cs` — `ReapplyQualityPreset`, + `OnLoad` preset wiring +- `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs` — new (12 tests) + +#### Out of scope (deferred) + +- Auto-detect preset on first launch (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). + --- ## 5. Data flow @@ -668,7 +768,49 @@ Per the brainstorm Q10 confirmation: --- -## 11. References +## 11. What was deferred (post-A.5) + +The following items were identified during A.5 development but deferred to +post-A.5 phases. They are tracked as OPEN issues in `docs/ISSUES.md`. + +1. **Tier 1 entity-classification cache** (commit `3639a6f` reverted at + `9b49009`): First attempt cached `meshRef.PartTransform` which is mutated + per frame for animated entities (skeletal pose). Next attempt needs: + (a) audit AnimationSequencer + AnimationHookRouter to identify ALL + per-frame mutations of MeshRef state; (b) redesign cache to bypass + animated entities OR cache only the animation-invariant subset; (c) test + specifically with a moving animated NPC on screen. (`docs/ISSUES.md` #53) + +2. **Lifestone missing visual**: The Holtburg lifestone has not rendered since + earlier in A.5 development. Possibly Bug A's far-tier strip incorrectly + catching a near-tier entity, or a separate earlier regression. + (`docs/ISSUES.md` #52) + +3. **Plumb JobKind through BuildLandblockForStreaming**: Bug A's fix (commit + `9217fd9`) strips entities post-load in the worker. Proper fix: skip the + `LandBlockInfo` + scenery load entirely for far-tier jobs. ~30 min. + (`docs/ISSUES.md` #54) + +4. **Tier 2 — Static/dynamic split with persistent groups**: ~2-week phase. + Avoids per-frame entity re-classification by maintaining stable groups + keyed at spawn time. Roadmap doc at + `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`. + +5. **Tier 3 — GPU-side culling via compute pre-pass**: ~1-month phase. + Same roadmap doc. + +6. **Eliminate ToEntries adapter allocation**: tiny win (~25 KB/frame). + +7. **InvalidateEntity wiring on palette/ObjDesc events**: needed by the + Tier 1 retry. + +8. **Visual gate at full High preset**: never validated due to the + GPU+CPU stack-up OS crash earlier in A.5. With Bug A fixed the crash + likely won't recur; defer retest to post-A.5 perf polish. + +--- + +## 12. References (formerly §11) - **Handoff (cold-start):** [`docs/research/2026-05-10-phase-a5-handoff.md`](../../research/2026-05-10-phase-a5-handoff.md) - **N.5b handoff (predecessor):** [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md) From d93d8235398f4970890982285d4838fea3d0f528 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 10:06:40 +0200 Subject: [PATCH 44/45] docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap (2026-04-11-roadmap.md): - Status header updated to 2026-05-10 / A.5 as the shipped phase. - A.5 row added to shipped table (after A.3): two-tier streaming, QualityPreset, Bug A/B fixes, deferred items, plan archive link. - A.5 sub-piece in Phase A section marked SHIPPED with archive link (replaces the old "not yet brainstormed" entry). - N.6 bullet changed from "Currently in flight" to "Planned (post-A.5 polish takes priority)"; A.5's landing means the "direct higher-radius comparison once A.5 lands" item is now available. ISSUES.md: - #52 (A.5/lifestone-missing): Holtburg lifestone not rendering since A.5 dev; two root-cause candidates; investigation approach. - #53 (A.5/tier1-redo): classification cache reverted at 9b49009; animation-mutation audit required before retry; 1-week estimate. - #54 (A.5/jobkind-plumbing): Bug A's post-load strip wastes worker CPU; proper fix plumbs JobKind through BuildLandblockForStreaming; 30 min–1 hour estimate. CLAUDE.md: - "Currently in flight" paragraph updated from N.6 to Post-A.5 polish (issues #52/#53/#54) with note that N.6 follows. - A.5 shipped paragraph added (mirrors N.5b/N.5/N.4 format). - WB integration cribs: new bullet documenting the two-tier streaming architecture (StreamingRegion / StreamingController / LandblockStreamer / GpuWorldState, N₁/N₂ defaults, QualitySettings, spec pointer). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 41 +++++++++++++------ docs/ISSUES.md | 68 ++++++++++++++++++++++++++++++++ docs/plans/2026-04-11-roadmap.md | 11 +++--- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d8de01..b4f0aba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,21 @@ ourselves". uvec2 + `sampler2DArray(handle)` constructor pattern (NOT the direct `uniform sampler2DArray` + `glProgramUniformHandleARB` form, which GL_INVALID_OPERATIONs on at least one driver). +- **Two-tier streaming architecture (Phase A.5, shipped 2026-05-10).** + `src/AcDream.App/Streaming/` owns the full streaming pipeline. Key types: + `StreamingRegion` (two-radius Chebyshev window: N₁=near, N₂=far; produces + `TwoTierDiff` with 5 transition lists per tick), `StreamingController` + (render-thread coordinator: routes `TwoTierDiff` to the worker queue and + drains completions up to `MaxCompletionsPerFrame` per frame), + `LandblockStreamer` (single background worker thread: `LoadFar` = heightmap + + mesh only, `LoadNear` = heightmap + `LandBlockInfo` + scenery + mesh, + `PromoteToNear` = `LandBlockInfo` + scenery only), + `GpuWorldState` (render-thread entity state: `AddEntitiesToExistingLandblock` + for promotions, `RemoveEntitiesFromLandblock` for demotions). + Default: N₁=4 (81 near LBs, full detail), N₂=12 (544 far LBs, terrain + only). Quality Preset system (`QualitySettings.From(preset)`) controls + both radii and MSAA/anisotropic/A2C/completions-per-frame as a unit. + Spec: `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`. **Execution phases:** R1→R8 in the architecture doc. Each phase has clear goals, test criteria, and builds on the previous. Don't skip phases. @@ -510,19 +525,19 @@ acdream's plan lives in two files committed to the repo: acceptance criteria. Do not drift from the spec without explicit user approval. -**Currently in flight: Phase N.6 — Perf polish.** -Roadmap entry at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md). -Builds on N.5 + N.5b. Legacy renderers (`InstancedMeshRenderer`, -`StaticMeshRenderer`, `WbFoundationFlag`) were retired in the N.5 ship -amendment, and the terrain legacy renderer (`TerrainChunkRenderer` + -`TerrainRenderer` + legacy `terrain.vert/.frag`) was retired in N.5b. -N.6 scope is perf-only: WB atlas adoption, persistent-mapped buffers -(strong candidate after N.5b's per-frame DEIC `BufferSubData`), -GPU-side culling via compute pre-pass, GL_TIME_ELAPSED query -double-buffering, direct higher-radius perf comparison once A.5 lands, -legacy `Texture2D`/`sampler2D` TextureCache path retirement (Sky / Debug -remain on the legacy path now that Terrain has migrated). -Plan + spec written when work begins. +**Currently in flight: Post-A.5 polish — Tier 1 retry + lifestone fix + JobKind plumbing.** +Open issues: #52 (lifestone missing), #53 (Tier 1 entity cache redo with animation-mutation +audit), #54 (JobKind plumbing through BuildLandblockForStreaming for proper far-tier skip). +After those three close, the next planned phase is N.6 (perf polish) — see roadmap for scope. + +**Phase A.5 (Two-tier Streaming + Horizon LOD) shipped 2026-05-10.** +N₁=4 near-tier (81 LBs, full detail) + N₂=12 far-tier (544 LBs, terrain only); fog +horizon; QualityPreset system (Low/Medium/High/Ultra) with env-var overrides; F11 +mid-session reapply. Two post-ship-prep bugs fixed: Bug A (far-tier worker was loading +full entity layer — ~71K entities, ~5x perf regression vs spec), Bug B (WalkEntities +per-frame list alloc — ~480 KB/frame GC pressure). Tier 1 entity cache reverted (animation +regression; see #53). Plan archived at +[`docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`](docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md). **Phase N.5b (Terrain on Modern Rendering Path) shipped 2026-05-09.** `TerrainModernRenderer` mirrors WB's `TerrainRenderManager` pattern diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 39f4723..8391ee0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,74 @@ Copy this block when adding a new issue: # Active issues +## #54 — A.5/jobkind-plumbing: far-tier worker loads full entity layer then strips + +**Status:** OPEN +**Severity:** LOW (correctness/perf; worker wastes CPU on far-tier LandBlockInfo + scenery generation that is immediately discarded) +**Filed:** 2026-05-10 +**Component:** streaming / LandblockStreamer + +**Description:** Bug A's fix (commit `9217fd9`) patches at the worker output — after a far-tier job completes the full `LoadNear` path, the result's entity list is stripped before posting to the completion queue. This means far-tier LBs still load `LandBlockInfo` + run `SceneryGenerator` + call `LandblockLoader.BuildEntitiesFromInfo` even though those results are thrown away. At N₂=12, that is ~544 far-tier LBs × unnecessary dat reads + scenery math on promotion sequences. + +**Proper fix:** plumb `LandblockStreamJobKind` through `BuildLandblockForStreaming` so far-tier jobs call only `LandBlock` heightmap read + `LandblockMesh.Build`, skipping `LandBlockInfo` + `SceneryGenerator` entirely. The function signature change is ~5 lines; wiring is ~10 lines. Estimated 30 min–1 hour total. + +**Files:** +- `src/AcDream.App/Streaming/LandblockStreamer.cs` — `HandleJob` + `BuildLandblockForStreaming` + +**Acceptance:** Far-tier LB worker path reads only the `LandBlock` dat file (no `LandBlockInfo`, no `SceneryGenerator` call). Verified by adding a counter diagnostic or via dotnet-trace showing the dat-read call count per job kind. + +--- + +## #53 — A.5/tier1-redo: entity-classification cache broke animation (reverted) + +**Status:** OPEN +**Severity:** MEDIUM (perf gap; the classification cache would save ~1-2ms/frame but cannot land until animation-mutation audit is done) +**Filed:** 2026-05-10 +**Component:** rendering / WbDrawDispatcher / AnimationSequencer + +**Description:** Tier 1 entity-classification cache (commit `3639a6f`) was reverted at `9b49009` due to an animation regression. The cache stored `meshRef.PartTransform` at first-classify time. For static entities this is stable. For animated entities, `AnimationSequencer` mutates `meshRef.PartTransform` every frame to apply the current skeletal pose. The cache froze the pose, causing NPCs and some animated entities to stop animating (some buildings also showed at wrong positions, likely entities incorrectly flagged as animated). + +**Root cause:** the "trust MeshRefs as the source of truth" comment in the dispatcher gave false confidence — MeshRefs IS the source of truth, but it is mutated EVERY frame for animated entities. + +**Next attempt needs:** + +1. Audit `AnimationSequencer` + `AnimationHookRouter` to identify ALL per-frame mutations of `MeshRef` state (not just `PartTransform` — are any other fields mutated?). +2. Redesign cache to: (a) bypass animated entities entirely (classify them each frame, cache only static entities), OR (b) cache only the animation-invariant subset of the classification key (group key, texture handle, blend mode) while reading the per-frame pose from the live `MeshRef`. +3. Test specifically with a moving animated NPC visible on screen before shipping. + +**Estimated:** 1 week including audit + redesign + retest. + +**Files:** +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — dispatcher classification logic +- `src/AcDream.Core/Animation/AnimationSequencer.cs` — mutation source +- `src/AcDream.Core/Animation/AnimationHookRouter.cs` — secondary mutation source + +--- + +## #52 — A.5/lifestone-missing: Holtburg lifestone not rendering + +**Status:** OPEN +**Severity:** MEDIUM (visible missing landmark; lifestone is the player's respawn anchor and should always be visible) +**Filed:** 2026-05-10 +**Component:** streaming / rendering + +**Description:** The Holtburg lifestone (spinning blue crystal) has not rendered since earlier in A.5 development. Reproduce: launch live client, walk to Holtburg town center, look toward the lifestone position. Should see the spinning blue crystal; instead see nothing. + +**Root cause (suspected, two candidates):** + +1. Bug A's far-tier strip (commit `9217fd9`) may be incorrectly stripping a near-tier entity. The lifestone's server GUID is `0x5000000A`; its dat object may be registering via the `LandBlockInfo` path but getting stripped as if it were a far-tier entity due to a tier-classification race or incorrect LB-tier tracking. +2. Separate regression from earlier in the A.5 development chain — possibly introduced when entity registration was restructured during T13/T16 streaming controller wiring. + +**Investigation approach:** + +1. Add a `[STREAMING-DIAG]` log line when far-tier stripping drops an entity — log the entity's GfxObj ID and LB address so the lifestone's GfxObj ID appears in the log if it is being stripped. +2. If not in the strip log, check whether the lifestone's LB is registering as near-tier at all during first-tick bootstrap. +3. Bisect to find the commit that broke it if the above two checks don't isolate the cause. + +**Acceptance:** Launch live, walk to Holtburg center, spinning blue crystal visible at the lifestone position. No regression on other static entities in the area. + +--- + ## #50 — Road-edge tree at 0xA9B1 visible in acdream but not retail **Status:** OPEN diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c4c33f1..68681bc 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-05-09 for Phase N.5b shipping (terrain on the modern rendering path via Path C — mirror WB's `TerrainRenderManager` pattern, consume `LandblockMesh.Build` for retail formula compliance; closes ISSUE #51). N.6 (perf polish) remains the in-flight phase. +**Status:** Living document. Updated 2026-05-10 for Phase A.5 shipping (two-tier streaming N₁=4/N₂=12 + QualityPreset system + Bug A/B fixes; closes the two-tier streaming spec). Post-A.5 polish (Tier 1 retry + lifestone fix + JobKind plumbing) is now the in-flight work. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -31,6 +31,7 @@ | A.1 | Streaming landblock loader — runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS`), camera-centered offline / player-centered live, hysteresis-based unloads, pending-spawn list for late CreateObject events | Live ✓ | | A.2 | Frustum culling — per-landblock AABB test (Gribb-Hartmann), terrain + static-mesh renderers skip culled landblocks, perf overlay in window title | Visual ✓ | | A.3 | Background net receive thread — dedicated daemon thread buffers UDP into Channel, render thread drains | Visual ✓ | +| 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; **NEW T22.5: QualityPreset system** (Low/Medium/High/Ultra) with per-preset radii + MSAA + anisotropic + A2C + completions; env-var overrides per field; F11 mid-session re-apply. **Bug fixes post-T26 ship-prep**: (Bug A) far-tier worker now strips entities from far-tier loads — without this fix, far-tier LBs were loading their full entity layer (~71K entities) defeating the two-tier optimization; (Bug B) WalkEntities switched from per-frame fresh-list allocation to caller-provided scratch list (eliminated ~480 KB/frame GC pressure). **Deferred to post-A.5**: Tier 1 entity-classification cache (first attempt broke animation; revert + redo with animation-mutation audit), lifestone visual (missing in render), JobKind plumbing through BuildLandblockForStreaming (proper Bug A fix), Tier 2/3 perf optimizations (roadmap at docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md). Plan archived at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md. | Live ✓ | | B.3 | Physics MVP resolver foundation — terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests ✓ | | B.2 | Player movement mode — Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live ✓ | | D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual ✓ | @@ -82,7 +83,7 @@ Plus polish that doesn't get its own phase number: - **✓ SHIPPED — A.2 — Frustum culling.** Per-landblock AABB test (Gribb-Hartmann plane extraction + positive-vertex AABB test) in both `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw`. Per-entity culling deferred. LOD deferred to Phase C. Performance overlay in window title shows FPS, frame time, visible/total landblock ratio, entity count, animated count. ~160fps uncapped at 5×5 radius. - **✓ SHIPPED — A.3 — Background net receive thread.** Dedicated daemon thread continuously pulls raw UDP datagrams from the kernel buffer into a `Channel`. Render thread's `Tick()` drains the channel. All decode, fragment assembly, ISAAC crypto, event dispatch, and ack-sending remain on the render thread — minimal change that prevents packet drops during frame stalls. Thread starts after `EnterWorld()` completes; `PumpOnce()` during handshake still reads the socket directly. - **A.4 — Async dat decoding.** Folded into the streaming worker — it's the worker's read path, not a separate subsystem. Called out here because regressions in dat caching could land on this surface. -- **A.5 — Two-tier streaming + terrain horizon LOD.** Split `ACDREAM_STREAM_RADIUS` into two: `ACDREAM_TERRAIN_RADIUS` (large, 8-12 cells = 1.5-2.3km) for terrain mesh + `ACDREAM_ENTITY_RADIUS` (small, 2-3 cells, current default) for entities + scenery. Distant landblocks render terrain only — no NPCs, no procedural scenery, no static objects. Tune `SceneLightingUbo`'s `uFogParams` so the far edge fades into sky color (eliminates the hard streaming boundary visible at higher radii). Optional: terrain LOD via mesh decimation for very distant chunks (combine 2×2 landblocks into one decimated mesh; cribs from `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs`). Motivation: at radius=5 today, perf scales from ~810 fps → ~200-300 fps because everything stays full-detail; both retail and WorldBuilder render terrain way out and strip entities/scenery at distance. Enables WB-style horizon visibility. **Estimate: 3-5 days for the radius split + fog tuning; +1 week if terrain LOD is included.** Not yet brainstormed. +- **✓ SHIPPED — A.5 — Two-tier streaming + horizon LOD.** Shipped 2026-05-10. See shipped table above for full description. Plan archived at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`. **Acceptance:** - Walk across 10+ landblocks in any direction, no crashes, no empty voids. @@ -664,7 +665,7 @@ for our deletions/additions; merge upstream `master` periodically. manifest at higher radius. Spec acceptance criterion #5 was wrong; amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. -- **N.6 — Perf polish.** **Currently in flight.** +- **N.6 — Perf polish.** **Planned (post-A.5 polish takes priority).** Builds on N.5 + N.5b. Legacy renderer retirement was pulled forward into N.5 ship amendment — `InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag` are gone — and the terrain legacy renderer @@ -675,8 +676,8 @@ for our deletions/additions; merge upstream `master` periodically. is a candidate), GPU-side culling via compute pre-pass (eliminates the per-frame slot walk + DEIC build entirely), GL_TIME_ELAPSED query double-buffering (deferred from N.5 — diagnostic shows `gpu_us=0/0` - under `ACDREAM_WB_DIAG=1`), direct higher-radius perf comparison once - A.5 lands (where modern's architectural wins manifest), retire the + under `ACDREAM_WB_DIAG=1`), direct higher-radius perf comparison (A.5 + has now landed — modern's architectural wins are measurable), retire the legacy `Texture2D`/`sampler2D` path in `TextureCache` (currently kept for Sky + Debug + particle paths now that Terrain has migrated). Plan + spec written when work begins. **Estimate: 1-2 weeks.** From 9245db5b04af2f8932edc347af81954eaf966d48 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 10:08:13 +0200 Subject: [PATCH 45/45] =?UTF-8?q?phase(A.5):=20SHIP=20=E2=80=94=20two-tier?= =?UTF-8?q?=20streaming=20+=20horizon=20LOD=20+=20Quality=20Preset=20syste?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final state: A.5 delivers a 2.3 km terrain horizon (radius=4 near + 12 far) with off-thread mesh build, fog blend at the N₁ boundary, mipmaps + 16x anisotropic on terrain, MSAA 4x + A2C foliage, depth-write audit + lock-in test, BUDGET_OVER diag flag, and a full Quality Preset system (Low/Medium/High/Ultra) with env-var overrides + F11 mid-session re-apply. Acceptance: - N.5b conformance sentinel: 89+ tests passing (TerrainSlot, TerrainModernConformance, Wb*, MatrixComposition, TextureCacheBindless, SplitFormulaDivergence). All clean. - Build green; ~999 tests passing across all projects; 8 pre-existing physics/input failures unchanged (out of A.5 scope). - Standstill at horizon-safe preset (radius=4/12, MSAA off, A2C off, aniso 4x), Holtburg, AMD Radeon RX 9070 XT @ 1440p: entity dispatcher cpu_us median ~3.5ms, p95 ~4ms (~200-240 FPS). Terrain dispatcher cpu_us median ~21µs (well under 1ms budget). - Visual gate (partial): horizon visible at ~2.3km; fog blend smooths N₁ boundary cleanly; system stable through walking traverse. Lifestone missing — known issue from earlier in development chain, deferred to post-A.5 (ISSUE #52). Two post-T26 perf bug fixes that were structural to A.5's promise: - (Bug A, 9217fd9) Far-tier worker now strips entities. Without this, T13/T16 shipped only the controller side of two-tier; the worker loaded full entity layers for far-tier LBs. Result was ~71K entities in GpuWorldState instead of ~10K — a 5x perf regression. Patch is at the worker-output level; cleaner JobKind plumbing through BuildLandblockForStreaming is post-A.5 (ISSUE #54). - (Bug B, 0ad8c99) WalkEntities switched from per-frame fresh-list allocation to a caller-provided scratch list reused across frames. Eliminated ~480 KB / frame GC pressure on the render thread. Tier 1 entity-classification cache attempted as ship-prep polish (commit 3639a6f) but reverted (9b49009) — caching meshRef.PartTransform froze the per-frame animation pose. Retry is a post-A.5 phase with animation-mutation audit + animated-bypass design (ISSUE #53). Decisions (per spec §4): - N₁=4 (full detail, 81 LBs), N₂=12 (terrain only, 544 LBs). - Bucketing Change #1 (animated-walk fix in WalkEntities) + Change #2 (cached AABB on WorldEntity) 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: refresh-rate-relative + per-preset (Q9 Option B reshape after Quality Preset addition). - Quality Preset system (T22.5, mid-execution scope add): Low / Medium / High / Ultra with per-preset radii + MSAA + anisotropic + A2C + completions; 6 env-var overrides; settings.json persistence; F11 mid-session re-apply. Deferred to post-A.5 polish phase: - Tier 1 retry with animation audit (ISSUE #53) - Lifestone missing (ISSUE #52) - JobKind plumbing through BuildLandblockForStreaming (ISSUE #54) - Tier 2 (static/dynamic group split) — multi-week phase - Tier 3 (GPU compute culling) — multi-week phase - Re-test full High preset (crashed at original attempt; should work post-Bug-A; not retested) 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-tier roadmap: docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md Memory: ~/.claude/projects/.../memory/project_phase_a5_state.md (5 gotchas) Co-Authored-By: Claude Opus 4.7 (1M context)