From 9bb6b254dca8af94474a5740a119a13fcfc10a89 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 12:47:49 +0200 Subject: [PATCH 1/2] spec(N.4): rendering pipeline foundation design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopting WB's ObjectMeshManager + TextureAtlasManager as acdream's shared rendering infrastructure. Two-tier split: atlas for shared procedural content (terrain props, scenery, buildings), per-instance path for server-spawned customized entities (characters, creatures, equipped items). Animation handled by composing per-frame override matrices from our existing AnimationSequencer with cached rest poses at draw time. Cache stays valid; AnimationSequencer untouched. Streaming-loader integration: ~200 LOC adapter shim wires landblock load/unload to IncrementRefCount/DecrementRefCount; pending-spawn list mechanism preserved. Surface metadata (Translucency/Luminosity/Diffuse/SurfOpacity/ NeedsUvRepeat/DisableFog) preserved via side-table keyed by (GfxObjId, surfaceIdx) — no fork patches required. Three algorithmic conformance tests run before substitution per the N.1/N.3 pattern. Visual verification at 5 named locations. 3-4 weeks, single shippable phase. Foundation enables N.5-N.9. Co-Authored-By: Claude Opus 4.6 --- ...08-phase-n4-rendering-foundation-design.md | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md diff --git a/docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md b/docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md new file mode 100644 index 0000000..18c68b4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md @@ -0,0 +1,414 @@ +# Phase N.4 — Rendering Pipeline Foundation: Design + +**Date:** 2026-05-08 +**Status:** Design complete, awaiting plan generation. +**Parent design:** [2026-05-08-phase-n-worldbuilder-migration-design.md](2026-05-08-phase-n-worldbuilder-migration-design.md) +**Roadmap entry:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) — Phase N.4 +**Inventory reference:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md) +**Related:** [ISSUE #51](../../ISSUES.md) — terrain split formula divergence (handled in N.5). + +## Goal + +Adopt WB's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager` and +`TextureAtlasManager` as acdream's rendering pipeline foundation. This +is the integration that unblocks Phases N.5 (terrain), N.6 (static +objects), N.7 (env cells), N.8 (sky/particles), and absorbs N.10 +(GL infrastructure consolidation). N.4 ships no visible change — the +world should look identical to today; what changes is the infrastructure +behind the scenes. + +## Why + +**The roadmap's original "drop-in helper" framing was wrong for N.4.** +Discovery during brainstorm 2026-05-08: WB's `ObjectMeshManager` is not +a stateless helper class like `SceneryHelpers` (N.1) or `TextureHelpers` +(N.3). It is a 2070-line stateful asset pipeline that owns: + +- GPU resources per object (VAO/VBO/IBO via `ObjectRenderData`) +- Reference counting (`IncrementRefCount`/`DecrementRefCount`) +- LRU cache + memory budget (default 1 GB) +- Background-thread CPU mesh preparation, main-thread GPU upload +- Shared texture atlases keyed by `(Width, Height, Format)` +- Particle emitter staging +- Modern bindless rendering path on capable hardware + +**There is no clean "just the mesh extraction" entry point.** WB's +`BuildPolygonIndices` (the algorithm we already faithfully ported into +[GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs)) is a +private method tightly coupled to atlas batching. To use WB's tested +infrastructure at all means adopting the whole pipeline. + +**N.5 + N.6 + N.7 build on this foundation.** WB's +`TerrainRenderManager`, `StaticObjectRenderManager`, and +`EnvCellRenderManager` all consume `ObjectMeshManager` (or its atlas) +as substrate. Without N.4, each later phase would need to either fork +those render managers or duplicate the infrastructure. Doing N.4 now +means N.5/N.6/N.7 become integration phases on top of shared plumbing, +not parallel infrastructure builds. + +**Real benefits beyond infrastructure consolidation:** + +1. **Memory budget with LRU eviction** (we don't have this; bigger + stream radii currently risk OOM). +2. **Texture atlasing → ~4-8× fewer draw calls** for static scenery + (~1100 entities at Holtburg today). +3. **Background-thread mesh preparation** — addresses the + render-thread-stall problem from + [feedback_phase_a1_hotfix_saga.md](../../../memory/feedback_phase_a1_hotfix_saga.md) + that forced us to revert async streaming. +4. **Bindless textures** on capable hardware (free perf when + GL 4.3 + `GL_ARB_bindless_texture` are available). + +## Architecture + +### Two-tier rendering split + +acdream's content cleanly partitions into two categories that map onto +two rendering paths: + +| Tier | Content | Why this category | Path | +|---|---|---|---| +| **Atlas (shared)** | Terrain props, scenery (procedural — trees / rocks / bushes / fences from ~50 templates), buildings, slabs, dungeon static geometry | Client-side procedural; no per-instance variation; many instances of few unique meshes | WB's `ObjectMeshManager` + `TextureAtlasManager`. Big sharing wins (1100 entities ↦ ~50 atlas slots). | +| **Per-instance (customized)** | Server-spawned entities (`CreateObject`): characters, creatures, equipped items. Anything carrying `SubPalettes` / `TextureChanges` / `AnimPartChange` / `HiddenParts` / `GfxObjRemapping` | Always uniquely customized; few visible at a time (~10-50) | Existing [TextureCache.GetOrUploadWithPaletteOverride](../../../src/AcDream.App/Rendering/TextureCache.cs:122). Already hash-keys overrides for caching; already tested. | + +**Routing rule**: + +- Objects spawned by `LandblockStreamLoader` (procedural, no + customization) → atlas tier. +- Objects spawned by `CreateObject` (network, always customized) → + per-instance tier. + +The boundary mirrors a distinction that already exists in our +networking model. We are not inventing a new conceptual line; we are +matching one that's already there. + +### Animation handling + +**Core insight:** in AC, animation is per-part TRANSFORM changes, not +mesh changes. A creature's Setup is a list of rigid GfxObj parts (head, +body, hands, etc.). Each part is its own static mesh; vertices inside +each part never change. Animation moves the parts as rigid bodies. + +This means **mesh data is static even for animated entities** — the +cache works fine. Only the per-part transforms change per frame, and +those don't live in the mesh cache. + +**Composition at draw time:** + +``` +final_part_world_matrix + = entity_world_transform + × animation_override (from AnimationSequencer, this frame) + × rest_pose_transform (cached in ObjectMeshData.SetupParts) +``` + +- WB's `ObjectMeshData.SetupParts: List<(ulong GfxObjId, Matrix4x4 Transform)>` + stores the rest-pose transforms (cached, shared). +- Our existing [AnimationSequencer](../../../src/AcDream.Core/Animation/AnimationSequencer.cs) + is **untouched**. It continues to produce per-part override matrices + per frame, driven by motion table + current motion command + tick. +- The renderer composes the three matrices per part per draw and pushes + the result as a uniform/instance attribute. + +**`AnimPartChange`** (server swaps a part's GfxObj — e.g., wielding a +sword): per-entity override map `Dictionary`. +At draw time, look up override; fall back to cached Setup part. WB's +mesh manager caches the override GfxObj's mesh data the same way as +any other part — first time seen, then shared. + +**`HiddenParts`** (bitmask hiding parts): per-entity `ulong` bitmask. +Draw loop: `if (hiddenMask & (1 << partIndex)) continue;`. + +**Per-frame CPU cost:** ~50 visible animated entities × ~20 parts = +~1000 matrix multiplies per frame. Sub-millisecond on any CPU. + +**GPU-side per-draw transform push:** start with uniform-per-draw +(simple, ~1000 draws/frame for animated entities — fine). Promote to +per-instance vertex attribute (instanced draw, ~50 draws/frame) only +if measured perf demands it. + +### Streaming loader integration + +Adapter shim, ~200 LOC, sits between `LandblockStreamLoader` / +`WorldSession` and `ObjectMeshManager`: + +| Source event | Adapter call | What `ObjectMeshManager` does | +|---|---|---| +| Landblock loaded by streaming | `IncrementRefCount(id)` per unique GfxObj/Setup id in `Setups[]` + `Statics[]` | Begins CPU prep on background worker if not cached; queues GPU upload on main thread | +| Landblock unloaded by streaming (radius hysteresis) | `DecrementRefCount(id)` per object | Drops to LRU when count reaches 0; LRU + 1 GB memory budget handles eviction | +| Network `CreateObject` | Per-instance path: build `PaletteOverride` from `SubPalettes`, decode through `TextureCache.GetOrUploadWithPaletteOverride`, register entity-local mesh data | Bypasses WB atlas; stays in our existing per-instance path | +| Network `RemoveObject` | Release per-instance state for entity | (no WB call) | + +**Pending-spawn list preservation:** the streaming loader's existing +[pending-spawn list](../../../memory/feedback_phase_a1_hotfix_saga.md) +mechanism stays in place. `CreateObject` arriving before its landblock +streams in still parks until the landblock arrives, then drains. The +adapter is invoked when the spawn drains, not when it parks. + +**Thread safety:** WB's `ObjectMeshManager` uses `ConcurrentDictionary` +for its internal state and is designed to take `IncrementRefCount` calls +from any thread. Our streaming worker can call it directly without +marshaling onto the render thread. (This is part of why WB's design +addresses the render-thread-stall problem.) + +### Surface metadata strategy + +**Side-table, not fork patch.** + +WB's `MeshBatchData` carries `IsTransparent` + `IsAdditive`. We need to +preserve these acdream-specific surface properties already present in +our [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs): + +- `Translucency` (`TranslucencyKind` enum: Opaque / AlphaBlend / Additive) +- `Luminosity` (float, self-illumination coefficient — sky pass critical) +- `Diffuse` (float) +- `SurfOpacity` (float, derived from `Surface.Translucency`) +- `NeedsUvRepeat` (bool, derived from authored UV range — sky-pass wrap-mode selection) +- `DisableFog` (bool, derived from emissive surface flags — sky-pass fog skip) + +Our renderer integration maintains a side-table: +`Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>`. The +key matches the shape of today's `GfxObjSubMesh` — a (GfxObj, surface +index) pair uniquely identifies a per-surface render batch. Stable +across `IncrementRefCount` cycles. The metadata is computed once at +mesh-extraction time (matching today's `GfxObjMesh.Build`) and looked +up at draw time. + +**Why side-table not fork patch:** + +- Keeps WB's types pristine; upstream merges stay clean. +- Lookup cost is negligible (one hash lookup per batch per frame). +- Easy to roll back if WB's design evolves to incorporate similar fields. +- Preserves the careful sky-pass work done in C.1 with no risk to sky + rendering during this migration. + +### Fork hygiene + +**Target: zero fork patches for N.4.** WB's `acdream` branch stays at +upstream `master` plus the editor-only file deletions inherited from +N.0/N.1. If a fork patch becomes genuinely necessary mid-implementation +(e.g., a public hook is missing for our customization layer), it lands +as a single named patch with a comment explaining the rationale. Each +patch is candidate to upstream back to Chorizite/WorldBuilder. + +## Components + +### New code (acdream-side) + +| File | Responsibility | +|---|---| +| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | Bridges acdream's lifecycle events to `ObjectMeshManager`. Holds the `ObjectMeshManager` instance, exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` to the rest of the renderer. | +| `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | Streaming-loader hook. Walks `LandblockEntry.Setups[]` + `Statics[]`, calls `WbMeshAdapter` with unique ids. Companion `LandblockUnloadAdapter` for unload events. | +| `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | Network-spawn hook. Routes `CreateObject` to per-instance path, `RemoveObject` to release. | +| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | Side-table type holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog`. | +| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | The `Dictionary` side-table, populated at mesh-extraction time, queried at draw time. | +| `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | Per-entity render state for animated entities: `partGfxObjOverrides` map (AnimPartChange), `hiddenMask` (HiddenParts), reference to `AnimationSequencer` for per-frame override matrices. | +| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | Per-frame draw loop. For each visible entity, looks up `ObjectRenderData`, composes per-part matrices (entity × animation × rest-pose), reads side-table metadata, issues GL draw. | + +### Modified code (acdream-side) + +| File | Change | +|---|---| +| `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | Replace internal mesh-data + GL-resource handling with calls into `WbMeshAdapter`. Public surface preserved for the rest of the renderer's call sites. **N.6 will fully replace this file**; N.4 leaves it in place as a thin adapter. | +| `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | Same pattern — internal swap, public surface preserved. **N.6 fully replaces this file.** | +| `src/AcDream.App/Rendering/TextureCache.cs` | Per-instance path stays. Atlas-tier callers (anything using `GetOrUpload(surfaceId)` for static content) route through `WbMeshAdapter` instead. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep their current behavior. | +| `src/AcDream.App/Rendering/GpuWorldState.cs` | Spawn/despawn callbacks route through `WbMeshAdapter`. Pending-spawn list mechanism preserved verbatim. | +| `src/AcDream.App/Rendering/GameWindow.cs` | Construct `WbMeshAdapter` on init; dispose on shutdown. | +| `src/AcDream.Core/Meshing/SetupMesh.cs` | Kept for tests + as the conformance-test reference implementation. Production callers route through `WbMeshAdapter`. | +| `src/AcDream.Core/Meshing/GfxObjMesh.cs` | Kept for tests + conformance reference. Production callers route through `WbMeshAdapter`. | + +## Data flow + +### Spawn — landblock-streamed (atlas tier) + +``` +LandblockStreamLoader.Load(landblockId) + → LandblockEntry { Setups, Statics, ... } + → LandblockSpawnAdapter.OnLoaded(entry) + for each unique gfxObjId in (entry.Setups ∪ entry.Statics): + WbMeshAdapter.IncrementRefCount(gfxObjId) + → ObjectMeshManager.IncrementRefCount(gfxObjId) + → if not cached: queue background prep + → on prep complete: queue main-thread upload + → on upload: GL VAO/VBO/IBO ready +``` + +### Spawn — network-customized (per-instance tier) + +``` +WorldSession.OnCreateObject(msg) + → EntitySpawnAdapter.OnCreate(entity) + → build PaletteOverride from msg.SubPalettes + → for each surface needing per-instance decode: + TextureCache.GetOrUploadWithPaletteOverride(...) + → register AnimatedEntityState (override map, hidden mask, + animation sequencer reference) +``` + +### Per-frame draw (atlas tier) + +``` +WbDrawDispatcher.Draw() + for each visible atlas-tier entity: + var renderData = WbMeshAdapter.GetRenderData(entity.GfxObjId) + foreach (batch in renderData.Batches): + bind atlas, bind shader, push uniforms + foreach (part in renderData.SetupParts): + push final_part_world_matrix uniform + glDrawElements(part.indices) +``` + +### Per-frame draw (per-instance tier, animated) + +``` +WbDrawDispatcher.DrawAnimated() + for each visible animated entity: + var state = entity.AnimatedEntityState + var sequencer = entity.AnimationSequencer + sequencer.AdvanceTo(currentTime) // existing + var animOverrides = sequencer.GetCurrentPartTransforms() // existing + + foreach (partIdx in 0..parts.Count): + if (state.hiddenMask & (1 << partIdx)) continue; + var gfxObjId = state.partGfxObjOverrides.GetValueOrDefault(partIdx) ?? defaultParts[partIdx] + var renderData = WbMeshAdapter.GetRenderData(gfxObjId) + var meta = AcSurfaceMetadataTable.Lookup(renderData.BatchKey) + var worldMatrix = entityWorld × animOverrides[partIdx] × renderData.RestPose + bind per-instance texture (TextureCache lookup) + push uniforms (worldMatrix, meta.Luminosity, meta.Diffuse, ...) + glDrawElements(...) +``` + +## Testing + +### Algorithmic conformance (before substitution) + +Per the N.1 / N.3 pattern, conformance tests run BEFORE the substitution +to prove equivalence: + +| Test | Compares | +|---|---| +| `MeshExtraction_OurBuildVsWbBuildPolygonIndices` | Battery of fixture GfxObjs (varying polygon counts, stippling flags, NegUVIndices, double-sided polys). For each: our `GfxObjMesh.Build` output vs WB's `ObjectMeshManager` output (extracted via test harness). Assert: identical vertex arrays, identical index arrays, identical per-bucket surface mapping. | +| `SetupFlattening_OurFlattenVsWbSetupParts` | Battery of representative Setups (flat / hierarchical / Resting-frame / Default-frame / no-frame). For each: our `SetupMesh.Flatten` output vs WB's Setup-parts walk. Assert: identical (GfxObjId, Matrix4x4) sequences. | +| `PerInstanceDecode_OldVsNewPath` | Synthetic palette + texture overrides (mirroring real `CreateObject` data). Decoded through new integrated path vs current `TextureCache.GetOrUploadWithPaletteOverride`. Assert: identical RGBA8. | + +If any test fails it's a real divergence — investigate, do not "fix" +the test (per N.3 watchout). + +### Component micro-tests + +| Test | Covers | +|---|---| +| `LandblockSpawnAdapter_RegistersAndUnregisters` | Mock `ObjectMeshManager`; verify ref-count increments/decrements pair correctly across landblock load/unload events. | +| `LandblockSpawnAdapter_DedupesSharedIds` | Same GfxObj id appearing in multiple landblocks: verify single ref-count per landblock, not per occurrence. | +| `EntitySpawnAdapter_RoutesToPerInstance` | `CreateObject` with `SubPalettes` set: verify per-instance path taken, atlas tier not invoked. | +| `AnimPartChange_OverridesAtDraw` | Per-instance override map: verify draw loop resolves correct part GfxObj id when override present, falls back to Setup default when absent. | +| `HiddenParts_SuppressesDraw` | Bitmask: verify draw loop skips hidden parts. | +| `MatrixComposition_EntityAnimRest` | Known entity transform + animation matrix + rest pose: verify final world matrix matches expected composition order (column-major: rest applied first, then animation, then entity world). | +| `SurfaceMetadata_SideTableLookup` | Populate side-table during mesh extraction; query at draw time; verify Luminosity / Diffuse / DisableFog round-trip correctly. | + +### Visual verification (per phase, before flipping `Live ✓`) + +Walk the following with the user, comparing against pre-N.4 screenshots +or video: + +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 starter dungeon) — EnvCell + geometry, interior lighting, animated creatures. +3. **Foundry** — heavy NPC traffic, customized appearances (the + server's first-time test bed for per-instance customization + correctness). +4. **A character with extreme palette overrides** — char-creation + variant if available, otherwise a known-customized server-side + test character. +5. **Long roam** — walk for ~5 minutes across multiple landblocks, + monitor GPU memory in title bar (memory budget enforcement working + means it stabilizes; memory growing unboundedly means LRU eviction + isn't firing). + +## Phasing + +Single shippable phase — no internal sub-phases. Within the phase, work +ordered to minimize the duration of "broken in middle" state: + +| Week | Focus | "Done when" | +|---|---|---| +| 1 | WB integration plumbing + atlas bring-up for static scenery only (smallest tier, highest sharing factor) + algorithmic conformance tests pass | Conformance tests green; static scenery renders through `ObjectMeshManager` while everything else uses old path | +| 2 | Streaming-loader adapter; LRU + memory budget verified under streaming pressure (long roam + radius 7×7) | Long roam holds steady GPU memory; landblock unload reclaims memory | +| 3 | Per-instance customization path; animated creatures with palette overrides; AnimPartChange + HiddenParts | Drudge / chicken / banderling render with correct customizations; animation matches today | +| 4 | Surface metadata side-table integration; sky-pass preservation; visual verification at named locations; polish | Visual verification at all 5 locations passes; sky pass renders identically; ready for `Live ✓` | + +## Risks + +1. **Per-instance customization scope creep.** If we discover a + customization path we don't already handle in `TextureCache` (e.g., + a rare `GfxObjRemapping` case), the per-instance path may need + extension. Mitigation: enumerate all customization paths during + week 3, add tests for each before integrating. + +2. **WB threading model interaction with our streaming worker.** + `ObjectMeshManager` uses `ConcurrentDictionary` and is designed for + concurrent `IncrementRefCount` calls, but its `_pendingRequests` queue + is guarded by a `lock`. Heavy concurrent landblock loads could serialize + on this lock. Mitigation: profile during week 2; if contention is + visible, batch landblock loads to amortize the lock. + +3. **Sky pass regression.** The sky pass's `NeedsUvRepeat` / + `DisableFog` / `Luminosity` flow is fragile and load-bearing. The + side-table preserves the data, but the integration point with + `SkyRenderer` needs careful review. Mitigation: sky-pass-specific + visual verification before flipping `Live ✓`. + +4. **Bindless rendering path mismatch.** WB enables bindless when + `GL 4.3 + GL_ARB_bindless_texture` are present. If we ship through + the bindless path and a player has older hardware, fallback path + must work. Mitigation: dev/test with `_useModernRendering = false` + forced during week 1 to ensure the non-bindless path is also exercised. + +5. **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. + +## Out of scope + +- Replacing `StaticMeshRenderer` / `InstancedMeshRenderer` — those become + thin adapters in N.4 and are fully replaced in **N.6**. +- Replacing `TerrainAtlas` / `TerrainBlending` — that's **N.5**. +- Replacing EnvCell rendering — that's **N.7**. +- Replacing sky / particle rendering — that's **N.8**. +- Replacing visibility / culling — that's **N.9**. +- Per-instance customization beyond what's in today's `TextureCache` + (e.g., novel customization opcodes from future Phase F work) — out of + scope; future opcodes route through the same per-instance path. + +## Documentation impact + +- [x] [Roadmap](../../plans/2026-04-11-roadmap.md) — N.4 entry rebranded + and N.5/N.6/N.7/N.8/N.9/N.10 estimates revised (committed `6d42744` + and merged to main). +- [ ] This spec — written 2026-05-08, committing alongside. +- [ ] [worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md) + — minor update at end of N.4 to mark `ObjectMeshManager` / + `TextureAtlasManager` as "now wired up" rather than just "should + use." Not blocking N.4 start. +- [ ] [acdream-architecture.md](../../architecture/acdream-architecture.md) + — needs an acknowledging note after N.4 lands that the rendering + pipeline is WB-backed. Can follow in a later commit. + +## Reference materials + +- WB `ObjectMeshManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs` +- WB `TextureAtlasManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureAtlasManager.cs` +- WB `BaseObjectRenderManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/BaseObjectRenderManager.cs` +- ACME secondary oracle for character appearance (CreaturePalette + / GfxObjRemapping / HiddenParts behavior): + `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/StaticObjectManager.cs` +- Existing acdream code: + - [SetupMesh.cs](../../../src/AcDream.Core/Meshing/SetupMesh.cs) + - [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs) + - [TextureCache.cs](../../../src/AcDream.App/Rendering/TextureCache.cs) + - [PaletteOverride.cs](../../../src/AcDream.Core/World/PaletteOverride.cs) + - [AnimationSequencer.cs](../../../src/AcDream.Core/Animation/AnimationSequencer.cs) From 506b86ba8689c48d5ecb407dcb984624f0ee268f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 13:04:21 +0200 Subject: [PATCH 2/2] plan(N.4): full implementation plan + CLAUDE.md pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28-task plan covering 4 weeks of work organized as: - Week 1 (Tasks 1-10): WB plumbing + atlas for static scenery + conformance - Week 2 (Tasks 11-15): streaming integration + memory budget verification - Week 3 (Tasks 16-21): per-instance customization + animation - Week 4 (Tasks 22-28): full draw dispatcher + visual verification + ship Living document — task checkboxes marked as commits land; adjustments appended in-place rather than rewriting earlier tasks. Conformance tests run before substitution per N.1/N.3 pattern. Behind ACDREAM_USE_WB_FOUNDATION=1 feature flag during weeks 1-3. CLAUDE.md updated with a "Currently in flight" pointer in the Roadmap discipline section so future agents pick up the plan as authoritative for rendering work. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 10 + ...026-05-08-phase-n4-rendering-foundation.md | 2467 +++++++++++++++++ 2 files changed, 2477 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md diff --git a/CLAUDE.md b/CLAUDE.md index 1731668..5e61746 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -437,6 +437,16 @@ acdream's plan lives in two files committed to the repo: acceptance criteria. Do not drift from the spec without explicit user approval. +**Currently in flight: Phase N.4 — Rendering Pipeline Foundation.** Plan +at [`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md). +Spec at [`docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`](docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.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. After N.4 ships +this pointer is removed and the plan's status flips to "Final." + **Rules:** 1. Before starting a new phase or sub-piece, re-read the roadmap and the diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md new file mode 100644 index 0000000..7c55aba --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -0,0 +1,2467 @@ +# 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 — phase shipped (merge ``)." + +Status: **Living document — work in progress, started 2026-05-08.** + +--- + +## 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; + +/// +/// Process-lifetime cache of ACDREAM_USE_WB_FOUNDATION 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). +/// +/// +/// Set ACDREAM_USE_WB_FOUNDATION=1 to route static-scenery + atlas +/// content through WB's ObjectMeshManager; per-instance customized +/// content (server CreateObject entities) takes the existing +/// path either +/// way. Flag becomes default-on at end of Phase N.4 after visual +/// verification. +/// +/// +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 +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; + +/// +/// AC-specific surface render metadata that WB's MeshBatchData +/// doesn't carry. Computed at mesh-extraction time and looked up by the +/// draw dispatcher to drive translucency / sky-pass / fog behavior. +/// +/// +/// All fields mirror those on today's so +/// behavior is preserved bit-for-bit through the migration. +/// +/// +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; + +/// +/// Thread-safe side-table mapping (gfxObjId, surfaceIdx) to +/// . Populated when a GfxObj's mesh data +/// is extracted; queried at draw time. +/// +/// +/// Keyed by (gfxObjId, surfaceIdx) 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. +/// +/// +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 +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; + +/// +/// Conformance: our must produce the same +/// vertex-array + index-array output as WB's ObjectMeshManager +/// 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. +/// +/// +/// If this test fails, either our port has drifted or the WB code has +/// changed upstream — investigate which, do not "fix" the test. +/// +/// +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); + } + + /// + /// 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). + /// + 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 + { + 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 +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; + +/// +/// Conformance: our 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. +/// +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 +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(() => + new WbMeshAdapter(gl: null!, dats: NullDats(), logger: NullLogger.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; + +/// +/// Single seam between acdream and WB's render pipeline. Owns the +/// instance and exposes a stable acdream- +/// shaped API ( / / +/// ) so the rest of the renderer doesn't need to +/// know about WB's types directly. +/// +/// +/// Instantiated once at GameWindow init when +/// is true. When the flag is off, +/// no instance is constructed and call sites fall through to the legacy +/// renderer paths. +/// +/// +public sealed class WbMeshAdapter : System.IDisposable +{ + private readonly ObjectMeshManager? _meshManager; + private readonly OpenGLGraphicsDevice? _graphicsDevice; + private bool _disposed; + + public WbMeshAdapter(GL gl, DatCollection dats, ILogger 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; + // 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.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(); + + /// + /// 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). + /// + 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 +EOF +)" +``` + +--- + +### Task 6: `WbDatReaderAdapter` — bridge our `DatCollection` to WB's `IDatReaderWriter` + +**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(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(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(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; + +/// +/// Adapter from acdream's (vendored from +/// upstream DatReaderWriter) to the IDatReaderWriter +/// interface WB's ObjectMeshManager consumes. Pass-through where +/// possible; reshapes calls to match WB's expected interface where the +/// libraries diverge. +/// +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 +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.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 +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 +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 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(subMeshes.Count); + foreach (var sm in subMeshes) + list.Add(UploadSubMesh(sm)); + _gpuByGfxObj[gfxObjId] = list; + } + + private static readonly List 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 +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 +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 IncrementCalls { get; } = new(); + public List 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; + +/// +/// Bridges landblock streaming events to 's +/// reference-count lifecycle. Walks LoadedLandblock.Setups and +/// LoadedLandblock.Statics for unique GfxObj/Setup ids; calls +/// IncrementRefCount on load and matching DecrementRefCount +/// on unload. +/// +/// +/// Maintains a Dictionary<landblockId, HashSet<ulong>> +/// snapshot of which ids each landblock holds, so unload can match the +/// load 1:1 without re-walking the (now-released) landblock data. +/// +/// +public sealed class LandblockSpawnAdapter +{ + private readonly IWbMeshAdapter _adapter; + private readonly Dictionary> _idsByLandblock = new(); + + public LandblockSpawnAdapter(IWbMeshAdapter adapter) + { + System.ArgumentNullException.ThrowIfNull(adapter); + _adapter = adapter; + } + + public void OnLandblockLoaded(uint landblockId, LoadedLandblock lb) + { + var unique = new HashSet(); + 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 +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 +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 / 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: . Steady-state: . Eviction +fires correctly on landblock unload. LandblockSpawnAdapter ref-count +balance verified through repeated traversal. + +Co-Authored-By: Claude Opus 4.6 +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 +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 " +``` + +--- + +## 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; + +/// +/// 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). +/// +/// +/// Instances are created by EntitySpawnAdapter.OnCreate and +/// disposed by EntitySpawnAdapter.OnRemove. +/// +/// +public sealed class AnimatedEntityState +{ + private readonly Dictionary _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 +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; + +/// +/// Routes network-spawned CreateObject 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 +/// path that +/// already hash-keys overrides for caching. +/// +public sealed class EntitySpawnAdapter +{ + private readonly TextureCache _textures; + private readonly Dictionary _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 +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 +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 +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 +{ + /// + /// 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. + /// + [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 +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 +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 _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((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 +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 +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 . ...` +- 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 +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 +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 — phase shipped (merge ``).**` + +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 +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)