Week 3 ships: AnimatedEntityState (Tasks 16+18+19, commitce72c57), EntitySpawnAdapter routing server-spawned content through the existing TextureCache.GetOrUploadWithPaletteOverride path (Task 17, commitc02c307). 947 tests pass. Adjustment 4: WorldEntity lacks HiddenPartsMask + AnimPartChanges fields. Adapter scaffolding ships; AnimatedEntityState gets default values (empty mask + empty override map). Plumbing deferred to Task 22 brainstorm — either add fields to WorldEntity or thread through a separate parameter to EntitySpawnAdapter.OnCreate. Adjustment 5: Task 20 (per-instance decode conformance) is structural. Both old and new paths call the same TextureCache function — bytes identical by construction. EntitySpawnAdapterTests already cover the routing. No separate conformance test file needed. Next: Task 22 (Week 4) — WbDrawDispatcher full draw loop. First task that actually draws through WB and unlocks Adjustment 3's mitigation (dual-pipeline cost resolves when legacy renderer can short-circuit its upload for atlas-tier content). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2659 lines
100 KiB
Markdown
2659 lines
100 KiB
Markdown
# Phase N.4 — Rendering Pipeline Foundation 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:** Adopt WB's `ObjectMeshManager` + `TextureAtlasManager` as acdream's rendering pipeline foundation. Two-tier split (atlas for shared procedural content; per-instance path for customized server-spawned entities). Animation by per-draw matrix composition with `AnimationSequencer` untouched. Streaming integration via ~200 LOC adapter shim. Surface metadata preserved via side-table (no fork patches). Single shippable phase, 3-4 weeks. Ships no visible change.
|
||
|
||
**Architecture:** Strangler-fig substitution behind `ACDREAM_USE_WB_FOUNDATION=1` feature flag. Conformance tests run before substitution per N.1/N.3 pattern. Week-by-week sequencing minimizes "broken in middle" state: week 1 brings up plumbing + atlas for static scenery; week 2 wires streaming; week 3 adds per-instance path + animation; week 4 polishes + ships. Foundation enables N.5/N.6/N.7/N.8 to be smaller integration phases on top.
|
||
|
||
**Tech Stack:** .NET 10 / C# 13 · Silk.NET.OpenGL (transitively via WB) · Chorizite.OpenGLSDLBackend (already referenced) · BCnEncoder.Net (transitively) · xUnit · `Chorizite.Core.Render` interfaces.
|
||
|
||
**Spec:** [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../specs/2026-05-08-phase-n4-rendering-foundation-design.md) — read FIRST. Everything below assumes you've read it.
|
||
**Parent design:** [docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md](../specs/2026-05-08-phase-n-worldbuilder-migration-design.md)
|
||
**Inventory:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
|
||
**Roadmap:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) — N.4 entry
|
||
|
||
**Prerequisites:**
|
||
- Phase N.0 shipped (commit `c8782c9`) — WB submodule + project references wired up.
|
||
- Phase N.1 shipped (merge `1978ef9`) — scenery via WB helpers.
|
||
- Phase N.3 shipped (merge `13132f9`) — texture decode via WB `TextureHelpers`.
|
||
- Build green, 883 tests passing, 8 pre-existing failures only.
|
||
- Worktree: `.claude/worktrees/quirky-jepsen-fd60f1` on branch `claude/quirky-jepsen-fd60f1`.
|
||
|
||
---
|
||
|
||
## File Plan
|
||
|
||
| File | Disposition | Responsibility |
|
||
|---|---|---|
|
||
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | NEW | Owns the `ObjectMeshManager` instance. Exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` / `Dispose`. The single seam between acdream and WB's render pipeline. |
|
||
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | NEW | Record holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog` (the AC-specific surface properties WB's `MeshBatchData` doesn't carry). |
|
||
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | NEW | `Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>` side-table. Populated at mesh-extraction time; queried at draw time. Thread-safe (`ConcurrentDictionary`). |
|
||
| `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | NEW | Streaming-loader hook. Walks `LandblockEntry.Setups[]` / `Statics[]` and calls `WbMeshAdapter.IncrementRefCount` per unique GfxObj. Companion unload path. |
|
||
| `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | NEW | Network-spawn hook. Routes `CreateObject` to per-instance path via existing `TextureCache.GetOrUploadWithPaletteOverride`. Builds per-entity `AnimatedEntityState`. |
|
||
| `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | NEW | Per-entity render state for animated entities: `partGfxObjOverrides` (AnimPartChange), `hiddenMask` (HiddenParts), reference to existing `AnimationSequencer`. |
|
||
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | NEW | Per-frame draw loop. Walks visible entities, looks up `ObjectRenderData`, composes per-part matrices (entity × animation × rest-pose), reads side-table, issues GL draws. |
|
||
| `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs` | NEW | Static flag gate: `WbFoundationFlag.IsEnabled` reads `ACDREAM_USE_WB_FOUNDATION` env var once at process start, exposes a single `bool`. Other call sites import this rather than re-reading the env var. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs` | NEW | Conformance: our `GfxObjMesh.Build` vs WB's algorithm output. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs` | NEW | Conformance: our `SetupMesh.Flatten` vs WB's setup-parts walk. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs` | NEW | Conformance: per-instance customization decode produces identical RGBA8 vs `TextureCache.GetOrUploadWithPaletteOverride`. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs` | NEW | Round-trip + thread-safety smoke. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs` | NEW | Register/unregister + dedup. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs` | NEW | Routes CreateObject with palette override to per-instance path. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs` | NEW | Entity × animation × rest-pose matrix composition. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs` | NEW | Bitmask suppression. |
|
||
| `tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs` | NEW | Override resolution. |
|
||
| `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | MODIFY | Internal swap: `EnsureUploaded` and `Draw` route through `WbMeshAdapter` when `WbFoundationFlag.IsEnabled`. Public surface unchanged. **N.6 fully replaces this file.** |
|
||
| `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | MODIFY | Same pattern — internal swap, public surface unchanged. **N.6 fully replaces this file.** |
|
||
| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | `GetOrUpload(surfaceId)` (atlas-tier callers) routes through `WbMeshAdapter` when flag on. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep current behavior. |
|
||
| `src/AcDream.App/Streaming/GpuWorldState.cs` | MODIFY | `AddLandblock` / `RemoveLandblock` call `LandblockSpawnAdapter` when flag on. `AppendLiveEntity` calls `EntitySpawnAdapter` when flag on. Pending-spawn list mechanism preserved verbatim. |
|
||
| `src/AcDream.App/Rendering/GameWindow.cs` | MODIFY | Construct `WbMeshAdapter` + `AcSurfaceMetadataTable` + `WbDrawDispatcher` on init. Dispose on shutdown. |
|
||
| `docs/plans/2026-04-11-roadmap.md` | MODIFY (final task) | Mark N.4 shipped after visual verification. |
|
||
| `CLAUDE.md` | MODIFY (early task) | Add pointer to this plan in the "Roadmap discipline" section so future agents pick it up. |
|
||
|
||
**Why this structure:** the `Wb/` subfolder isolates everything new in N.4 from existing renderers. After N.6 fully replaces `StaticMeshRenderer` / `InstancedMeshRenderer`, the `Wb/` folder becomes the canonical rendering implementation. Each new file has one responsibility. Existing files are touched minimally; the bulk of N.4 lives in the new folder.
|
||
|
||
---
|
||
|
||
## Plan Living-Document Convention
|
||
|
||
This plan is the **execution source of truth** for N.4. It is updated as tasks land:
|
||
|
||
- After each commit that completes a task, mark the task's checkboxes ✅ and append the commit SHA next to the task header.
|
||
- If a task uncovers an architectural surprise that requires re-planning, add a **`### Adjustment N`** subsection under the affected task with the date, what changed, and why. Do not silently rewrite earlier tasks.
|
||
- If a downstream task changes shape because of an earlier task's outcome, append the changes to the downstream task in-place rather than scattering deltas.
|
||
- Final commit for the phase updates this header note from "Living document — work in progress" to "Final state at <date> — phase shipped (merge `<sha>`)."
|
||
|
||
Status: **Living document — work in progress, started 2026-05-08.**
|
||
|
||
**Progress (2026-05-08):** Weeks 1 + 2 + 3 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural — same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only.
|
||
|
||
**Next: Task 22** (Week 4) — `WbDrawDispatcher` full draw loop. The first task that actually draws through WB and unlocks the dual-pipeline-cost mitigation from Adjustment 3.
|
||
|
||
| Task | Status | Commit |
|
||
|---|---|---|
|
||
| 1 — WbFoundationFlag scaffold | ✅ | `81b5ed8` |
|
||
| 2 — AcSurfaceMetadata + Table | ✅ | `46deed6` |
|
||
| 3 — Mesh-extraction conformance | ✅ | `ed73fc5` |
|
||
| 4 — Setup-flatten conformance | ✅ | `ed73fc5` |
|
||
| 5 — WbMeshAdapter stub + IWbMeshAdapter | ✅ | (post-`ed73fc5`) |
|
||
| 6 — WbDatReaderAdapter | ✅ OBSOLETED (Adj. 1) | `502c3a8` |
|
||
| 7 — GameWindow wiring under flag | ✅ | `502c3a8` |
|
||
| 8 — CLAUDE.md pointer | ✅ | `506b86b` (preemptive) |
|
||
| 9 — Real WB pipeline + InstancedMeshRenderer routing | ✅ partial / Adj. 2 reverted | `4ad7a98` + `4f318bc` |
|
||
| 10 — Week 1 wrap-up | ✅ | `c49c6ed` |
|
||
| 11 — LandblockSpawnAdapter | ✅ | `669768d` |
|
||
| 12 — Wire into GpuWorldState | ✅ | `931a690` |
|
||
| 13 — Memory budget verification | ✅ deferred to Task 22 (Adj. 3) | — |
|
||
| 14 — Pending-spawn integration test | ✅ | `f4f0101` |
|
||
| Tick — drain WB pipeline queues | ✅ added per Adj. 3 | `bf53cb4` |
|
||
| 15 — Week 2 wrap-up | ✅ | `36f7a60` |
|
||
| 16+18+19 — AnimatedEntityState + AnimPartChange + HiddenParts | ✅ | `ce72c57` |
|
||
| 17 — EntitySpawnAdapter | ✅ + Adj. 4 | `c02c307` |
|
||
| 20 — Per-instance decode conformance | ✅ structural (Adj. 5) | (no test file) |
|
||
| 21 — Week 3 wrap-up | ✅ | (this commit) |
|
||
| 22 — WbDrawDispatcher full draw loop | pending | — |
|
||
| 23 — Surface metadata side-table population | pending | — |
|
||
| 24 — Sky-pass preservation check | pending | — |
|
||
| 25 — Component micro-tests round-out | pending | — |
|
||
| 26 — Visual verification + flag default-on | pending | — |
|
||
| 27 — Delete legacy code paths | pending | — |
|
||
| 28 — Update memory + ISSUES + finalize plan | pending | — |
|
||
|
||
---
|
||
|
||
## Week 1 — Plumbing + Atlas for Static Scenery + Conformance
|
||
|
||
Goal of week 1: WB infrastructure wired up behind feature flag. Conformance tests pass. Static scenery routes through `ObjectMeshManager` when flag is on. Everything else still uses old path. **Done when: build green, all conformance tests pass, flag-on Holtburg roam visually identical to flag-off.**
|
||
|
||
### Task 1: Wb folder skeleton + `WbFoundationFlag`
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs`
|
||
|
||
- [ ] **Step 1.1: Create the Wb folder by creating the flag file**
|
||
|
||
```csharp
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Process-lifetime cache of <c>ACDREAM_USE_WB_FOUNDATION</c> env var.
|
||
/// Read once at static-init time; all consumers import this rather than
|
||
/// re-reading the env var per call (env-var lookups on Windows are not
|
||
/// free at hot-path cadence).
|
||
///
|
||
/// <para>
|
||
/// Set <c>ACDREAM_USE_WB_FOUNDATION=1</c> to route static-scenery + atlas
|
||
/// content through WB's <c>ObjectMeshManager</c>; per-instance customized
|
||
/// content (server <c>CreateObject</c> entities) takes the existing
|
||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> path either
|
||
/// way. Flag becomes default-on at end of Phase N.4 after visual
|
||
/// verification.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class WbFoundationFlag
|
||
{
|
||
public static bool IsEnabled { get; } =
|
||
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.2: Build to verify the new folder compiles**
|
||
|
||
Run: `dotnet build --verbosity quiet`
|
||
Expected: 0 errors. The folder exists implicitly because the file's namespace declares it.
|
||
|
||
- [ ] **Step 1.3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): WbFoundationFlag scaffold for ACDREAM_USE_WB_FOUNDATION env var
|
||
|
||
Creates the src/AcDream.App/Rendering/Wb/ folder and the static flag
|
||
gate that other call sites will import. Read once at static-init time.
|
||
Set ACDREAM_USE_WB_FOUNDATION=1 to enable WB foundation routing.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `AcSurfaceMetadata` + `AcSurfaceMetadataTable`
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs`
|
||
- Create: `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs`
|
||
|
||
- [ ] **Step 2.1: Write failing test**
|
||
|
||
Create `tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs`:
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Meshing;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class AcSurfaceMetadataTableTests
|
||
{
|
||
[Fact]
|
||
public void Add_ThenLookup_RoundTripsSameMetadata()
|
||
{
|
||
var table = new AcSurfaceMetadataTable();
|
||
var meta = new AcSurfaceMetadata(
|
||
Translucency: TranslucencyKind.AlphaBlend,
|
||
Luminosity: 0.5f,
|
||
Diffuse: 0.8f,
|
||
SurfOpacity: 0.7f,
|
||
NeedsUvRepeat: true,
|
||
DisableFog: false);
|
||
|
||
table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);
|
||
|
||
Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
|
||
Assert.Equal(meta, got);
|
||
}
|
||
|
||
[Fact]
|
||
public void Lookup_MissingKey_ReturnsFalse()
|
||
{
|
||
var table = new AcSurfaceMetadataTable();
|
||
Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
|
||
}
|
||
|
||
[Fact]
|
||
public void Add_OverwritesPreviousMetadata()
|
||
{
|
||
var table = new AcSurfaceMetadataTable();
|
||
var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
|
||
var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);
|
||
|
||
table.Add(0xAAAA, 0, first);
|
||
table.Add(0xAAAA, 0, second);
|
||
|
||
Assert.True(table.TryLookup(0xAAAA, 0, out var got));
|
||
Assert.Equal(second, got);
|
||
}
|
||
|
||
[Fact]
|
||
public void Add_FromMultipleThreads_IsThreadSafe()
|
||
{
|
||
var table = new AcSurfaceMetadataTable();
|
||
var threads = new System.Threading.Tasks.Task[8];
|
||
for (int t = 0; t < 8; t++)
|
||
{
|
||
int threadIdx = t;
|
||
threads[t] = System.Threading.Tasks.Task.Run(() =>
|
||
{
|
||
for (int i = 0; i < 1000; i++)
|
||
{
|
||
ulong key = (ulong)(threadIdx * 1000 + i);
|
||
table.Add(key, 0, new AcSurfaceMetadata(
|
||
TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
|
||
}
|
||
});
|
||
}
|
||
System.Threading.Tasks.Task.WaitAll(threads);
|
||
|
||
// 8000 entries should be present.
|
||
for (int t = 0; t < 8; t++)
|
||
for (int i = 0; i < 1000; i++)
|
||
Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AcSurfaceMetadataTableTests" --verbosity normal`
|
||
Expected: COMPILE FAIL — types don't exist.
|
||
|
||
- [ ] **Step 2.3: Create `AcSurfaceMetadata.cs`**
|
||
|
||
```csharp
|
||
using AcDream.Core.Meshing;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// AC-specific surface render metadata that WB's <c>MeshBatchData</c>
|
||
/// doesn't carry. Computed at mesh-extraction time and looked up by the
|
||
/// draw dispatcher to drive translucency / sky-pass / fog behavior.
|
||
///
|
||
/// <para>
|
||
/// All fields mirror those on today's <see cref="GfxObjSubMesh"/> so
|
||
/// behavior is preserved bit-for-bit through the migration.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed record AcSurfaceMetadata(
|
||
TranslucencyKind Translucency,
|
||
float Luminosity,
|
||
float Diffuse,
|
||
float SurfOpacity,
|
||
bool NeedsUvRepeat,
|
||
bool DisableFog);
|
||
```
|
||
|
||
- [ ] **Step 2.4: Create `AcSurfaceMetadataTable.cs`**
|
||
|
||
```csharp
|
||
using System.Collections.Concurrent;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Thread-safe side-table mapping <c>(gfxObjId, surfaceIdx)</c> to
|
||
/// <see cref="AcSurfaceMetadata"/>. Populated when a GfxObj's mesh data
|
||
/// is extracted; queried at draw time.
|
||
///
|
||
/// <para>
|
||
/// Keyed by <c>(gfxObjId, surfaceIdx)</c> not by WB's runtime batch
|
||
/// identity because batch objects can be evicted and re-loaded by WB's
|
||
/// LRU; the (gfxObj, surface) pair is stable across cycles.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class AcSurfaceMetadataTable
|
||
{
|
||
private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
|
||
|
||
public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
|
||
=> _table[(gfxObjId, surfaceIdx)] = meta;
|
||
|
||
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
|
||
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
|
||
|
||
public void Clear() => _table.Clear();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.5: Run tests to verify pass**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AcSurfaceMetadataTableTests" --verbosity normal`
|
||
Expected: 4/4 PASS.
|
||
|
||
- [ ] **Step 2.6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): AcSurfaceMetadata side-table for WB-pristine surface props
|
||
|
||
Holds Translucency / Luminosity / Diffuse / SurfOpacity / NeedsUvRepeat /
|
||
DisableFog keyed by (gfxObjId, surfaceIdx). Populated at extraction time,
|
||
queried by the draw dispatcher. ConcurrentDictionary because mesh
|
||
extraction happens on background workers.
|
||
|
||
No fork patches required — keeps WB's MeshBatchData pristine.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Mesh-extraction conformance test
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs`
|
||
|
||
This test proves our existing `GfxObjMesh.Build` produces the same vertex + index output as WB's algorithm. Per [GfxObjMesh.cs:24](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs:24), our code is already a faithful port of WB's `BuildPolygonIndices` — this test pins that fact.
|
||
|
||
- [ ] **Step 3.1: Write the test**
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.Core.Meshing;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Conformance: our <see cref="GfxObjMesh.Build"/> must produce the same
|
||
/// vertex-array + index-array output as WB's <c>ObjectMeshManager</c>
|
||
/// would for the same input GfxObj. We don't invoke WB's full pipeline
|
||
/// (it requires a GL context); instead we re-implement the WB algorithm
|
||
/// inline against the same source code we ported from, then compare.
|
||
///
|
||
/// <para>
|
||
/// If this test fails, either our port has drifted or the WB code has
|
||
/// changed upstream — investigate which, do not "fix" the test.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class MeshExtractionConformanceTests
|
||
{
|
||
[Fact]
|
||
public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
|
||
{
|
||
var gfxObj = MakeUnitQuadGfxObj();
|
||
|
||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||
|
||
Assert.Single(ours);
|
||
var sub = ours[0];
|
||
// Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
|
||
Assert.Equal(4, sub.Vertices.Length);
|
||
Assert.Equal(6, sub.Indices.Length);
|
||
// Fan from vertex 0: (0,1,2) and (0,2,3).
|
||
Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
|
||
{
|
||
var gfxObj = MakeUnitQuadGfxObj();
|
||
// Force the polygon to be double-sided via Stippling.Both.
|
||
var poly = gfxObj.Polygons[0];
|
||
poly.Stippling = StipplingType.Both;
|
||
poly.NegSurface = 0; // same surface idx for both sides
|
||
|
||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||
|
||
// One sub-mesh per (surfaceIdx, isNeg) bucket.
|
||
Assert.Equal(2, ours.Count);
|
||
// Negative-side bucket has reversed winding.
|
||
var neg = ours.First(s => s.Indices.SequenceEqual(new uint[] { 2, 1, 0, 3, 2, 0 }));
|
||
Assert.NotNull(neg);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
|
||
{
|
||
var gfxObj = MakeUnitQuadGfxObj();
|
||
var poly = gfxObj.Polygons[0];
|
||
poly.Stippling = StipplingType.None; // no explicit Negative flag
|
||
poly.SidesType = CullMode.Clockwise; // AC's "double-sided via SidesType" convention
|
||
poly.NegSurface = 0;
|
||
|
||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||
|
||
Assert.Equal(2, ours.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_NoPosFlag_OnlyEmitsNegSide()
|
||
{
|
||
var gfxObj = MakeUnitQuadGfxObj();
|
||
var poly = gfxObj.Polygons[0];
|
||
poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
|
||
poly.NegSurface = 0;
|
||
|
||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||
|
||
Assert.Single(ours);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_NegUVIndices_AppliedToNegSideVertices()
|
||
{
|
||
var gfxObj = MakeUnitQuadGfxObj();
|
||
var poly = gfxObj.Polygons[0];
|
||
poly.Stippling = StipplingType.Both;
|
||
poly.NegSurface = 0;
|
||
// Default UV index 0 maps to UV (0,0); NegUVIndices=[2,2,2,2] should
|
||
// map to UV (1,1) on the neg side.
|
||
poly.NegUVIndices = [2, 2, 2, 2];
|
||
|
||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||
|
||
var posSide = ours.First(s => s.Vertices[0].TexCoord == new Vector2(0, 0));
|
||
var negSide = ours.First(s => s.Vertices[0].TexCoord == new Vector2(1, 1));
|
||
Assert.NotNull(posSide);
|
||
Assert.NotNull(negSide);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a synthetic 1×1 quad GfxObj with UV indices [0,1,2,3] mapping
|
||
/// to UVs [(0,0), (1,0), (1,1), (0,1)]. Default surface index 0,
|
||
/// PosSurface=0, NegSurface=0. No Stippling flags (caller may set).
|
||
/// </summary>
|
||
private static GfxObj MakeUnitQuadGfxObj()
|
||
{
|
||
var gfx = new GfxObj { Surfaces = [0u] };
|
||
|
||
// Vertices: 4 corners with UV at each corner.
|
||
gfx.VertexArray = new VertexArray();
|
||
for (ushort i = 0; i < 4; i++)
|
||
{
|
||
var sw = new SwVertex
|
||
{
|
||
Origin = new Vector3(i % 2, i / 2, 0),
|
||
Normal = new Vector3(0, 0, 1),
|
||
UVs = new System.Collections.Generic.List<UV>
|
||
{
|
||
new UV { U = i % 2, V = i / 2 },
|
||
new UV { U = 0.5f, V = 0.5f },
|
||
new UV { U = 1, V = 1 },
|
||
},
|
||
};
|
||
gfx.VertexArray.Vertices[i] = sw;
|
||
}
|
||
|
||
// One quad polygon with vertex sequence [0,1,2,3] and PosUVIndices [0,0,0,0].
|
||
var poly = new Polygon
|
||
{
|
||
VertexIds = [0, 1, 2, 3],
|
||
PosUVIndices = [0, 0, 0, 0],
|
||
NegUVIndices = [],
|
||
PosSurface = 0,
|
||
NegSurface = -1,
|
||
Stippling = StipplingType.None,
|
||
SidesType = CullMode.Counterclockwise, // single-sided by default
|
||
};
|
||
gfx.Polygons[0] = poly;
|
||
return gfx;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.2: Run test to verify pass (since algorithm already ported)**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~MeshExtractionConformanceTests" --verbosity normal`
|
||
Expected: 5/5 PASS. If any test fails, investigate before continuing — it means our port has drifted.
|
||
|
||
- [ ] **Step 3.3: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
test(N.4): mesh-extraction conformance pinning GfxObjMesh.Build behavior
|
||
|
||
Five tests covering: simple quad, double-sided via Stippling.Both,
|
||
double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
|
||
NoPos-only emission, and NegUVIndices application to neg-side vertices.
|
||
|
||
These pin GfxObjMesh.Build's output as the conformance baseline before
|
||
N.4 substitutes it with WB's pipeline.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Setup-flatten conformance test
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs`
|
||
|
||
- [ ] **Step 4.1: Write the test**
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.Core.Meshing;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Conformance: our <see cref="SetupMesh.Flatten"/> must produce the same
|
||
/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
|
||
/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride →
|
||
/// Resting → Default → first available) before substitution.
|
||
/// </summary>
|
||
public sealed class SetupFlattenConformanceTests
|
||
{
|
||
[Fact]
|
||
public void Flatten_NoFrames_FallsBackToIdentity()
|
||
{
|
||
var setup = new Setup { Parts = [0x01000001ul] };
|
||
|
||
var refs = SetupMesh.Flatten(setup);
|
||
|
||
Assert.Single(refs);
|
||
Assert.Equal(0x01000001ul, refs[0].GfxObjId);
|
||
// No frame → identity transform, identity scale.
|
||
Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
|
||
}
|
||
|
||
[Fact]
|
||
public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation()
|
||
{
|
||
var setup = new Setup { Parts = [0x01000001ul] };
|
||
var anim = new AnimationFrame
|
||
{
|
||
Frames =
|
||
[
|
||
new Frame
|
||
{
|
||
Origin = new Vector3(10, 20, 30),
|
||
Orientation = Quaternion.CreateFromYawPitchRoll(0, 0, 0),
|
||
},
|
||
],
|
||
};
|
||
setup.PlacementFrames[Placement.Default] = anim;
|
||
|
||
var refs = SetupMesh.Flatten(setup);
|
||
|
||
// Translation column should encode Origin.
|
||
Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation);
|
||
}
|
||
|
||
[Fact]
|
||
public void Flatten_WithRestingFrame_PrefersRestingOverDefault()
|
||
{
|
||
var setup = new Setup { Parts = [0x01000001ul] };
|
||
setup.PlacementFrames[Placement.Default] = new AnimationFrame
|
||
{
|
||
Frames = [new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity }],
|
||
};
|
||
setup.PlacementFrames[Placement.Resting] = new AnimationFrame
|
||
{
|
||
Frames = [new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity }],
|
||
};
|
||
|
||
var refs = SetupMesh.Flatten(setup);
|
||
|
||
// Resting wins.
|
||
Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation);
|
||
}
|
||
|
||
[Fact]
|
||
public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting()
|
||
{
|
||
var setup = new Setup { Parts = [0x01000001ul] };
|
||
setup.PlacementFrames[Placement.Resting] = new AnimationFrame
|
||
{
|
||
Frames = [new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity }],
|
||
};
|
||
var motionOverride = new AnimationFrame
|
||
{
|
||
Frames = [new Frame { Origin = new Vector3(7, 7, 7), Orientation = Quaternion.Identity }],
|
||
};
|
||
|
||
var refs = SetupMesh.Flatten(setup, motionFrameOverride: motionOverride);
|
||
|
||
Assert.Equal(new Vector3(7, 7, 7), refs[0].PartTransform.Translation);
|
||
}
|
||
|
||
[Fact]
|
||
public void Flatten_DefaultScalePerPart_AppliedToTransform()
|
||
{
|
||
var setup = new Setup
|
||
{
|
||
Parts = [0x01000001ul, 0x01000002ul],
|
||
DefaultScale = [new Vector3(2, 2, 2), new Vector3(3, 3, 3)],
|
||
};
|
||
|
||
var refs = SetupMesh.Flatten(setup);
|
||
|
||
// Identity-frame * scale-2 → diagonal matrix with 2s.
|
||
Assert.Equal(2f, refs[0].PartTransform.M11);
|
||
Assert.Equal(3f, refs[1].PartTransform.M11);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.2: Run test, verify pass**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~SetupFlattenConformanceTests" --verbosity normal`
|
||
Expected: 5/5 PASS.
|
||
|
||
- [ ] **Step 4.3: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
test(N.4): setup-flatten conformance pinning placement-frame fallback chain
|
||
|
||
Five tests covering: identity (no frames), Default frame, Resting beats
|
||
Default, motion override beats Resting, DefaultScale per part. Pins
|
||
SetupMesh.Flatten's behavior as the conformance baseline before N.4
|
||
routes it through WB's setup-parts walk.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `WbMeshAdapter` skeleton
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs`
|
||
|
||
- [ ] **Step 5.1: Write failing test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using DatReaderWriter;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class WbMeshAdapterTests
|
||
{
|
||
[Fact]
|
||
public void Construct_WithNullGl_ThrowsArgumentNull()
|
||
{
|
||
Assert.Throws<System.ArgumentNullException>(() =>
|
||
new WbMeshAdapter(gl: null!, dats: NullDats(), logger: NullLogger<WbMeshAdapter>.Instance));
|
||
}
|
||
|
||
[Fact]
|
||
public void Dispose_DisposesUnderlyingMeshManager_NoThrow()
|
||
{
|
||
// Without a real GL context we can't fully construct ObjectMeshManager,
|
||
// but we can verify the adapter's Dispose path is safe to invoke when
|
||
// the manager is null (early-init failure path).
|
||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||
adapter.Dispose(); // should not throw
|
||
}
|
||
|
||
private static DatCollection NullDats() => DatCollection.Empty;
|
||
}
|
||
```
|
||
|
||
(Note: the tests above are minimal. Full coverage of the adapter happens
|
||
once it has real methods to exercise — Tasks 7-8.)
|
||
|
||
- [ ] **Step 5.2: Run, expect compile fail**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbMeshAdapterTests" --verbosity normal`
|
||
Expected: COMPILE FAIL — type doesn't exist, `DatCollection.Empty` may not exist.
|
||
|
||
- [ ] **Step 5.3: Create the adapter**
|
||
|
||
Create `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`:
|
||
|
||
```csharp
|
||
using Chorizite.OpenGLSDLBackend;
|
||
using Chorizite.OpenGLSDLBackend.Lib;
|
||
using DatReaderWriter;
|
||
using Microsoft.Extensions.Logging;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Single seam between acdream and WB's render pipeline. Owns the
|
||
/// <see cref="ObjectMeshManager"/> instance and exposes a stable acdream-
|
||
/// shaped API (<see cref="IncrementRefCount"/> / <see cref="DecrementRefCount"/> /
|
||
/// <see cref="GetRenderData"/>) so the rest of the renderer doesn't need to
|
||
/// know about WB's types directly.
|
||
///
|
||
/// <para>
|
||
/// Instantiated once at <c>GameWindow</c> init when
|
||
/// <see cref="WbFoundationFlag.IsEnabled"/> is true. When the flag is off,
|
||
/// no instance is constructed and call sites fall through to the legacy
|
||
/// renderer paths.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class WbMeshAdapter : System.IDisposable
|
||
{
|
||
private readonly ObjectMeshManager? _meshManager;
|
||
private readonly OpenGLGraphicsDevice? _graphicsDevice;
|
||
private bool _disposed;
|
||
|
||
public WbMeshAdapter(GL gl, DatCollection dats, ILogger<WbMeshAdapter> logger)
|
||
{
|
||
System.ArgumentNullException.ThrowIfNull(gl);
|
||
System.ArgumentNullException.ThrowIfNull(dats);
|
||
System.ArgumentNullException.ThrowIfNull(logger);
|
||
|
||
// OpenGLGraphicsDevice is the host WB's ObjectMeshManager needs.
|
||
// Constructed once and owned by the adapter for the process lifetime.
|
||
_graphicsDevice = new OpenGLGraphicsDevice(gl);
|
||
|
||
// ObjectMeshManager wants its own ILogger<ObjectMeshManager>;
|
||
// we use NullLogger to avoid the wrong category, the adapter's
|
||
// own logger handles the acdream-side trail.
|
||
var omLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<ObjectMeshManager>.Instance;
|
||
var datsAdapter = new WbDatReaderAdapter(dats); // see Task 6
|
||
_meshManager = new ObjectMeshManager(_graphicsDevice, datsAdapter, omLogger);
|
||
}
|
||
|
||
private WbMeshAdapter()
|
||
{
|
||
// Uninitialized constructor — only for tests that need a Dispose-safe
|
||
// instance without a real GL context.
|
||
_meshManager = null;
|
||
_graphicsDevice = null;
|
||
}
|
||
|
||
internal static WbMeshAdapter CreateUninitialized() => new();
|
||
|
||
/// <summary>
|
||
/// Get GPU-side render data for an object id, blocking on background
|
||
/// preparation if the upload hasn't finished yet. Returns null if the
|
||
/// object can't be loaded (e.g., dat missing).
|
||
/// </summary>
|
||
public Chorizite.OpenGLSDLBackend.Lib.ObjectRenderData? GetRenderData(ulong id)
|
||
=> _meshManager?.GetRenderData(id);
|
||
|
||
public void IncrementRefCount(ulong id) => _meshManager?.IncrementRefCount(id);
|
||
|
||
public void DecrementRefCount(ulong id) => _meshManager?.DecrementRefCount(id);
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
_meshManager?.Dispose();
|
||
_graphicsDevice?.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.4: Build to expose compilation issues**
|
||
|
||
Run: `dotnet build --verbosity quiet`
|
||
Expected: Compile errors will reveal what we need:
|
||
- `WbDatReaderAdapter` does not exist yet (Task 6 creates it)
|
||
- `OpenGLGraphicsDevice` constructor signature may differ
|
||
|
||
For now, comment out the `_graphicsDevice = new OpenGLGraphicsDevice(gl);` and `_meshManager = new ObjectMeshManager(...)` lines and provide a TODO comment marker so the test file compiles. Replace with:
|
||
|
||
```csharp
|
||
// Real init defers to Task 6 (dat reader adapter) + Task 9 (full bring-up).
|
||
// During Task 5 the adapter is a stub that returns null/no-ops everywhere.
|
||
_graphicsDevice = null;
|
||
_meshManager = null;
|
||
```
|
||
|
||
- [ ] **Step 5.5: Add `DatCollection.Empty` if it doesn't exist**
|
||
|
||
If `DatCollection.Empty` is missing, the test won't compile. Check:
|
||
|
||
Run: `grep -rn "DatCollection.Empty" src/`
|
||
|
||
If absent, the test should be adjusted to construct an empty `DatCollection` directly (or skipped — the meaningful adapter tests come later). Update the test as needed; the test's only job at this stage is to verify Dispose is safe.
|
||
|
||
- [ ] **Step 5.6: Run tests**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbMeshAdapterTests" --verbosity normal`
|
||
Expected: 2/2 PASS (with the stub init).
|
||
|
||
- [ ] **Step 5.7: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): WbMeshAdapter skeleton — single seam to WB ObjectMeshManager
|
||
|
||
Stub init pending Task 6 (dat reader adapter) + Task 9 (full bring-up).
|
||
Public API: IncrementRefCount / DecrementRefCount / GetRenderData /
|
||
Dispose. Construction-safe (validates args), Dispose-safe (no-op when
|
||
underlying manager is null).
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: ~~WbDatReaderAdapter~~ — OBSOLETED 2026-05-08
|
||
|
||
**Adjustment 1 (2026-05-08):** discovered during pre-Task-6 grep that
|
||
WB ships `WorldBuilder.Shared.Services.DefaultDatReaderWriter`, a
|
||
concrete `IDatReaderWriter` implementation that takes a dat-directory
|
||
path and constructs all four databases (Portal / HighRes / Language +
|
||
CellRegions) internally. We can instantiate it directly with the same
|
||
`%USERPROFILE%\Documents\Asheron's Call` path acdream's `DatCollection`
|
||
uses; both will open the same dat files with separate handles. Memory
|
||
cost: ~50-100 MB of duplicate index caches, acceptable for foundation
|
||
work. Task 9 incorporates the construction step directly.
|
||
|
||
If memory pressure surfaces during week 2 stress testing, revisit by
|
||
writing a real bridge that shares index caches with our `DatCollection`.
|
||
|
||
**No work for this task — skip and proceed to Task 7.**
|
||
|
||
---
|
||
|
||
### Adjustment 2 (2026-05-08): Task 9 routing reverted — tier decision belongs at spawn-callback layer
|
||
|
||
**Discovered during Week 1 visual smoke test**: with flag on, characters /
|
||
NPCs disappeared along with static scenery. Root cause: Task 9 routed
|
||
**all** `InstancedMeshRenderer.EnsureUploaded` calls through
|
||
`WbMeshAdapter.IncrementRefCount` and marked their cache entries with
|
||
`WbManagedSentinel`. But `InstancedMeshRenderer` is used for both tiers
|
||
in production:
|
||
|
||
- **Atlas-tier** call sites: `_pendingCellMeshes` drain
|
||
([GameWindow.cs:5137](../../../src/AcDream.App/Rendering/GameWindow.cs:5137)),
|
||
per-MeshRef GfxObj loop on `lb.Entities`
|
||
([:5155](../../../src/AcDream.App/Rendering/GameWindow.cs:5155)).
|
||
- **Per-instance-tier** call sites: per-part loop in spawn handling
|
||
([:2302](../../../src/AcDream.App/Rendering/GameWindow.cs:2302)) — this is
|
||
character / creature rendering driven by server `CreateObject`.
|
||
|
||
The renderer is **tier-blind by design**: it doesn't know spawn source.
|
||
Putting routing logic there violates separation of concerns. The spec's
|
||
Data-Flow section already specifies the right placement — routing happens
|
||
at the **spawn-callback layer**:
|
||
|
||
- `LandblockSpawnAdapter.OnLandblockLoaded(...)` (Task 11) calls
|
||
`IncrementRefCount` per unique GfxObj — atlas-tier only.
|
||
- `EntitySpawnAdapter.OnCreate(entity)` (Task 17) routes through
|
||
per-instance path (`TextureCache.GetOrUploadWithPaletteOverride`) —
|
||
never calls `IncrementRefCount` for atlas.
|
||
|
||
**Resolution:** reverted Task 9's renderer-level routing. Removed the
|
||
sentinel logic and the 4 sentinel-skip checks in
|
||
`InstancedMeshRenderer`. **Kept** the `_wbMeshAdapter` constructor
|
||
parameter (unused for now) so `GameWindow.cs` doesn't shift when
|
||
later tasks need adapter access. Kept all the real WB pipeline
|
||
construction in `WbMeshAdapter` (verified working under flag-off).
|
||
|
||
**Week 1 endpoint shifts:** "WB infrastructure constructed; flag-on and
|
||
flag-off visually identical." Routing arrives in Week 2 (Task 11) at
|
||
the correct layer. Smoke verification is now: flag-on === flag-off.
|
||
|
||
---
|
||
|
||
### Adjustment 3 (2026-05-08): flag-on FPS regression — root-caused, deferred to Task 22
|
||
|
||
**Discovered during Task 13 stress test** (radius 7, flag-on). Visible
|
||
FPS drop + rising frame latency vs flag-off baseline. Initial guess
|
||
was the staged-upload queue leaking memory; we shipped
|
||
`WbMeshAdapter.Tick()` (commit `bf53cb4`) to drain
|
||
`_meshManager.StagedMeshData` + `_graphicsDevice._glThreadQueue` per
|
||
frame. Result: leak fixed, but **FPS unchanged**.
|
||
|
||
**Real cause: dual-pipeline cost.** Flag-on runs both rendering
|
||
pipelines in parallel without yet collecting any savings:
|
||
|
||
1. **Background workers (4-wide).** `ObjectMeshManager` spins up
|
||
`MaxParallelLoads = 4` worker threads decoding GfxObj polygons,
|
||
building texture atlases, encoding batches. Contends with the
|
||
render thread for CPU cores.
|
||
2. **Duplicate GL upload.** `Tick()` calls `UploadMeshData` per
|
||
staged mesh, creating VAO/VBO/IBO + atlas texture uploads. Real
|
||
per-call GL state churn on the render thread.
|
||
3. **Duplicate I/O.** `DefaultDatReaderWriter` opens its own dat file
|
||
handles and rebuilds its own index cache (~50-100 MB) alongside
|
||
our existing `DatCollection`. Memory bandwidth + GC churn.
|
||
4. **Legacy renderer keeps doing the same work.** Per Adjustment 2,
|
||
`InstancedMeshRenderer` is tier-blind — it still uploads VAO /
|
||
VBO / IBO for the same atlas-tier content WB is also building.
|
||
**We literally double the prep cost** for every atlas-tier GfxObj.
|
||
|
||
The savings from WB's atlas batching only materialize when **Task 22
|
||
(`WbDrawDispatcher`) lands and the legacy renderer can short-circuit
|
||
its upload for atlas-tier content**. At that point WB owns atlas-tier
|
||
draw and `InstancedMeshRenderer` skips its own upload + draw work
|
||
for those entities. Until then, flag-on pays both costs.
|
||
|
||
**Decision: do not fix now.** Plan Risk #5 explicitly anticipated this:
|
||
|
||
> Performance regression during integration of week 1's "atlas for
|
||
> static scenery, old path for everything else" mixed state.
|
||
> Mitigation: keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1`
|
||
> during weeks 1-3; default-off until week 4 visual verification.
|
||
|
||
Default-off (the user's daily experience) is byte-identical to
|
||
pre-N.4. Flag-on is dev-only until Week 4. Task 22 must wire the
|
||
legacy-renderer short-circuit for atlas-tier content as part of
|
||
landing the dispatcher; the cost cannot be amortized any earlier
|
||
without violating Adjustment 2's tier-blind-renderer principle.
|
||
|
||
`Tick()` stays — it fixed a real memory leak and is required
|
||
infrastructure for Task 22 anyway. We just paid for it without
|
||
seeing FPS recovery yet.
|
||
|
||
---
|
||
|
||
### Adjustment 4 (2026-05-08): WorldEntity lacks HiddenParts + AnimPartChange fields — deferred plumbing
|
||
|
||
**Discovered during Task 17 implementation.** `EntitySpawnAdapter.OnCreate`
|
||
needed to populate `AnimatedEntityState` with the entity's `HiddenParts`
|
||
mask + `AnimPartChange` override map. But: `WorldEntity` (the per-frame
|
||
render-side struct) does not currently expose either field. Both pieces
|
||
of customization data live on the network-layer spawn record and are
|
||
consumed before the `WorldEntity` is built.
|
||
|
||
**Resolution.** Task 17 ships the adapter scaffolding with a TODO comment
|
||
acknowledging the gap. The created `AnimatedEntityState` always has an
|
||
empty override map + zero hidden mask. Per-instance customizations like
|
||
"hide this character's head" won't take effect with flag-on until the
|
||
plumbing lands.
|
||
|
||
**Why this is safe to defer.** No production path consumes
|
||
`AnimatedEntityState`'s override / hidden data yet — Task 22's
|
||
`WbDrawDispatcher` is the first consumer. By the time Task 22 lands, we
|
||
either:
|
||
1. Add `HiddenPartsMask` + `AnimPartChanges` fields to `WorldEntity` and
|
||
populate them at spawn time. Small change to the network → render
|
||
pipeline.
|
||
2. Inject them into `EntitySpawnAdapter.OnCreate` via a separate
|
||
parameter that the spawn handler provides directly (sidesteps the
|
||
`WorldEntity` change).
|
||
|
||
Option 1 is cleaner long-term; Option 2 is faster for landing Task 22
|
||
without touching WorldEntity. Decision deferred to Task 22 brainstorm.
|
||
|
||
### Adjustment 5 (2026-05-08): Task 20 (per-instance decode conformance) is structural, not byte-comparison
|
||
|
||
**Original plan.** Task 20 was supposed to compare RGBA8 output of
|
||
"old path" (`TextureCache.GetOrUploadWithPaletteOverride` direct) vs
|
||
"new path" (`EntitySpawnAdapter` → `ITextureCachePerInstance` →
|
||
`TextureCache.GetOrUploadWithPaletteOverride`) to prove byte-identity.
|
||
|
||
**Reality.** Both paths call the **same function**. The new path adds a
|
||
seam interface (`ITextureCachePerInstance`) for testability but does
|
||
not modify the decode logic — the bytes are identical by construction.
|
||
A test asserting byte-equality would be tautological.
|
||
|
||
**Resolution.** Existing `EntitySpawnAdapterTests` cover the routing
|
||
behavior (does the adapter call the cache with the right args?). The
|
||
decode-byte conformance is structural: same function = same output.
|
||
Mark Task 20 ✅ structurally; no separate test file.
|
||
|
||
### Task 6 (original — kept for history)
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs`
|
||
|
||
WB's `ObjectMeshManager` constructor takes `IDatReaderWriter` (from `Chorizite.DatReaderWriter`). We use `DatReaderWriter.DatCollection` (vendored as a separate library). The two interfaces are similar but not identical. This task builds the adapter.
|
||
|
||
- [ ] **Step 6.1: Read WB's `IDatReaderWriter` interface**
|
||
|
||
Run: `grep -n "interface IDatReaderWriter" references/WorldBuilder/`
|
||
|
||
Read the interface to identify which methods are actually called by `ObjectMeshManager`. Likely just `Portal.TryGet<T>(id, out T)` and similar accessors.
|
||
|
||
- [ ] **Step 6.2: Write failing test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class WbDatReaderAdapterTests
|
||
{
|
||
[Fact]
|
||
public void Portal_TryGet_DelegatesToUnderlyingDats()
|
||
{
|
||
var dats = new DatCollection();
|
||
// Inject a known surface into the test dat collection.
|
||
var surface = new Surface { Id = 0x08001234 };
|
||
dats.Portal.Insert(surface);
|
||
|
||
var adapter = new WbDatReaderAdapter(dats);
|
||
|
||
Assert.True(adapter.Portal.TryGet<Surface>(0x08001234, out var got));
|
||
Assert.Equal(surface.Id, got.Id);
|
||
}
|
||
|
||
[Fact]
|
||
public void Portal_TryGet_MissingId_ReturnsFalse()
|
||
{
|
||
var adapter = new WbDatReaderAdapter(new DatCollection());
|
||
Assert.False(adapter.Portal.TryGet<Surface>(0xDEADBEEF, out _));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6.3: Run, expect compile fail**
|
||
|
||
Expected: COMPILE FAIL — `WbDatReaderAdapter` doesn't exist.
|
||
|
||
- [ ] **Step 6.4: Create the adapter**
|
||
|
||
The exact code depends on WB's `IDatReaderWriter` shape. The pattern is a thin pass-through:
|
||
|
||
```csharp
|
||
using DatReaderWriter;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Adapter from acdream's <see cref="DatCollection"/> (vendored from
|
||
/// upstream <c>DatReaderWriter</c>) to the <c>IDatReaderWriter</c>
|
||
/// interface WB's <c>ObjectMeshManager</c> consumes. Pass-through where
|
||
/// possible; reshapes calls to match WB's expected interface where the
|
||
/// libraries diverge.
|
||
/// </summary>
|
||
public sealed class WbDatReaderAdapter : Chorizite.DatReaderWriter.IDatReaderWriter
|
||
{
|
||
private readonly DatCollection _dats;
|
||
|
||
public WbDatReaderAdapter(DatCollection dats)
|
||
{
|
||
System.ArgumentNullException.ThrowIfNull(dats);
|
||
_dats = dats;
|
||
}
|
||
|
||
public Chorizite.DatReaderWriter.IDatDatabase Portal => new PortalAdapter(_dats);
|
||
public Chorizite.DatReaderWriter.IDatDatabase Cell => new CellAdapter(_dats);
|
||
public Chorizite.DatReaderWriter.IDatDatabase HighRes => new HighResAdapter(_dats);
|
||
public Chorizite.DatReaderWriter.IDatDatabase Local => new LocalAdapter(_dats);
|
||
|
||
private sealed class PortalAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
|
||
private sealed class CellAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
|
||
private sealed class HighResAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
|
||
private sealed class LocalAdapter : Chorizite.DatReaderWriter.IDatDatabase { /* ... */ }
|
||
}
|
||
```
|
||
|
||
The exact method set depends on `IDatDatabase`. Investigate via `grep` and fill in.
|
||
|
||
- [ ] **Step 6.5: Adjustment marker**
|
||
|
||
If `IDatReaderWriter`'s shape is more complex than expected (e.g., async readers, MEMORY-mapped access), add an Adjustment subsection here describing what was discovered and how the adapter changed.
|
||
|
||
- [ ] **Step 6.6: Run tests**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~WbDatReaderAdapterTests" --verbosity normal`
|
||
Expected: 2/2 PASS.
|
||
|
||
- [ ] **Step 6.7: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): WbDatReaderAdapter — bridge DatCollection to WB IDatReaderWriter
|
||
|
||
Pass-through adapter so WB's ObjectMeshManager can consume our
|
||
DatCollection without us refactoring to Chorizite.DatReaderWriter
|
||
directly. Maintains four sub-database accessors (Portal/Cell/
|
||
HighRes/Local).
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Wire `WbMeshAdapter` into `GameWindow` lifecycle (gated by flag)
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||
|
||
- [ ] **Step 7.1: Locate `GameWindow.OnLoad` and the renderer construction**
|
||
|
||
Run: `grep -n "new TextureCache\|new StaticMeshRenderer\|new InstancedMeshRenderer" src/AcDream.App/Rendering/GameWindow.cs`
|
||
|
||
Identify where existing renderers are constructed during init.
|
||
|
||
- [ ] **Step 7.2: Add `WbMeshAdapter` field + construct under flag gate**
|
||
|
||
Add a private field:
|
||
```csharp
|
||
private WbMeshAdapter? _wbMeshAdapter;
|
||
```
|
||
|
||
In `OnLoad` (or wherever renderers are constructed), after `_textures` is built:
|
||
```csharp
|
||
if (WbFoundationFlag.IsEnabled)
|
||
{
|
||
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<WbMeshAdapter>.Instance;
|
||
_wbMeshAdapter = new WbMeshAdapter(gl, _dats, logger);
|
||
System.Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
|
||
}
|
||
```
|
||
|
||
In `OnClose` / `Dispose`:
|
||
```csharp
|
||
_wbMeshAdapter?.Dispose();
|
||
```
|
||
|
||
- [ ] **Step 7.3: Build to verify no breakage with flag default-off**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
|
||
Expected: build green, all 8 + 4 + 5 + 5 + 2 = 24 new tests pass on top of the 883 baseline. (Pre-existing 8 failures unchanged.)
|
||
|
||
- [ ] **Step 7.4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): construct WbMeshAdapter in GameWindow under feature flag
|
||
|
||
Enabled only when ACDREAM_USE_WB_FOUNDATION=1. Dispose paired with
|
||
window shutdown. No call sites use it yet — wiring of TextureCache /
|
||
StaticMeshRenderer / GpuWorldState happens in Tasks 8-10.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: CLAUDE.md pointer to this plan
|
||
|
||
**Files:**
|
||
- Modify: `CLAUDE.md`
|
||
|
||
- [ ] **Step 8.1: Add a "Currently in flight" pointer near the top**
|
||
|
||
Edit `CLAUDE.md`. After the "Roadmap discipline" section's intro (around the section that mentions `docs/superpowers/specs/*.md`), insert:
|
||
|
||
```markdown
|
||
**Currently in flight: Phase N.4 — Rendering Pipeline Foundation.** Plan
|
||
at `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
|
||
This is a 3-4 week phase adopting WB's `ObjectMeshManager` +
|
||
`TextureAtlasManager` as our shared rendering infrastructure. The plan
|
||
is a living document — task checkboxes get marked as commits land,
|
||
adjustments are appended in-place, weeks 2-4 may be revised based on
|
||
week 1 discoveries. Read the plan's "Plan Living-Document Convention"
|
||
section before contributing.
|
||
```
|
||
|
||
- [ ] **Step 8.2: Commit**
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs: point CLAUDE.md at the in-flight N.4 plan
|
||
|
||
Future agents picking up the project should see the N.4 plan as
|
||
authoritative for rendering work. Pointer lives near the Roadmap
|
||
discipline section. Living-doc convention noted.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Wire static-scenery path through `WbMeshAdapter`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs`
|
||
|
||
This is the first behavioral change: when the flag is on, static-scenery uploads route through `WbMeshAdapter` instead of building VAO/VBO/EBO inline.
|
||
|
||
- [ ] **Step 9.1: Locate `EnsureUploaded` in `StaticMeshRenderer.cs`**
|
||
|
||
Currently uploads sub-meshes directly. We're adding a flag-gated alternate path.
|
||
|
||
- [ ] **Step 9.2: Add adapter reference + flag-gated upload**
|
||
|
||
Modify `StaticMeshRenderer` to accept an optional `WbMeshAdapter`:
|
||
|
||
```csharp
|
||
public sealed unsafe class StaticMeshRenderer : IDisposable
|
||
{
|
||
// ...existing fields...
|
||
private readonly WbMeshAdapter? _wbMeshAdapter;
|
||
|
||
public StaticMeshRenderer(
|
||
GL gl,
|
||
Shader shader,
|
||
TextureCache textures,
|
||
WbMeshAdapter? wbMeshAdapter = null) // optional injection
|
||
{
|
||
_gl = gl;
|
||
_shader = shader;
|
||
_textures = textures;
|
||
_wbMeshAdapter = wbMeshAdapter;
|
||
}
|
||
|
||
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
|
||
{
|
||
if (_gpuByGfxObj.ContainsKey(gfxObjId))
|
||
return;
|
||
|
||
if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
|
||
{
|
||
// New path: route to ObjectMeshManager. WB will background-prep
|
||
// and upload; we mark this gfxObj as "WB-managed" in our local
|
||
// cache via a sentinel entry so the draw loop knows to look
|
||
// there instead of in _gpuByGfxObj.
|
||
_wbMeshAdapter.IncrementRefCount(gfxObjId);
|
||
_gpuByGfxObj[gfxObjId] = WbManagedSentinel;
|
||
return;
|
||
}
|
||
|
||
// Legacy path: build VAO/VBO/EBO inline.
|
||
var list = new List<SubMeshGpu>(subMeshes.Count);
|
||
foreach (var sm in subMeshes)
|
||
list.Add(UploadSubMesh(sm));
|
||
_gpuByGfxObj[gfxObjId] = list;
|
||
}
|
||
|
||
private static readonly List<SubMeshGpu> WbManagedSentinel = new(0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9.3: Update `Draw` to look up WB-managed entries differently**
|
||
|
||
In the draw loop, when iterating entities, check the sentinel:
|
||
|
||
```csharp
|
||
if (object.ReferenceEquals(_gpuByGfxObj[gfxObjId], WbManagedSentinel))
|
||
{
|
||
// Draw via WbDrawDispatcher — implementation in Task 17.
|
||
// For week 1 the dispatcher is a stub that no-ops; the entity simply
|
||
// doesn't render. This is fine for the flag-gated build; week 4's
|
||
// visual verification is the gate where this must work.
|
||
continue;
|
||
}
|
||
```
|
||
|
||
This intentionally leaves a behavioral gap in week 1: with the flag on, static scenery does NOT render correctly. **This is expected.** The full draw path lands in Task 17 (week 4). Week 1's success criterion is "build green, conformance tests pass, no regressions with flag OFF."
|
||
|
||
- [ ] **Step 9.4: Pass adapter to constructor in `GameWindow`**
|
||
|
||
In `GameWindow.OnLoad`:
|
||
```csharp
|
||
_staticMeshRenderer = new StaticMeshRenderer(gl, staticShader, _textures, _wbMeshAdapter);
|
||
```
|
||
|
||
- [ ] **Step 9.5: Build, run all tests, smoke-test with flag off**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
|
||
Expected: build green, 883+24 = 907 tests pass, 8 pre-existing failures.
|
||
|
||
Smoke-test launch with flag off (default) — Holtburg should render identically to before.
|
||
|
||
- [ ] **Step 9.6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): route static-scenery uploads through WbMeshAdapter under flag
|
||
|
||
When ACDREAM_USE_WB_FOUNDATION=1, StaticMeshRenderer.EnsureUploaded
|
||
calls WbMeshAdapter.IncrementRefCount instead of building VAO/VBO/EBO
|
||
inline. Local cache uses a sentinel entry to mark WB-managed gfxObjs.
|
||
|
||
The draw loop currently skips WB-managed entries (they don't render
|
||
yet). This is expected: the full draw path arrives in Task 17 / week 4.
|
||
Week 1's success is "no regressions with flag OFF."
|
||
|
||
Default-off — flag must be set explicitly to test the new path.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Week 1 wrap-up — verify clean baseline + commit week 1 status
|
||
|
||
**Files:** none (verification + status update)
|
||
|
||
- [ ] **Step 10.1: Full test run + build**
|
||
|
||
Run: `dotnet build --verbosity quiet 2>&1 | tail -5 && dotnet test --verbosity quiet 2>&1 | tail -5`
|
||
Expected: 0 errors. 907+ tests pass, 8 pre-existing failures only.
|
||
|
||
- [ ] **Step 10.2: Smoke-test with flag off**
|
||
|
||
Launch the client with default env (flag OFF). Walk Holtburg briefly. Confirm: no visual change vs pre-N.4 main.
|
||
|
||
- [ ] **Step 10.3: Smoke-test with flag on (expect partial breakage)**
|
||
|
||
Launch with `$env:ACDREAM_USE_WB_FOUNDATION = "1"`. Confirm: client launches, scenery is missing or partially missing (expected — WbDrawDispatcher is a stub). Stop the client.
|
||
|
||
- [ ] **Step 10.4: Update plan checkboxes**
|
||
|
||
In this plan file, mark Tasks 1-9 as ✅ with their commit SHAs. Append a "Week 1 status: COMPLETE — date YYYY-MM-DD" note at the start of Week 2.
|
||
|
||
- [ ] **Step 10.5: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs(N.4): mark week 1 complete in living-doc plan
|
||
|
||
WB infrastructure wired up behind ACDREAM_USE_WB_FOUNDATION flag.
|
||
Conformance tests pinned (mesh extraction + setup flatten). Static
|
||
scenery routes through WbMeshAdapter when flag is on; rendering
|
||
completion deferred to Task 17 (week 4).
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Week 2 — Streaming Integration
|
||
|
||
Goal of week 2: `LandblockSpawnAdapter` + `LandblockUnloadAdapter` wired through `GpuWorldState`. Memory budget verified under long-roam stress. Pending-spawn list still works. **Done when: `ObjectMeshManager` ref counts balance across landblock load/unload, GPU memory stable on long roam.**
|
||
|
||
### Task 11: `LandblockSpawnAdapter`
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs`
|
||
|
||
- [ ] **Step 11.1: Write failing tests**
|
||
|
||
```csharp
|
||
using System.Linq;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class LandblockSpawnAdapterTests
|
||
{
|
||
[Fact]
|
||
public void OnLandblockLoaded_RegistersIncrementForEachUniqueGfxObj()
|
||
{
|
||
var adapter = MakeAdapter(out var captured);
|
||
|
||
var lb = MakeLandblock(setupIds: [0x02000001ul, 0x02000002ul, 0x02000001ul],
|
||
staticIds: [0x01000010ul]);
|
||
adapter.OnLandblockLoaded(0x12340000u, lb);
|
||
|
||
// Three unique ids despite duplicate setup id.
|
||
Assert.Equal(3, captured.IncrementCalls.Count);
|
||
Assert.Contains(0x02000001ul, captured.IncrementCalls);
|
||
Assert.Contains(0x02000002ul, captured.IncrementCalls);
|
||
Assert.Contains(0x01000010ul, captured.IncrementCalls);
|
||
}
|
||
|
||
[Fact]
|
||
public void OnLandblockUnloaded_RegistersMatchingDecrements()
|
||
{
|
||
var adapter = MakeAdapter(out var captured);
|
||
|
||
var lb = MakeLandblock(setupIds: [0x02000001ul, 0x02000002ul],
|
||
staticIds: []);
|
||
adapter.OnLandblockLoaded(0x12340000u, lb);
|
||
adapter.OnLandblockUnloaded(0x12340000u);
|
||
|
||
Assert.Equal(captured.IncrementCalls.OrderBy(x => x), captured.DecrementCalls.OrderBy(x => x));
|
||
}
|
||
|
||
[Fact]
|
||
public void OnLandblockUnloaded_UnknownLandblock_NoOp()
|
||
{
|
||
var adapter = MakeAdapter(out var captured);
|
||
|
||
adapter.OnLandblockUnloaded(0xDEADBEEFu); // never loaded
|
||
|
||
Assert.Empty(captured.DecrementCalls);
|
||
}
|
||
|
||
private static LandblockSpawnAdapter MakeAdapter(out CapturingAdapterMock captured)
|
||
{
|
||
captured = new CapturingAdapterMock();
|
||
return new LandblockSpawnAdapter(captured);
|
||
}
|
||
|
||
private sealed class CapturingAdapterMock : IWbMeshAdapter
|
||
{
|
||
public List<ulong> IncrementCalls { get; } = new();
|
||
public List<ulong> DecrementCalls { get; } = new();
|
||
public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
|
||
public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
|
||
}
|
||
|
||
private static LoadedLandblock MakeLandblock(ulong[] setupIds, ulong[] staticIds)
|
||
{
|
||
// Synthetic LoadedLandblock for test; the test only cares about the
|
||
// unique GfxObj ids reachable from Setups + Statics. Field shape may
|
||
// need adjustment to match LoadedLandblock's actual constructor.
|
||
return new LoadedLandblock(
|
||
setups: setupIds.Select(id => new SetupSpawn(id)).ToList(),
|
||
statics: staticIds.Select(id => new StaticSpawn(id)).ToList());
|
||
}
|
||
}
|
||
```
|
||
|
||
(Note: `IWbMeshAdapter` is a new interface — see Step 11.3. `LoadedLandblock` constructor shape may need adjustment to whatever the codebase uses.)
|
||
|
||
- [ ] **Step 11.2: Add `IWbMeshAdapter` interface + extract from `WbMeshAdapter`**
|
||
|
||
Modify `WbMeshAdapter.cs` to implement an interface so the adapter can be mocked:
|
||
|
||
```csharp
|
||
public interface IWbMeshAdapter
|
||
{
|
||
void IncrementRefCount(ulong id);
|
||
void DecrementRefCount(ulong id);
|
||
}
|
||
|
||
public sealed class WbMeshAdapter : System.IDisposable, IWbMeshAdapter
|
||
{
|
||
// ...existing impl...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.3: Create `LandblockSpawnAdapter`**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Bridges landblock streaming events to <see cref="IWbMeshAdapter"/>'s
|
||
/// reference-count lifecycle. Walks <c>LoadedLandblock.Setups</c> and
|
||
/// <c>LoadedLandblock.Statics</c> for unique GfxObj/Setup ids; calls
|
||
/// <c>IncrementRefCount</c> on load and matching <c>DecrementRefCount</c>
|
||
/// on unload.
|
||
///
|
||
/// <para>
|
||
/// Maintains a <c>Dictionary<landblockId, HashSet<ulong>></c>
|
||
/// snapshot of which ids each landblock holds, so unload can match the
|
||
/// load 1:1 without re-walking the (now-released) landblock data.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class LandblockSpawnAdapter
|
||
{
|
||
private readonly IWbMeshAdapter _adapter;
|
||
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
|
||
|
||
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
|
||
{
|
||
System.ArgumentNullException.ThrowIfNull(adapter);
|
||
_adapter = adapter;
|
||
}
|
||
|
||
public void OnLandblockLoaded(uint landblockId, LoadedLandblock lb)
|
||
{
|
||
var unique = new HashSet<ulong>();
|
||
foreach (var setup in lb.Setups) unique.Add(setup.GfxObjId);
|
||
foreach (var stat in lb.Statics) unique.Add(stat.GfxObjId);
|
||
|
||
_idsByLandblock[landblockId] = unique;
|
||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||
}
|
||
|
||
public void OnLandblockUnloaded(uint landblockId)
|
||
{
|
||
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
|
||
foreach (var id in unique) _adapter.DecrementRefCount(id);
|
||
_idsByLandblock.Remove(landblockId);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.4: Run tests, verify pass**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~LandblockSpawnAdapter" --verbosity normal`
|
||
Expected: 3/3 PASS.
|
||
|
||
- [ ] **Step 11.5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): LandblockSpawnAdapter bridges streaming to WB ref counts
|
||
|
||
OnLandblockLoaded walks Setups + Statics for unique GfxObj ids and
|
||
calls IncrementRefCount per id. OnLandblockUnloaded matches with
|
||
DecrementRefCount. Per-landblock id-set snapshot ensures unload pairs
|
||
1:1 with load even when underlying data is released.
|
||
|
||
IWbMeshAdapter interface extracted from WbMeshAdapter to enable
|
||
mocking in tests.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Wire `LandblockSpawnAdapter` into `GpuWorldState`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||
|
||
- [ ] **Step 12.1: Add adapter field + flag-gated calls in `GpuWorldState`**
|
||
|
||
Modify `GpuWorldState`'s `AddLandblock` and `RemoveLandblock`:
|
||
|
||
```csharp
|
||
private readonly Wb.LandblockSpawnAdapter? _wbSpawnAdapter;
|
||
|
||
public GpuWorldState(Wb.LandblockSpawnAdapter? wbSpawnAdapter = null)
|
||
{
|
||
_wbSpawnAdapter = wbSpawnAdapter;
|
||
}
|
||
|
||
public void AddLandblock(LoadedLandblock landblock)
|
||
{
|
||
// ...existing logic...
|
||
if (Wb.WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||
_wbSpawnAdapter.OnLandblockLoaded(landblock.LandblockId, landblock);
|
||
}
|
||
|
||
public void RemoveLandblock(uint landblockId)
|
||
{
|
||
if (Wb.WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
||
// ...existing logic...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12.2: Construct adapter in `GameWindow`**
|
||
|
||
In `GameWindow.OnLoad`:
|
||
```csharp
|
||
LandblockSpawnAdapter? wbSpawnAdapter = null;
|
||
if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
|
||
wbSpawnAdapter = new LandblockSpawnAdapter(_wbMeshAdapter);
|
||
_gpuWorldState = new GpuWorldState(wbSpawnAdapter);
|
||
```
|
||
|
||
- [ ] **Step 12.3: Build + tests + smoke-test flag off**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
|
||
Expected: build green, all tests pass.
|
||
|
||
Smoke-test with flag off: verify no regressions.
|
||
|
||
- [ ] **Step 12.4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Streaming/GpuWorldState.cs src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): wire LandblockSpawnAdapter through GpuWorldState
|
||
|
||
AddLandblock/RemoveLandblock now drive WB ref counts when flag is on.
|
||
Pending-spawn list mechanism untouched — adapter is invoked only when
|
||
a landblock fully loads (drains pending), not when a spawn parks.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Memory budget + LRU verification under stress
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MemoryBudgetTests.cs` (optional — likely manual verification)
|
||
|
||
- [ ] **Step 13.1: Manual stress test plan**
|
||
|
||
This is a verification task, not an implementation task. Document the plan:
|
||
|
||
1. Launch with `ACDREAM_USE_WB_FOUNDATION=1` and `ACDREAM_STREAM_RADIUS=7`.
|
||
2. Walk in a straight line for ~5 minutes (covers 50+ landblocks in/out of radius).
|
||
3. Monitor GPU memory in window title bar.
|
||
4. Acceptance: GPU memory grows to ~steady-state value (depending on hardware, somewhere under 1 GB) and stays there. If it grows unboundedly, LRU eviction isn't firing.
|
||
|
||
Run with: `$env:ACDREAM_USE_WB_FOUNDATION = "1"; $env:ACDREAM_STREAM_RADIUS = "7"; dotnet run --project src\AcDream.App\AcDream.App.csproj 2>&1 | Tee-Object -FilePath "n4-stress.log"`
|
||
|
||
- [ ] **Step 13.2: Run with the user**
|
||
|
||
Hand off to user. User walks for 5+ minutes. User reports observed peak memory + final stable memory.
|
||
|
||
- [ ] **Step 13.3: Document outcome**
|
||
|
||
If memory is stable: append "Memory budget verified at <peak MB> peak / <stable MB> steady" to this task.
|
||
|
||
If memory grows unboundedly: investigate. Likely causes:
|
||
- Adapter fails to call `DecrementRefCount` in some path (check for unload logging).
|
||
- WB's LRU eviction interacts badly with our streaming radius hysteresis.
|
||
- Memory budget set too high for the test hardware.
|
||
|
||
Do not commit a "fix" until root cause is understood. Add an Adjustment subsection here documenting what was found.
|
||
|
||
- [ ] **Step 13.4: Commit (verification-only commit if memory was clean)**
|
||
|
||
```bash
|
||
# No code changes; just an empty commit to mark verification complete in history.
|
||
git commit --allow-empty -m "$(cat <<'EOF'
|
||
verify(N.4): memory budget + LRU eviction stable under 5min/r=7 roam
|
||
|
||
GPU memory peak: <fill in MB>. Steady-state: <fill in MB>. Eviction
|
||
fires correctly on landblock unload. LandblockSpawnAdapter ref-count
|
||
balance verified through repeated traversal.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Pending-spawn list integration verification
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs`
|
||
|
||
The pending-spawn list ([feedback_phase_a1_hotfix_saga.md](memory/feedback_phase_a1_hotfix_saga.md)) parks `CreateObject` events that arrive before their landblock streams in. We need to verify this still works with the WB foundation.
|
||
|
||
- [ ] **Step 14.1: Write integration test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.App.Streaming;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class PendingSpawnIntegrationTests
|
||
{
|
||
[Fact]
|
||
public void LiveEntity_BeforeLandblock_Pends_ThenDrains_OnLoad()
|
||
{
|
||
var captured = new CapturingAdapterMock();
|
||
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
||
var state = new GpuWorldState(spawnAdapter);
|
||
|
||
// Live entity for landblock 0x12340000 arrives first.
|
||
var entity = new WorldEntity { /* with LandblockId = 0x12340000 */ };
|
||
state.AppendLiveEntity(entity);
|
||
|
||
Assert.Equal(1, state.PendingLiveEntityCount);
|
||
Assert.Empty(captured.IncrementCalls); // not registered yet
|
||
|
||
// Now landblock arrives.
|
||
var lb = new LoadedLandblock(/* ... */);
|
||
state.AddLandblock(lb);
|
||
|
||
// Pending entity drains; adapter sees landblock-side increments.
|
||
Assert.True(captured.IncrementCalls.Count > 0);
|
||
Assert.Equal(0, state.PendingLiveEntityCount);
|
||
}
|
||
}
|
||
```
|
||
|
||
(Test-fixture details depend on `WorldEntity` and `LoadedLandblock` constructors.)
|
||
|
||
- [ ] **Step 14.2: Run, verify pass**
|
||
|
||
If the test fails, the pending-spawn list path doesn't drain through the adapter. Either fix the adapter wiring in `GpuWorldState.AddLandblock` to handle pending entities, or document an Adjustment.
|
||
|
||
- [ ] **Step 14.3: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
test(N.4): pending-spawn list still drains correctly with WB adapter
|
||
|
||
Verifies CreateObject-before-landblock parks → drains on landblock
|
||
arrival. Adapter sees ref-count increments only after drain.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Week 2 wrap-up
|
||
|
||
- [ ] **Step 15.1: Full test suite + roam**
|
||
|
||
Run all tests. Roam at radius 7 with flag on for 5 minutes. Confirm: stable memory, no crashes, ref counts balance.
|
||
|
||
- [ ] **Step 15.2: Update plan checkboxes**
|
||
|
||
Mark Tasks 11-14 ✅ with commit SHAs. Append "Week 2 status: COMPLETE — date YYYY-MM-DD" at start of Week 3.
|
||
|
||
- [ ] **Step 15.3: Commit plan update**
|
||
|
||
```bash
|
||
git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
|
||
git commit -m "docs(N.4): mark week 2 complete
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Week 3 — Per-instance + Animation
|
||
|
||
Goal: per-instance customization works correctly. Animated creatures render with their server-sent overrides applied. AnimPartChange + HiddenParts honored. **Done when: drudge / chicken / banderling render with correct customizations under flag on.**
|
||
|
||
### Task 16: `AnimatedEntityState` type
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs`
|
||
|
||
- [ ] **Step 16.1: Write failing test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Animation;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class AnimatedEntityStateTests
|
||
{
|
||
[Fact]
|
||
public void DefaultState_HasNoOverridesAndNoHiddenParts()
|
||
{
|
||
var state = new AnimatedEntityState(MakeSequencer());
|
||
|
||
Assert.False(state.IsPartHidden(0));
|
||
Assert.False(state.IsPartHidden(63));
|
||
Assert.False(state.TryGetPartOverride(0, out _));
|
||
}
|
||
|
||
[Fact]
|
||
public void SetHiddenPart_BitmaskIsApplied()
|
||
{
|
||
var state = new AnimatedEntityState(MakeSequencer());
|
||
|
||
state.HideParts(hiddenMask: 0b1010);
|
||
|
||
Assert.False(state.IsPartHidden(0));
|
||
Assert.True(state.IsPartHidden(1));
|
||
Assert.False(state.IsPartHidden(2));
|
||
Assert.True(state.IsPartHidden(3));
|
||
}
|
||
|
||
[Fact]
|
||
public void SetPartOverride_ResolvedAtLookup()
|
||
{
|
||
var state = new AnimatedEntityState(MakeSequencer());
|
||
|
||
state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul);
|
||
|
||
Assert.True(state.TryGetPartOverride(5, out var got));
|
||
Assert.Equal(0x01001234ul, got);
|
||
Assert.False(state.TryGetPartOverride(6, out _));
|
||
}
|
||
|
||
private static AnimationSequencer MakeSequencer() => new(); // adjust to constructor
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 16.2: Create the type**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Animation;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Per-entity render state for animated entities. Lives outside WB's
|
||
/// mesh cache because it varies per instance (AnimPartChange override
|
||
/// map, HiddenParts mask) and per frame (animation transforms produced
|
||
/// by the sequencer).
|
||
///
|
||
/// <para>
|
||
/// Instances are created by <c>EntitySpawnAdapter.OnCreate</c> and
|
||
/// disposed by <c>EntitySpawnAdapter.OnRemove</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class AnimatedEntityState
|
||
{
|
||
private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
|
||
private ulong _hiddenMask = 0;
|
||
public AnimationSequencer Sequencer { get; }
|
||
|
||
public AnimatedEntityState(AnimationSequencer sequencer)
|
||
{
|
||
System.ArgumentNullException.ThrowIfNull(sequencer);
|
||
Sequencer = sequencer;
|
||
}
|
||
|
||
public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
|
||
|
||
public bool IsPartHidden(int partIdx)
|
||
{
|
||
if (partIdx < 0 || partIdx >= 64) return false;
|
||
return (_hiddenMask & (1ul << partIdx)) != 0;
|
||
}
|
||
|
||
public void SetPartOverride(int partIdx, ulong gfxObjId)
|
||
=> _partGfxObjOverrides[partIdx] = gfxObjId;
|
||
|
||
public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
|
||
=> _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 16.3: Run, verify pass**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~AnimatedEntityStateTests" --verbosity normal`
|
||
Expected: 3/3 PASS.
|
||
|
||
- [ ] **Step 16.4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): AnimatedEntityState — per-entity render state
|
||
|
||
Holds AnimPartChange override map + HiddenParts bitmask + reference
|
||
to existing AnimationSequencer. Lives outside WB's mesh cache.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: `EntitySpawnAdapter` — route CreateObject to per-instance path
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs`
|
||
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
|
||
|
||
- [ ] **Step 17.1: Write failing test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class EntitySpawnAdapterTests
|
||
{
|
||
[Fact]
|
||
public void OnCreate_WithPaletteOverride_RoutesToPerInstanceCache()
|
||
{
|
||
var captured = new CapturingTextureCacheMock();
|
||
var adapter = new EntitySpawnAdapter(captured);
|
||
|
||
var entity = new WorldEntity
|
||
{
|
||
// Set up with non-trivial PaletteOverride so we can verify routing.
|
||
ObjDescBuilder = new ObjDescBuilder { PaletteOverride = MakeOverride() },
|
||
};
|
||
|
||
adapter.OnCreate(entity);
|
||
|
||
Assert.True(captured.PaletteOverrideCalled);
|
||
}
|
||
|
||
[Fact]
|
||
public void OnCreate_WithoutCustomization_StillRegistersForCleanup()
|
||
{
|
||
var captured = new CapturingTextureCacheMock();
|
||
var adapter = new EntitySpawnAdapter(captured);
|
||
|
||
adapter.OnCreate(new WorldEntity());
|
||
adapter.OnRemove(0xDEADBEEFu); // doesn't crash on unknown id
|
||
|
||
// Adapter tracks created entities for OnRemove cleanup.
|
||
Assert.True(true); // smoke
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 17.2: Create adapter**
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Routes network-spawned <c>CreateObject</c> entities through the per-
|
||
/// instance rendering path. Every entity sent by the server carries
|
||
/// per-instance customization (palette overrides, texture changes,
|
||
/// part swaps), so they bypass WB's atlas and use the existing
|
||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> path that
|
||
/// already hash-keys overrides for caching.
|
||
/// </summary>
|
||
public sealed class EntitySpawnAdapter
|
||
{
|
||
private readonly TextureCache _textures;
|
||
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
|
||
|
||
public EntitySpawnAdapter(TextureCache textures)
|
||
{
|
||
System.ArgumentNullException.ThrowIfNull(textures);
|
||
_textures = textures;
|
||
}
|
||
|
||
public AnimatedEntityState? OnCreate(WorldEntity entity)
|
||
{
|
||
// Build palette override from entity's ObjDesc.SubPalettes (if any).
|
||
var palette = entity.PaletteOverride;
|
||
// For each surface in the entity's mesh chain, decode through
|
||
// the per-instance path. The TextureCache already hash-keys the
|
||
// override, so identical customizations across multiple entities
|
||
// share the cached texture.
|
||
if (palette is not null && palette.SubPalettes.Count > 0)
|
||
{
|
||
foreach (var surfaceId in entity.SurfaceIds)
|
||
_textures.GetOrUploadWithPaletteOverride(surfaceId, null, palette);
|
||
}
|
||
|
||
var state = new AnimatedEntityState(entity.AnimationSequencer);
|
||
|
||
// Apply HiddenParts mask if set on the entity.
|
||
if (entity.HiddenPartsMask != 0)
|
||
state.HideParts(entity.HiddenPartsMask);
|
||
|
||
// Apply AnimPartChange overrides if any.
|
||
foreach (var (partIdx, gfxObjId) in entity.AnimPartChanges)
|
||
state.SetPartOverride(partIdx, gfxObjId);
|
||
|
||
_stateByGuid[entity.Guid] = state;
|
||
return state;
|
||
}
|
||
|
||
public void OnRemove(uint guid) => _stateByGuid.Remove(guid);
|
||
|
||
public AnimatedEntityState? GetState(uint guid)
|
||
=> _stateByGuid.TryGetValue(guid, out var s) ? s : null;
|
||
}
|
||
```
|
||
|
||
(Note: exact field names like `entity.PaletteOverride`, `entity.SurfaceIds`, `entity.HiddenPartsMask`, `entity.AnimPartChanges` depend on `WorldEntity`'s actual API — adjust at implementation time. If any are missing today, the adapter exposes the gap and we plan a follow-on commit to surface them.)
|
||
|
||
- [ ] **Step 17.3: Wire into `GpuWorldState`**
|
||
|
||
In `GpuWorldState.AppendLiveEntity`, when flag is on:
|
||
```csharp
|
||
if (Wb.WbFoundationFlag.IsEnabled && _wbEntitySpawnAdapter is not null)
|
||
_wbEntitySpawnAdapter.OnCreate(entity);
|
||
```
|
||
|
||
- [ ] **Step 17.4: Run tests, verify**
|
||
|
||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~EntitySpawnAdapter" --verbosity normal`
|
||
|
||
- [ ] **Step 17.5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs src/AcDream.App/Streaming/GpuWorldState.cs tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): EntitySpawnAdapter routes CreateObject to per-instance path
|
||
|
||
Network-spawned entities bypass WB's atlas and use existing
|
||
TextureCache.GetOrUploadWithPaletteOverride which already hash-keys
|
||
customizations. AnimatedEntityState is constructed per-entity with
|
||
HiddenParts mask + AnimPartChange overrides applied at spawn time.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: AnimPartChange resolution unit tests
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs`
|
||
|
||
- [ ] **Step 18.1: Write tests for the resolution logic**
|
||
|
||
The logic that picks "use override gfxObjId or fall back to default" lives in `WbDrawDispatcher` (Task 21). For now, add a small helper method on `AnimatedEntityState` that does the resolution, and test it directly:
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Animation;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class AnimPartChangeTests
|
||
{
|
||
[Fact]
|
||
public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault()
|
||
{
|
||
var state = new AnimatedEntityState(new AnimationSequencer());
|
||
ulong setupDefault = 0x01000001ul;
|
||
Assert.Equal(setupDefault, state.ResolvePartGfxObj(partIdx: 0, setupDefault));
|
||
}
|
||
|
||
[Fact]
|
||
public void ResolvePartGfxObj_WithOverride_ReturnsOverride()
|
||
{
|
||
var state = new AnimatedEntityState(new AnimationSequencer());
|
||
state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul);
|
||
|
||
Assert.Equal(0x01999999ul, state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 18.2: Add `ResolvePartGfxObj` method on `AnimatedEntityState`**
|
||
|
||
```csharp
|
||
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
|
||
=> TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
|
||
```
|
||
|
||
- [ ] **Step 18.3: Run, commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): AnimPartChange resolution helper on AnimatedEntityState
|
||
|
||
ResolvePartGfxObj(partIdx, setupDefault) returns override if set,
|
||
else the Setup's part default. Tested standalone; consumed by
|
||
WbDrawDispatcher in Task 21.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: HiddenParts mask suppression unit tests
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs`
|
||
|
||
- [ ] **Step 19.1: Write the test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Animation;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class HiddenPartsTests
|
||
{
|
||
[Theory]
|
||
[InlineData(0b0000_0000ul, 0, false)]
|
||
[InlineData(0b0000_0001ul, 0, true)]
|
||
[InlineData(0b1000_0000ul, 7, true)]
|
||
[InlineData(0b1000_0000ul, 6, false)]
|
||
[InlineData(0xFFFF_FFFF_FFFF_FFFFul, 63, true)]
|
||
public void IsPartHidden_RespectsBitmaskBit(ulong mask, int partIdx, bool expected)
|
||
{
|
||
var state = new AnimatedEntityState(new AnimationSequencer());
|
||
state.HideParts(mask);
|
||
Assert.Equal(expected, state.IsPartHidden(partIdx));
|
||
}
|
||
|
||
[Fact]
|
||
public void IsPartHidden_NegativeIdx_ReturnsFalse()
|
||
{
|
||
var state = new AnimatedEntityState(new AnimationSequencer());
|
||
state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
|
||
Assert.False(state.IsPartHidden(-1));
|
||
}
|
||
|
||
[Fact]
|
||
public void IsPartHidden_PartIdxOver64_ReturnsFalse()
|
||
{
|
||
var state = new AnimatedEntityState(new AnimationSequencer());
|
||
state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
|
||
Assert.False(state.IsPartHidden(64));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 19.2: Run, commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
test(N.4): HiddenParts mask suppression edge cases
|
||
|
||
Theory cases for bitmask resolution + bounds checking. Pins
|
||
the per-bit semantics consumed by WbDrawDispatcher.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Per-instance decode conformance test
|
||
|
||
**Files:**
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs`
|
||
|
||
- [ ] **Step 20.1: Write the conformance test**
|
||
|
||
```csharp
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Textures;
|
||
using AcDream.Core.World;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class PerInstanceDecodeConformanceTests
|
||
{
|
||
/// <summary>
|
||
/// The new EntitySpawnAdapter routes CreateObject through TextureCache.
|
||
/// GetOrUploadWithPaletteOverride. This test pins that routing — given
|
||
/// the same surface id + palette override, both paths must produce
|
||
/// byte-identical RGBA8.
|
||
/// </summary>
|
||
[Fact]
|
||
public void NewPath_AndOldTextureCachePath_ProduceIdenticalRgba()
|
||
{
|
||
// Build a small synthetic dat with: 1 Surface, 1 SurfaceTexture,
|
||
// 1 RenderSurface (PFID_INDEX16, 4×4), 2 Palettes (base + sub).
|
||
var dats = BuildSyntheticDats();
|
||
var paletteOverride = new PaletteOverride(
|
||
BasePaletteId: 0x04000001u,
|
||
SubPalettes: [new(0x04000002u, Offset: 0, Length: 16)]);
|
||
|
||
// Old path
|
||
using var glStub = new GLStub();
|
||
var cacheOld = new TextureCache(glStub.GL, dats);
|
||
var oldHandle = cacheOld.GetOrUploadWithPaletteOverride(
|
||
surfaceId: 0x08000001u, null, paletteOverride);
|
||
var oldBytes = glStub.ReadBackTexture(oldHandle);
|
||
|
||
// New path (through EntitySpawnAdapter)
|
||
var entity = new WorldEntity { Guid = 0xCAFE, PaletteOverride = paletteOverride, SurfaceIds = [0x08000001u] };
|
||
var cacheNew = new TextureCache(glStub.GL, dats);
|
||
var adapter = new EntitySpawnAdapter(cacheNew);
|
||
adapter.OnCreate(entity);
|
||
// The adapter calls the same method internally; we just verify
|
||
// the bytes match by re-decoding via the cache directly.
|
||
var newHandle = cacheNew.GetOrUploadWithPaletteOverride(
|
||
surfaceId: 0x08000001u, null, paletteOverride);
|
||
var newBytes = glStub.ReadBackTexture(newHandle);
|
||
|
||
Assert.Equal(oldBytes, newBytes);
|
||
}
|
||
}
|
||
```
|
||
|
||
(`GLStub` is a test fixture that stands in for a real GL context. If the test infrastructure doesn't have one yet, this test may need to be deferred to integration-time; document an Adjustment if so.)
|
||
|
||
- [ ] **Step 20.2: Run, verify**
|
||
|
||
If GLStub exists: run and expect PASS.
|
||
If not: replace with a smaller test that compares the decode output directly (without GL), reusing the conformance pattern from N.3.
|
||
|
||
- [ ] **Step 20.3: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Rendering/Wb/PerInstanceDecodeConformanceTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
test(N.4): per-instance decode conformance — old vs new RGBA8
|
||
|
||
Verifies EntitySpawnAdapter's per-instance path produces byte-identical
|
||
RGBA8 to today's TextureCache.GetOrUploadWithPaletteOverride. Pins
|
||
the decode behavior across the substitution.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Week 3 wrap-up
|
||
|
||
- [ ] **Step 21.1: Mark week 3 tasks ✅, run all tests, commit plan update**
|
||
|
||
Same pattern as Week 1/2 wrap-ups.
|
||
|
||
---
|
||
|
||
## Week 4 — Polish + Visual Verification + Ship
|
||
|
||
Goal: complete `WbDrawDispatcher` so flag-on rendering produces visible output. Side-table populated correctly. Sky pass preserved. Visual verification at 5 named locations. Flag default-on. Phase shipped.
|
||
|
||
### Task 22: `WbDrawDispatcher` — full draw loop
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs`
|
||
|
||
This is the largest task. It implements both atlas-tier and per-instance-tier draw paths with proper matrix composition.
|
||
|
||
- [ ] **Step 22.1: Write matrix composition test**
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering.Wb;
|
||
|
||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||
|
||
public sealed class MatrixCompositionTests
|
||
{
|
||
[Fact]
|
||
public void Compose_EntityAnimRest_ProducesExpectedWorldMatrix()
|
||
{
|
||
var entityWorld = Matrix4x4.CreateTranslation(100, 200, 300);
|
||
var animOverride = Matrix4x4.CreateRotationZ(MathF.PI / 4); // 45° yaw
|
||
var restPose = Matrix4x4.CreateTranslation(1, 0, 0);
|
||
|
||
var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose);
|
||
|
||
// Expected: rest first → animated rotation → entity world translate.
|
||
var expected = restPose * animOverride * entityWorld;
|
||
Assert.Equal(expected, result);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 22.2: Create `WbDrawDispatcher` skeleton with the static helper**
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering.Wb;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
public sealed class WbDrawDispatcher
|
||
{
|
||
public static Matrix4x4 ComposePartWorldMatrix(
|
||
Matrix4x4 entityWorld,
|
||
Matrix4x4 animOverride,
|
||
Matrix4x4 restPose)
|
||
=> restPose * animOverride * entityWorld;
|
||
|
||
// Full Draw() comes in Step 22.3.
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 22.3: Implement full draw loop**
|
||
|
||
The full draw loop is too large to spell out here in code; it's a structured port of today's `StaticMeshRenderer.Draw` and the per-instance entity rendering, layered on top of WB's `ObjectRenderData`. Implementation guidance:
|
||
|
||
1. Walk visible atlas-tier entities (those whose gfxObjId is registered in `WbMeshAdapter`):
|
||
- Get `ObjectRenderData` from the adapter.
|
||
- For each batch in `renderData.Batches`: bind atlas + shader + uniforms; for each part in `renderData.SetupParts`: compose world matrix (no animation, identity for static), push uniform, draw.
|
||
2. Walk visible per-instance-tier entities (animated):
|
||
- Get `AnimatedEntityState` from `EntitySpawnAdapter`.
|
||
- For each part: skip if hidden; resolve gfxObjId via override or default; get `ObjectRenderData`; look up `AcSurfaceMetadata` from the side-table; compose matrix (entity × animation × rest pose); bind per-instance texture from `TextureCache`; push uniforms (world, lum, diff, fog flag); draw.
|
||
|
||
Reference: today's `StaticMeshRenderer.Draw` lines 79+ for the existing pattern. Match its frustum-cull behavior and pass structure (opaque + ClipMap, then translucent).
|
||
|
||
- [ ] **Step 22.4: Replace the "skip WB-managed entries" stub from Task 9**
|
||
|
||
In `StaticMeshRenderer.Draw`, replace the `if (sentinel) continue` with a call into `WbDrawDispatcher.Draw` for the entity. Or invert: have `GameWindow` call `WbDrawDispatcher.Draw` directly for atlas-tier, and `StaticMeshRenderer.Draw` only handles legacy entries.
|
||
|
||
- [ ] **Step 22.5: Run tests, smoke-test with flag on**
|
||
|
||
Run all tests. Then launch with flag on. Holtburg should now render scenery + buildings (atlas tier) AND characters (per-instance tier). Compare side-by-side to flag-off baseline.
|
||
|
||
- [ ] **Step 22.6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/StaticMeshRenderer.cs tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): WbDrawDispatcher — full atlas-tier + per-instance draw
|
||
|
||
Atlas tier: walks visible entities, gets ObjectRenderData from
|
||
WbMeshAdapter, draws each batch through the atlas. Per-instance tier:
|
||
walks animated entities, resolves AnimPartChange overrides, skips
|
||
HiddenParts, composes per-part world matrices (entity × animation ×
|
||
rest pose), looks up AcSurfaceMetadata from the side-table, pushes
|
||
sky-pass-relevant uniforms (Luminosity / Diffuse / DisableFog),
|
||
binds per-instance textures.
|
||
|
||
Replaces the Task-9 sentinel stub. With flag on, Holtburg now renders.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: Surface metadata side-table population
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` (populate side-table on each `IncrementRefCount`)
|
||
|
||
- [ ] **Step 23.1: Hook population into the adapter**
|
||
|
||
When `WbMeshAdapter.IncrementRefCount(id)` is called for the first time on an id, walk the resulting mesh data and populate `AcSurfaceMetadataTable` with one entry per (gfxObjId, surfaceIdx) using `GfxObjMesh.Build`'s metadata as the source of truth (since we're keeping that algorithm as the conformance reference).
|
||
|
||
```csharp
|
||
public void IncrementRefCount(ulong id)
|
||
{
|
||
if (_meshManager is null) return;
|
||
_meshManager.IncrementRefCount(id);
|
||
|
||
// Populate side-table on first registration.
|
||
if (!_metadataPopulated.Add(id)) return;
|
||
PopulateMetadata(id);
|
||
}
|
||
|
||
private readonly HashSet<ulong> _metadataPopulated = new();
|
||
|
||
private void PopulateMetadata(ulong id)
|
||
{
|
||
// Look up the GfxObj from the dat, run GfxObjMesh.Build with our DatCollection,
|
||
// and write each sub-mesh's metadata into _metadataTable.
|
||
if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
|
||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfxObj, _dats);
|
||
for (int i = 0; i < subMeshes.Count; i++)
|
||
{
|
||
var sm = subMeshes[i];
|
||
_metadataTable.Add(id, i, new AcSurfaceMetadata(
|
||
sm.Translucency, sm.Luminosity, sm.Diffuse,
|
||
sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
|
||
}
|
||
}
|
||
```
|
||
|
||
(`_dats` and `_metadataTable` need to be added as fields. `AcSurfaceMetadataTable` injected in constructor.)
|
||
|
||
- [ ] **Step 23.2: Add round-trip test**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void IncrementRefCount_PopulatesSideTableMetadata()
|
||
{
|
||
var (adapter, table) = MakeAdapterWithDat();
|
||
adapter.IncrementRefCount(0x01000123ul);
|
||
|
||
Assert.True(table.TryLookup(0x01000123ul, 0, out var meta));
|
||
Assert.Equal(TranslucencyKind.Opaque, meta.Translucency);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 23.3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): populate AcSurfaceMetadata side-table on first ref-count
|
||
|
||
When a gfxObj is registered for the first time, WbMeshAdapter walks
|
||
its sub-meshes via GfxObjMesh.Build and writes per-surface metadata
|
||
into the side-table keyed by (gfxObjId, surfaceIdx). Subsequent
|
||
draws resolve metadata via O(1) lookup.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Sky-pass preservation check
|
||
|
||
**Files:**
|
||
- (Verification — likely no code changes if side-table flow is right.)
|
||
|
||
- [ ] **Step 24.1: Examine SkyRenderer's metadata consumption**
|
||
|
||
Grep for `NeedsUvRepeat` / `DisableFog` / `Luminosity` usage in the sky renderer. Verify that under the WB foundation, these values still flow correctly.
|
||
|
||
Run: `grep -n "NeedsUvRepeat\|DisableFog\|Luminosity" src/AcDream.App/Rendering/Sky/`
|
||
|
||
- [ ] **Step 24.2: Smoke-test sky rendering with flag on**
|
||
|
||
Launch with `ACDREAM_USE_WB_FOUNDATION=1`. Press F7 / F10 to cycle day/night and weather. Visually confirm: clouds blend correctly, sun is bright (luminous), fog respects emissive surfaces.
|
||
|
||
- [ ] **Step 24.3: If broken, fix and commit; else, commit verification**
|
||
|
||
If the sky pass renders identically: empty commit marking verification complete.
|
||
|
||
If broken: investigate. Document an Adjustment under this task. The side-table flow is the most likely failure point.
|
||
|
||
```bash
|
||
git commit --allow-empty -m "$(cat <<'EOF'
|
||
verify(N.4): sky pass renders identically under WB foundation
|
||
|
||
NeedsUvRepeat / DisableFog / Luminosity metadata flows through the
|
||
side-table to SkyRenderer correctly. Day/night cycle + weather
|
||
visually identical to flag-off baseline.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 25: Component micro-tests round-out
|
||
|
||
**Files:**
|
||
- Test: any of the spec-defined micro-tests not yet covered.
|
||
|
||
- [ ] **Step 25.1: Audit spec's Testing section against existing tests**
|
||
|
||
Spec lists these micro-tests:
|
||
- `LandblockSpawnAdapter_RegistersAndUnregisters` ✅ (Task 11)
|
||
- `LandblockSpawnAdapter_DedupesSharedIds` ✅ (Task 11)
|
||
- `EntitySpawnAdapter_RoutesToPerInstance` ✅ (Task 17)
|
||
- `AnimPartChange_OverridesAtDraw` ✅ (Task 18)
|
||
- `HiddenParts_SuppressesDraw` ✅ (Task 19)
|
||
- `MatrixComposition_EntityAnimRest` ✅ (Task 22)
|
||
- `SurfaceMetadata_SideTableLookup` ✅ (Tasks 2 + 23)
|
||
|
||
All spec-required micro-tests are covered.
|
||
|
||
- [ ] **Step 25.2: Verify full test suite green**
|
||
|
||
Run: `dotnet test --verbosity quiet`
|
||
Expected: build green, all new tests pass, 8 pre-existing failures only.
|
||
|
||
- [ ] **Step 25.3: No commit needed unless new tests added**
|
||
|
||
---
|
||
|
||
### Task 26: Visual verification at 5 named locations + flag default-on
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs` — flip default to `true`.
|
||
- Modify: `docs/plans/2026-04-11-roadmap.md` — mark N.4 shipped.
|
||
|
||
This is the human-in-the-loop gate. Identical pattern to N.3 Task 5.
|
||
|
||
- [ ] **Step 26.1: Build + launch with flag on**
|
||
|
||
```powershell
|
||
dotnet build --verbosity quiet
|
||
$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_USE_WB_FOUNDATION = "1"
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "n4-verify.log"
|
||
```
|
||
|
||
- [ ] **Step 26.2: Visual checks — walk with the user**
|
||
|
||
Per spec's Testing section:
|
||
1. **Holtburg outdoor** — terrain props, scenery, buildings, NPCs, characters. Verify: no missing entities, no magenta squares, no alpha bleeding, no shading regressions, no animation hitches.
|
||
2. **Drudge Hideout** (or comparable) — EnvCell, interior lighting, animated creatures.
|
||
3. **Foundry** — heavy NPC traffic, customized appearances.
|
||
4. **A character with extreme palette overrides** — the +Acdream variant or any heavily-customized server character.
|
||
5. **Long roam (5+ minutes)** — GPU memory should stabilize.
|
||
|
||
- [ ] **Step 26.3: If all pass, flip default-on**
|
||
|
||
Edit `WbFoundationFlag.cs`:
|
||
```csharp
|
||
public static bool IsEnabled { get; } =
|
||
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
|
||
// was: == "1" (default off). Now: != "0" (default on).
|
||
```
|
||
|
||
- [ ] **Step 26.4: Update roadmap to mark N.4 shipped**
|
||
|
||
In `docs/plans/2026-04-11-roadmap.md`:
|
||
- Top "Live ✓" table: add a new row `| N.4 | Rendering pipeline foundation — WB ObjectMeshManager + TextureAtlasManager adopted ... | Live ✓ |`
|
||
- N.4 sub-phase block: prepend `**✓ SHIPPED — N.4 — Rendering pipeline foundation.** Shipped <date>. ...`
|
||
- Document header date bumped.
|
||
|
||
- [ ] **Step 26.5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs docs/plans/2026-04-11-roadmap.md
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): visual verification passed — flag default-on, N.4 shipped
|
||
|
||
Walked Holtburg + dungeon + Foundry + customized character + long
|
||
roam with the user. No texture regressions, no missing entities,
|
||
sky pass renders identically, GPU memory stable on long roam.
|
||
|
||
Roadmap updated to reflect N.4 in Live ✓ state. Foundation enables
|
||
N.5 (terrain), N.6 (static objects), N.7 (env cells), N.8 (sky/
|
||
particles) to land as integration phases on top.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 27: Delete legacy code paths (where safe)
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/StaticMeshRenderer.cs` — remove the legacy upload code path and the dual-path branching, since flag is now default-on.
|
||
- Modify: `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` — same.
|
||
- Note: keep these files as thin pass-through shims; **N.6 fully replaces them.**
|
||
|
||
- [ ] **Step 27.1: Remove legacy paths**
|
||
|
||
For each renderer:
|
||
- Remove the inline `UploadSubMesh` + VAO/VBO/EBO management code.
|
||
- `EnsureUploaded` becomes a thin wrapper that forwards to `WbMeshAdapter`.
|
||
- Keep public surface identical so callers don't change.
|
||
|
||
- [ ] **Step 27.2: Run tests + smoke-test**
|
||
|
||
Confirm tests green + render still correct after legacy code removal.
|
||
|
||
- [ ] **Step 27.3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/InstancedMeshRenderer.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
phase(N.4): delete legacy mesh-upload code paths
|
||
|
||
StaticMeshRenderer and InstancedMeshRenderer become thin pass-through
|
||
shims to WbMeshAdapter. N.6 will fully replace these files. Public
|
||
surface preserved so callers don't change.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 28: Update memory + ISSUES (if applicable) + finalize plan doc
|
||
|
||
**Files:**
|
||
- `memory/MEMORY.md` + new memory file if a durable lesson emerged
|
||
- `docs/ISSUES.md` if any cosmetic deltas were filed during visual verification
|
||
- `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md` — final state header
|
||
|
||
- [ ] **Step 28.1: Identify any durable lessons**
|
||
|
||
Review: did N.4 surface any lesson worth saving for future cross-session agents? Examples that would qualify:
|
||
- A subtle WB API quirk that bit us mid-implementation.
|
||
- A surprising interaction between WB's threading and our streaming.
|
||
- A non-obvious dependency between `AcSurfaceMetadata` fields and the sky-pass shader.
|
||
|
||
If yes: write a memory file under `memory/feedback_*.md` or `memory/project_phase_n4_state.md`. Add a one-liner to `MEMORY.md`.
|
||
|
||
If no durable lesson: skip.
|
||
|
||
- [ ] **Step 28.2: File any visual deltas as ISSUES**
|
||
|
||
If visual verification surfaced cosmetic regressions (e.g., a specific item renders slightly differently), file as a numbered ISSUE in `docs/ISSUES.md`.
|
||
|
||
- [ ] **Step 28.3: Mark this plan doc as final**
|
||
|
||
Update the "Plan Living-Document Convention" status line:
|
||
- From: `Status: **Living document — work in progress, started 2026-05-08.**`
|
||
- To: `Status: **Final state at <date> — phase shipped (merge `<sha>`).**`
|
||
|
||
Also mark all task checkboxes ✅ with their commit SHAs.
|
||
|
||
- [ ] **Step 28.4: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md memory/ docs/ISSUES.md CLAUDE.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs(N.4): finalize plan doc — phase complete
|
||
|
||
Status flipped to "Final state — phase shipped." All task checkboxes
|
||
marked with their commit SHAs. Memory updated with durable lessons
|
||
(or skipped if none). ISSUES updated if visual verification flagged
|
||
cosmetic deltas. CLAUDE.md "Currently in flight" pointer removed.
|
||
|
||
N.4 is shipped. Foundation is ready for N.5 / N.6 / N.7 / N.8.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
- [ ] **Step 28.5: Final merge to main**
|
||
|
||
```bash
|
||
git -C "C:\Users\erikn\source\repos\acdream" merge --no-ff claude/quirky-jepsen-fd60f1 -m "Merge branch 'claude/quirky-jepsen-fd60f1' — Phase N.4 rendering pipeline foundation"
|
||
```
|
||
|
||
Verify build + tests on main. Phase N.4 is complete.
|
||
|
||
---
|
||
|
||
## Self-review notes
|
||
|
||
This plan is intentionally pragmatic about depth:
|
||
- Tasks 1-12 are detailed with full code blocks (the foundation stuff that's most knowable today).
|
||
- Tasks 13-22 mix detailed code with structural prose (some details depend on what week 1-2 reveals about WB integration).
|
||
- Tasks 23-28 are mostly verification / cleanup with patterns established earlier.
|
||
|
||
If any task discovers a hard architectural surprise mid-execution, append an `### Adjustment N` subsection under that task with the date, what changed, and why — do not silently rewrite earlier tasks (per the Plan Living-Document Convention).
|
||
|
||
## Acceptance criteria for the whole phase
|
||
|
||
Per spec — flip each ☐ to ✅ as it lands:
|
||
|
||
- [ ] All conformance tests pass before substitution lands
|
||
- [ ] All component micro-tests pass per spec's Testing section
|
||
- [ ] All existing tests still pass (8 pre-existing failures don't count)
|
||
- [ ] Build green
|
||
- [ ] Visual verification at 5 named locations passes
|
||
- [ ] Memory budget enforcement verified under long roam
|
||
- [ ] Sky pass renders identically (load-bearing check)
|