diff --git a/.gitignore b/.gitignore index d060c06d..755511fa 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ references/* launch.log launch-*.log launch.utf8.log +n4-verify*.log # ImGui auto-saved window/docking state (per-user, not source) imgui.ini diff --git a/CLAUDE.md b/CLAUDE.md index 17316686..88aec9b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,19 +25,54 @@ single source of truth for how the client is structured. All work must align with this document. When the architecture doc and reality diverge, update one or the other β€” never leave them out of sync. -**WorldBuilder is acdream's rendering + dat-handling base** as of -2026-05-08. Before re-implementing any AC-specific rendering or -dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md` -FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into -our fork once wired up), not from retail decomp. WorldBuilder is -MIT-licensed, verified to render the world correctly, and uses the same -Silk.NET stack we target. Re-porting from retail decomp when WB already -has a tested port is how subtle bugs (the scenery edge-vertex bug, the +**WorldBuilder is acdream's rendering + dat-handling base, integrated +as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the +production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher` +is the production draw path (default-on, see `WbFoundationFlag`). Before +re-implementing any AC-specific rendering or dat-handling algorithm, +**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If +WorldBuilder has it, port from WorldBuilder (or call into our fork via +the adapter), not from retail decomp. WorldBuilder is MIT-licensed, +verified to render the world correctly, and uses the same Silk.NET +stack we target. Re-porting from retail decomp when WB already has a +tested port is how subtle bugs (the scenery edge-vertex bug, the triangle-Z bug) keep slipping in. Retail decomp remains the oracle for network, physics, animation, movement, UI, plugin, audio, chat β€” see the inventory doc's πŸ”΄ list for the full scope of "we still write this ourselves". +**WB integration cribs:** +- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` β€” single seam over WB's + `ObjectMeshManager`. Owns the WB pipeline, drains its staged-upload + queue per frame via `Tick()`, populates `AcSurfaceMetadataTable` with + per-batch translucency / luminosity / fog metadata. +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` β€” production draw + path. Groups all visible (entity, batch) pairs, single-uploads the + matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` + per group with `BaseInstance` pointing at the slice. Per-entity + frustum cull, opaque front-to-back sort, palette-hash memoization. +- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` / + `EntitySpawnAdapter.cs` β€” bridge spawn lifecycle to WB ref-counts. + Atlas tier (procedural) goes via Landblock; per-instance tier + (server-spawned, palette/texture overrides) goes via Entity. +- `WbFoundationFlag` is default-on. `ACDREAM_USE_WB_FOUNDATION=0` + falls back to legacy `InstancedMeshRenderer` (kept as escape hatch + until N.6 fully retires it). +- **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh + into a single global VAO/VBO/IBO. Each batch references its slice + via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO). + Honor those offsets when issuing draws β€” `DrawElementsInstanced` + with `indices=0` will draw every entity's first triangle from the + global mesh, not the per-batch range. (This is exactly the + exploded-character bug we hit during Task 26.) +- **WB's `ObjectRenderBatch.SurfaceId` is unset** β€” the actual surface + id lives in `batch.Key.SurfaceId` (the `TextureKey` struct). +- **`ObjectMeshManager.IncrementRefCount` only bumps a counter** β€” it + does NOT trigger mesh loading. You must explicitly call + `PrepareMeshDataAsync(id, isSetup)` to fire the background decode. + Result auto-enqueues to `_stagedMeshData` which `Tick()` drains. + `WbMeshAdapter` does this for you on first registration. + **Execution phases:** R1β†’R8 in the architecture doc. Each phase has clear goals, test criteria, and builds on the previous. Don't skip phases. @@ -437,6 +472,20 @@ 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.5 β€” Modern Rendering Path.** Roadmap entry +at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md). +Builds on N.4's `WbDrawDispatcher` to adopt WB's modern rendering primitives: +bindless textures (eliminate `glBindTexture` calls) and +`glMultiDrawElementsIndirect` (one GL call per pass instead of one per +group). Together these target a 2-5Γ— CPU win on draw-heavy scenes by +eliminating the remaining per-group state changes. Plan + spec to be +written when work begins. + +**Phase N.4 (Rendering Pipeline Foundation) shipped 2026-05-08.** WB's +`ObjectMeshManager` is integrated and is the default rendering path +behind `ACDREAM_USE_WB_FOUNDATION` (default-on). Plan archived at +[`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md). + **Rules:** 1. Before starting a new phase or sub-piece, re-read the roadmap and the diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 464590f3..d3fd9917 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,63 @@ Copy this block when adding a new issue: # Active issues +## #51 β€” WB's terrain-split formula diverges from retail's `FSplitNESW` + +**Status:** OPEN +**Severity:** MEDIUM (blocks isolated N.2; affects sequencing of N-phase migration) +**Filed:** 2026-05-08 +**Component:** terrain math / Phase N (WorldBuilder rendering migration) + +**Description:** WB's `TerrainUtils.CalculateSplitDirection` +([references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44](references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44)) +uses a different math expression from retail's `FSplitNESW` +(documented in CLAUDE.md as **the** real AC terrain split formula, +constants `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`). +Ours is a degree-2 polynomial in (x,y); WB's is linear in (x,y). +They cannot be algebraically equivalent and disagree on a meaningful +fraction of cells. + +**Concrete impact:** On any cell where the formulas pick different +diagonals, the same world position (X, Y) maps to different terrain +heights β€” up to ~2m for a sloped cell with one elevated corner. If a +caller mixes "WB-formula path" and "AC2D-formula path" for the same +cell, the player physics floats above or sinks below the visible +ground. This is the bug class fixed in +[src/AcDream.Core/Physics/TerrainSurface.cs:113-120](src/AcDream.Core/Physics/TerrainSurface.cs:113) +(diagonal-direction inversion). + +**Files implicated:** +- `src/AcDream.Core/Physics/TerrainSurface.cs` β€” uses AC2D formula via + `IsSplitSWtoNE` +- `src/AcDream.Core/World/TerrainBlending.cs` β€” visual mesh, also AC2D +- `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44` + β€” WB's diverging formula +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs` + β€” WB's render mesh (presumably also uses WB's formula in lockstep) + +**Sequencing implication:** Phase N.2 (terrain math helpers +substitution) cannot be shipped in isolation β€” it must land alongside +N.5 (visual terrain renderer migration), at which point both physics +and visual mesh switch to WB's formula together. Roadmap N.2 entry +flags this dependency. + +**Research needed (when N.5 picks this up):** +1. Quantify divergence: run WB's `CalculateSplitDirection` and our + `IsSplitSWtoNE` across all (lbX, lbY, cellX, cellY) tuples for a + representative landblock set; record disagreement rate. +2. Confirm WB's `TerrainGeometryGenerator` uses WB's formula in its + render mesh β€” if so, switching everything to WB's formula keeps + visual + physics synced. (Highly likely.) +3. Decide whether ANY retail-conformance test (e.g., physics matching + server-authoritative Z within tolerance) is invalidated by the + formula change. + +**Acceptance:** Resolved when N.5 lands and both physics + visual +mesh use WB's split formula, OR when we decide to keep the AC2D +formula and patch WB's renderer in our fork. + +--- + ## #50 β€” Road-edge tree at 0xA9B1 visible in acdream but not retail **Status:** OPEN diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index ee78dc50..8fc303d6 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream β€” strategic roadmap -**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning. +**Status:** Living document. Updated 2026-05-08 for Phase N.4 shipping (`WbMeshAdapter` + `WbDrawDispatcher` + `ACDREAM_USE_WB_FOUNDATION` default-on) + N.5 rebranded to "Modern rendering path" (bindless + multi-draw indirect on top of N.4's foundation). **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -58,6 +58,8 @@ | L.0 | Full retail-style Settings interface β€” F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live βœ“ | | C.1 | PES particle system + sky-pass refinements β€” retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive β€” pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` β†’ alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 β†’ 1331 tests. | Live βœ“ | | N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live βœ“ | +| N.3 | WorldBuilder-backed texture decode β€” `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha β†’ `FillA8Additive`, non-additive entity surfaces β†’ `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live βœ“ | +| N.4 | Rendering pipeline foundation β€” adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6. | Live βœ“ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -573,52 +575,105 @@ for our deletions/additions; merge upstream `master` periodically. formula was ~180Β° off from retail's actual `Frame::set_heading` atan2 round-trip). One known cosmetic difference filed in ISSUES.md (road-edge tree at landblock 0xA9B1). -- **N.2 β€” Terrain math helpers.** Refactor `TerrainSurface.SampleZ` / - `SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight` - / `GetNormal` internally. ~1-2 days. Smallest remaining N phase, low - risk after N.1's conformance proof on GetNormal. -- **N.3 β€” Texture decoding.** Replace our `TextureCache` decode - pipeline (`src/AcDream.App/Rendering/TextureCache.cs`) with WB's - `TextureHelpers` (INDEX16, P8, BGRA, DXT, alpha). Touches every - texture path. **Realistic estimate: 3-5 days** (was 2-3) β€” the GL - upload path needs adapting and we'll need conformance tests per - texture format. Handoff doc: - `docs/research/2026-05-08-phase-n3-handoff.md`. -- **N.4 β€” Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs` - with calls to WB's `ObjectMeshManager`. Character-appearance - behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain - ours β€” ACME is the secondary oracle. **Realistic estimate: 1.5-2 - weeks** (was 1) β€” character appearance edge cases like N.1's - rotation bug will surface. -- **N.5 β€” Terrain rendering.** Replace `TerrainChunkRenderer` + - `TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` + - `LandSurfaceManager` + `TerrainGeometryGenerator`. **Realistic - estimate: 3-4 weeks** (was 2) β€” largest single phase, GPU-buffer - ownership shifts, integration with our streaming loader is - non-trivial. -- **N.6 β€” Static objects rendering.** Replace `StaticMeshRenderer` + - `InstancedMeshRenderer` with WB's `StaticObjectRenderManager`. - **Realistic estimate: 2-3 weeks** (was 2) β€” interacts with N.4 - output. +- **N.2 β€” Terrain math helpers.** ⚠️ **Blocked on N.5 β€” do not attempt + in isolation.** Originally scoped as a 1-2 day low-risk substitution + of `TerrainSurface.SampleZ` / `SampleSurface` / `SampleSurfacePolygon` + with WB's `TerrainUtils.GetHeight` / `GetNormal`. Audit during N.3 + follow-up uncovered that **WB's `CalculateSplitDirection` uses a + different formula than retail's `FSplitNESW`** (the AC2D-cited + polynomial `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25` + that our visual terrain mesh and physics already share). The + formulas pick different cell-diagonals on disputed cells, producing + up to ~2m Z divergence at the same world position. Substituting + physics-side alone would un-sync physics from the still-ours visual + mesh β€” exactly the triangle-Z hover bug class. N.1's conformance + test proved WB's `GetNormal` is good enough for slope-filtering + (boolean walkable check) but NOT that WB's height formula matches + retail. Resolution: fold this work into **N.5** when the visual + mesh switches to WB's renderer in lockstep with physics. Until + then, leave `TerrainSurface` alone. See ISSUE #51. +- **βœ“ SHIPPED β€” N.3 β€” Texture decoding.** Shipped 2026-05-08. `SurfaceDecoder` + now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8 to WB's + `TextureHelpers.Fill*`. The A8 divergence (our old code did R=G=B=A=val + always; WB splits additive vs non-additive) was resolved by threading an + `isAdditive` parameter through `DecodeRenderSurface`: terrain alpha masks + pass `isAdditive: true` (matches our prior behavior, preserves the + shader's `.r` blend-weight read), entity surfaces pass + `surface.Type.HasFlag(SurfaceType.Additive)`. Bonus: R5G6B5 + A4R4G4B4 + formats now decode (previously fell to magenta). X8R8G8B8, DXT1/3/5, and + SolidColor remain ours (no WB equivalent). **9 conformance tests prove + byte-identical equivalence per format** before substitution; updated + `SurfaceDecoderTests` to match the new A8 split semantics. Visual + verification at Holtburg passed 2026-05-08 β€” no texture regressions. +- **βœ“ SHIPPED β€” N.4 β€” Rendering pipeline foundation.** Shipped 2026-05-08. + WB's `ObjectMeshManager` is integrated as the production mesh pipeline + behind `ACDREAM_USE_WB_FOUNDATION=1` (default-on). The integration is + three pieces: `WbMeshAdapter` (single seam owning the WB pipeline, + drains the staged-upload queue per frame, populates + `AcSurfaceMetadataTable` for translucency / luminosity / fog), + `WbDrawDispatcher` (production draw path β€” groups all visible + (entity, batch) pairs, uploads matrices in a single `glBufferData`, + fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group + with `BaseInstance` slicing the shared instance VBO), and the + `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge that wires our + streaming loader to WB's `IncrementRefCount` / `PrepareMeshDataAsync` + lifecycle (atlas tier vs per-instance customized). + Issue #47 (close-detail mesh) preserved; sky pass structurally + independent of the WB foundation. Perf wins shipped as part of N.4: + per-entity AABB frustum cull, opaque front-to-back sort, palette-hash + memoization. Legacy `InstancedMeshRenderer` retained as flag-off + fallback until N.6 fully retires it. Plan archived at + `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`. +- **N.5 β€” Modern rendering path.** **Rebranded from "Terrain rendering" + 2026-05-08 after N.4 perf review.** N.4 left two big remaining wins + on the table that pair naturally: (1) bindless textures via + `GL_ARB_bindless_texture` (WB already populates + `ObjectRenderBatch.BindlessTextureHandle`; switch our shader to + consume per-instance handles, eliminate 100% of `glBindTexture` + calls), and (2) `glMultiDrawElementsIndirect` (one GL call per pass + instead of one per group; build a `DrawElementsIndirectCommand` + buffer, fire one indirect draw, the driver pulls everything). Both + require shader changes (same shader, in fact β€” bindless + indirect + are the same modern path WB uses internally). Together they target a + 2-5Γ— CPU win on draw-heavy scenes (Holtburg courtyard, Foundry, + dense dungeons). Also folds in: persistent-mapped instance VBO + (`glBufferStorage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT` + ring + buffer + sync) and texture pre-warm at landblock load (smooths + streaming-boundary hitches). **Estimate: 2-3 weeks.** +- **N.5b β€” Terrain rendering on N.5 path.** Wire WB's + `TerrainRenderManager` + `LandSurfaceManager` + `TerrainGeometryGenerator` + onto the modern rendering path. Closes N.2's deferred terrain math + substitution: visual mesh and physics both switch to WB's + `CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep, + resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 β€” modern path + primitives already in place from N.5). +- **N.6 β€” Static objects rendering.** Wire WB's + `StaticObjectRenderManager` onto the modern rendering path; **fully + delete** legacy `StaticMeshRenderer` + `InstancedMeshRenderer` (they + remain as `ACDREAM_USE_WB_FOUNDATION=0` escape hatches through N.5). + Mostly draw orchestration at this point β€” most of the substance + landed in N.4 + N.5. **Estimate: 1-2 weeks** (was 2-3). - **N.7 β€” EnvCells / dungeons.** Replace EnvCell rendering with WB's - `EnvCellRenderManager` + `PortalRenderManager`. **Realistic - estimate: 2-3 weeks** (was 2). + `EnvCellRenderManager` + `PortalRenderManager` on top of N.4's + foundation. **Estimate: 1-2 weeks** (was 2-3 β€” naturally smaller now + that infrastructure is shared). - **N.8 β€” Sky + particles.** Replace sky rendering + particle pipeline (#36 / C.1 work) with WB's `SkyboxRenderManager` + - `ParticleEmitterRenderer`. **Realistic estimate: 1.5-2 weeks** - (was 1) β€” visual continuity matters; we just shipped C.1 and that - work flows through here. + `ParticleEmitterRenderer`. **Estimate: ~1 week** (was 1.5-2 β€” C.1 + already shipped most of this; N.8 is glue + sampler-object reuse). - **N.9 β€” Visibility / culling.** Replace `CellVisibility` + - `FrustumCuller` with WB's `VisibilityManager`. **Realistic - estimate: 1 week** (was 3-5 days) β€” affects perf and what gets - drawn. + `FrustumCuller` with WB's `VisibilityManager`. **Estimate: ~1 week** + (was 3-5 days, slight bump for streaming-loader interaction). - **N.10 β€” GL infrastructure consolidation (optional).** Replace our `Shader` / `TextureCache` / `SamplerCache` plumbing with WB's - `ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week. + `ManagedGL*` wrappers + `OpenGLGraphicsDevice`. **Largely subsumed by + N.4** β€” `OpenGLGraphicsDevice` arrives as the host of `ObjectMeshManager` + and atlas. May not need a dedicated phase; revisit after N.6. -**Estimated calendar:** **3-4 months / 10-12 engineering weeks for -N.2-N.9 (skipping N.10).** (Was 2-3 months / 6-8 weeks β€” revised -upward after N.1 landed; realistic per-phase numbers above.) +**Estimated calendar:** **2.5-3 months / 9-13 engineering weeks for +N.4-N.9 (N.10 likely subsumed; N.2 folded into N.5; N.3 shipped).** +Revised 2026-05-08 after recognizing N.4-N.6 are one rendering rebuild +on shared infrastructure rather than three independent substitutions. **Each sub-phase:** - Ships behind `ACDREAM_USE_WB_=1` flag. diff --git a/docs/research/2026-05-08-phase-n4-week4-handoff.md b/docs/research/2026-05-08-phase-n4-week4-handoff.md new file mode 100644 index 00000000..6ab30fe1 --- /dev/null +++ b/docs/research/2026-05-08-phase-n4-week4-handoff.md @@ -0,0 +1,318 @@ +# Phase N.4 Week 4 handoff β€” full draw dispatcher + visual verification + ship + +**Use this whole document as the prompt** when handing off to a fresh +agent. Everything they need to pick up cold is below. + +--- + +## Background you'll need + +You're working in `acdream`, a from-scratch C# .NET 10 reimplementation +of Asheron's Call's retail client. The project's house rule (in +`CLAUDE.md`) is **the code is modern, the behavior is retail**. + +acdream is in the middle of Phase N.4 β€” the rendering pipeline +foundation migration to WorldBuilder's `ObjectMeshManager` + +`TextureAtlasManager`. **Three of the four planned weeks have shipped +this session (2026-05-08)**: + +- Week 1 (commits up through `c49c6ed`): foundation types β€” feature + flag, surface metadata side-table, mesh-extraction + setup-flatten + conformance tests, `WbMeshAdapter` constructed against the real WB + pipeline. +- Week 2 (commits up through `36f7a60`): streaming integration β€” + `LandblockSpawnAdapter` routes atlas-tier (procedural / `ServerGuid==0`) + GfxObjs to WB's ref-count lifecycle. `WbMeshAdapter.Tick()` drains + the WB pipeline's main-thread queues per frame (fixes a real memory + leak). +- Week 3 (commits up through `d30fcb2`): per-instance tier hookup β€” + `AnimatedEntityState` holds per-server-spawned-entity overrides; + `EntitySpawnAdapter` routes server-spawned entities through the + existing `TextureCache.GetOrUploadWithPaletteOverride` decode path. + +**Current state at `main`:** build green, **947 tests pass**, 8 +pre-existing failures only (unchanged from pre-N.4 main). Default-off +behavior is byte-identical to pre-N.4 main; flag-on (`ACDREAM_USE_WB_FOUNDATION=1`) +runs both rendering pipelines in parallel β€” WB silently prepares +content, but nothing is yet drawn through it. + +**Read first:** + +- [docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md](../superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md) β€” + the **living-document** plan. Top of file has a Progress table + showing Tasks 1-21 βœ… shipped with commit SHAs. Adjustments 1-5 + document architectural surprises caught during execution. **Read the + Adjustments before writing any Task 22 code** β€” they explain why the + current architecture is what it is. +- [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md) β€” + the design spec. Architecture / two-tier split / animation handling / + data-flow diagrams. Strategic source of truth for "how the pieces + fit together." +- [CLAUDE.md](../../CLAUDE.md) β€” project-wide rules. The "Currently in + flight" section near the top points at the plan. + +## What Week 4 is + +Seven tasks (22-28). **Task 22 alone is the biggest single task in the +entire 28-task plan** β€” it's the moment we flip from "WB is silently +preparing content" to "WB is drawing content to your screen." + +The remaining six tasks are smaller: surface-metadata side-table +population, sky-pass preservation check, micro-tests round-out, visual +verification at 5 named locations, flag default-on, delete legacy +code, finalize plan + memory + ISSUES. + +**Task 22 also unlocks the Adjustment 3 mitigation.** Right now +flag-on has a real FPS regression because both rendering pipelines run +in parallel (legacy renderer still does atlas-tier upload + draw, +even though WB is also building atlas state). When Task 22 lands the +dispatcher AND wires the legacy-renderer short-circuit for atlas-tier +content, that double-work disappears. + +## Two unresolved decisions before Task 22 starts + +These need a brainstorm checkpoint at the start of Week 4, NOT a +"just dispatch": + +1. **Adjustment 4 plumbing.** `WorldEntity` doesn't carry + `HiddenPartsMask` or `AnimPartChanges` β€” those live on the + network-layer spawn record and don't make it to the render-side + entity. Two options: + - **A**: add `HiddenPartsMask` + `AnimPartChanges` fields to + `WorldEntity`, populate at spawn time. Cleaner long-term; small + networkβ†’render plumbing change. + - **B**: thread them as separate parameters into + `EntitySpawnAdapter.OnCreate(entity, hiddenMask, animPartChanges)`. + Sidesteps the `WorldEntity` change but couples the spawn-handler + to the adapter API. + + Decide before writing Task 22 because the dispatcher reads from + `AnimatedEntityState` which currently holds defaults (empty mask + + empty override map). Without this resolved, hidden parts won't + actually be hidden flag-on. + +2. **Surface-metadata side-table population strategy** (Task 23). The + spec proposes: when `WbMeshAdapter.IncrementRefCount(id)` is first + called for a GfxObj, walk its sub-meshes via `GfxObjMesh.Build`, + write each `(gfxObjId, surfaceIdx) β†’ AcSurfaceMetadata` entry into + the side-table. The `_metadataPopulated: HashSet` field + tracks which ids have been processed. + + **But:** if the same GfxObj gets its ref count drop to zero and + then re-incremented (LRU eviction + reload), do we re-populate? + The metadata is invariant per-GfxObj (surface flags don't change + with eviction), so probably no β€” the `HashSet` is fine. But + verify before implementing. + +## Watchouts (lessons from Weeks 1-3) + +These are real, observed gotchas. Read each before going deeper. + +- **The renderer is tier-blind by design (Adjustment 2).** Don't try + to put routing decisions in `InstancedMeshRenderer` or any mesh + uploader. Routing belongs at the **spawn-callback layer**: + `LandblockSpawnAdapter` for atlas-tier, `EntitySpawnAdapter` for + per-instance. Task 22's dispatcher reads from those adapters' + per-entity state at draw time; it doesn't make tier decisions. + +- **Flag-off must stay byte-identical to pre-N.4.** Every Task-22 code + path must have a `WbFoundationFlag.IsEnabled` gate. Default-off path + is what users see; we can't regress it. + +- **WB's pipeline does work even when you're not draining its results.** + Adjustment 3: `IncrementRefCount` triggers background mesh prep, + texture decode, atlas allocation. `WbMeshAdapter.Tick()` already + drains the upload queue per frame. The remaining FPS cost is + pure dual-pipeline cost (legacy + WB doing the same upload work). + Task 22's short-circuit fixes this. + +- **`MeshRef.SurfaceOverrides`** is the per-surface texture-swap data + carried by spawned entities. `GfxObjSubMesh.SurfaceId` is what gets + swapped. Task 22's draw loop must consult both: the entity's + `MeshRef.SurfaceOverrides` for explicit swaps, and otherwise the + mesh's built-in `SurfaceId`. + +- **Conformance tests catch divergences early.** Per N.1's rotation + bug: write the conformance test BEFORE the substitution. The + matrix-composition test (`(entityWorld) Γ— (animation) Γ— (restPose)`) + is the load-bearing one for Task 22 β€” pin it before integrating. + +- **`WbMeshAdapter.Tick()` is required.** It's already wired into + `GameWindow.OnRender`. Task 22's dispatcher needs the upload queue + drained BEFORE it tries to draw, so order in OnRender is: + `_wbMeshAdapter?.Tick()` β†’ `_wbDrawDispatcher?.Draw(...)` β†’ other + draw work. + +- **Name retail decomp first; Phase N.4 doesn't change that rule.** + Task 22's matrix composition uses standard graphics math β€” no AC- + specific algorithms β€” so the "grep `named-retail/` first" workflow + doesn't apply to the matrix code itself. But for any AC-specific + question that surfaces during integration (e.g., "does retail + render hidden parts as zero-alpha or skip them entirely?"), grep + `docs/research/named-retail/acclient_2013_pseudo_c.txt` first. + +## Acceptance criteria for Week 4 + +From the plan: + +- [ ] All conformance tests pass (Tasks 3, 4, 20 β€” already shipped; + verify still green after Task 22 lands). +- [ ] All component micro-tests pass (Tasks 11, 17, 18, 19, 22 β€” + Task 22 adds matrix-composition tests). +- [ ] All existing tests still pass. 8 pre-existing failures don't + count. +- [ ] Build green throughout. +- [ ] Visual verification at 5 named locations passes: + 1. Holtburg outdoor β€” terrain props, scenery, buildings, NPCs, + characters all render correctly. + 2. Drudge Hideout (or comparable) β€” EnvCell + interior lighting + + animated creatures. + 3. Foundry β€” heavy NPC traffic + customized appearances. + 4. A character with extreme palette overrides. + 5. Long roam (5+ minutes) β€” GPU memory stabilizes (LRU eviction + fires). +- [ ] Memory budget enforcement actually verified (Task 13 was + deferred to here; Task 22 makes it testable because GL resources + finally get allocated for LRU to evict). +- [ ] Sky pass renders identically (load-bearing β€” sky's + `Translucent+ClipMap` cloud sheet, raw-`Additive` fog skip, + `Luminosity` keyframe handling all flow through the side-table + via `AcSurfaceMetadata`). +- [ ] Flag flipped to default-on at the end (Task 26). +- [ ] Legacy code paths deleted (Task 27). +- [ ] Roadmap + memory + ISSUES updated (Task 28). + +## Tasks 22-28 β€” quick map + +Full detail is in the plan. Brief here: + +- **22 β€” `WbDrawDispatcher` full draw loop.** ~1-2 days. Atlas-tier + + per-instance-tier draw with matrix composition. Reads from + `WbMeshAdapter.GetRenderData(id)` for atlas content; reads from + `EntitySpawnAdapter.GetState(serverGuid)` for per-instance state; + composes per-part `(entity Γ— animation Γ— rest-pose)` matrices; + pushes uniforms; issues GL draws. **Also wires the legacy- + renderer short-circuit** for atlas-tier content (the Adjustment 3 + fix). +- **23 β€” Surface-metadata side-table population.** ~half day. Hook + into `WbMeshAdapter.IncrementRefCount` so that on first registration + of a GfxObj, the side-table gets populated with one + `AcSurfaceMetadata` per surfaceIdx (using `GfxObjMesh.Build`'s + metadata as the source of truth). +- **24 β€” Sky-pass preservation check.** ~half day. Verify the sky + pass's `NeedsUvRepeat` / `DisableFog` / `Luminosity` flow through + the side-table to `SkyRenderer` correctly. Likely no code change; + smoke-test sky rendering with flag on, weather/day-night cycle. +- **25 β€” Component micro-tests round-out.** Audit existing tests + against the spec's Testing section. Probably nothing to add since + Tasks 11/17/18/19/22 already cover the listed micro-tests. +- **26 β€” Visual verification + flag default-on.** Human-in-the-loop + walk through the 5 named locations. If clean, flip + `WbFoundationFlag.IsEnabled` from `== "1"` to `!= "0"` so flag-on + becomes the default. +- **27 β€” Delete legacy code paths.** Remove the now-unused legacy + upload code in `StaticMeshRenderer` + `InstancedMeshRenderer`. + N.6 fully replaces these files anyway. +- **28 β€” Update roadmap + memory + ISSUES + finalize plan.** Mark + N.4 shipped in the roadmap's Live βœ“ table. File any cosmetic + deltas as ISSUES. Add a memory note if a durable lesson emerged. + Flip the plan's status header from "Living document β€” work in + progress" to "Final state β€” phase shipped (merge ``)". + +## Where to start + +1. **Read the three "Read first" docs above end-to-end.** Especially + the Adjustments section in the plan β€” those are the architectural + constraints Task 22 must respect. +2. **Decide Adjustment 4 plumbing** (option A vs B from above). This + is a small brainstorm checkpoint, not a multi-question + `superpowers:brainstorming` skill invocation. Document the choice + inline in the plan as Adjustment 6. +3. **Don't create a new worktree.** The existing branch + `claude/quirky-jepsen-fd60f1` and worktree + `.claude/worktrees/quirky-jepsen-fd60f1` are clean and ready. + Submodule already initialized. Build green. +4. **Use `superpowers:subagent-driven-development`** to execute Week 4 + task-by-task. Pattern from Weeks 1-3: dispatch one subagent per + task (or batch of related tasks), use Sonnet for implementation, + merge to main per logical chunk, update the plan's Progress table + as commits land. +5. **Pause for visual verification at Task 26.** This is a human-in- + the-loop step β€” needs you to walk the 5 named locations. + +## Open questions a fresh agent might hit + +- **Q: Why did Adjustment 5 mark Task 20 (per-instance decode + conformance) as "structural"?** Because both old and new paths call + the same `TextureCache.GetOrUploadWithPaletteOverride` function. We + preserved the decode logic exactly; the seam is at the call site, + not at the algorithm. Byte-equality is automatic. + +- **Q: Can I delete `InstancedMeshRenderer`?** Not in N.4. The plan + marks it as "becomes a thin adapter in N.4, fully replaced in N.6." + Task 27 deletes the legacy upload paths inside it but keeps the + file as a draw-orchestration adapter until N.6. + +- **Q: What's the memory budget check actually checking?** GPU memory + stabilizes during long roam. WB's `_maxGpuMemory = 1 GB` triggers + LRU eviction once the cache exceeds that. We verify by walking + for 5+ minutes at radius 7 (49 landblocks visible at any time) and + confirming GPU memory in the title bar plateaus rather than + growing unboundedly. + +- **Q: What happens if Task 22 takes longer than expected?** The + living-document convention says document Adjustments inline. If + Task 22 needs to split (e.g., atlas-tier draw lands first, per- + instance tier in a follow-on commit), that's fine β€” just update + the Progress table and add an Adjustment explaining the split. + +## Useful greps and commands + +- `dotnet build --verbosity quiet 2>&1 | tail -3` β€” quick build check. +- `dotnet test --verbosity quiet 2>&1 | tail -3` β€” full test suite. +- `git -C C:\Users\erikn\source\repos\acdream log --oneline -10` β€” + recent main commits. +- `grep -rn "WbFoundationFlag.IsEnabled" src/` β€” every place we gate + on the flag (audit before flipping default-on in Task 26). +- `grep -rn "_wbMeshAdapter\|_wbSpawnAdapter\|_wbEntitySpawnAdapter" src/` β€” + every WB adapter wiring point. + +## Smoke-test launch (PowerShell) + +```powershell +# Kill any stale processes first +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 4 + +# Flag-on at radius 7 β€” Week 4 dev environment +$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" +$env:ACDREAM_STREAM_RADIUS = "7" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "n4-week4-smoke.log" +``` + +(Drop the `ACDREAM_USE_WB_FOUNDATION` line for flag-off comparison.) + +## Adjustments index β€” quick reference + +For full text, see the plan document (each is a `### Adjustment N` +subsection under Task 6's old position, in chronological order): + +1. **`DefaultDatReaderWriter` discovery** (2026-05-08) β€” no + dat-reader bridge needed; WB ships a usable concrete + implementation. +2. **Renderer is tier-blind** (2026-05-08) β€” routing belongs at + spawn callbacks, not in the renderer. +3. **FPS regression = dual-pipeline cost** (2026-05-08) β€” both + pipelines run in parallel until Task 22's short-circuit lands. +4. **`WorldEntity` lacks HiddenParts/AnimPartChange fields** + (2026-05-08) β€” plumbing deferred; Task 22 needs to resolve + (option A: add fields; option B: thread as separate args). +5. **Task 20 is structural** (2026-05-08) β€” same function called + both paths, byte-equality automatic, no test file needed. diff --git a/docs/research/2026-05-08-phase-n5-handoff.md b/docs/research/2026-05-08-phase-n5-handoff.md new file mode 100644 index 00000000..1c4d7bed --- /dev/null +++ b/docs/research/2026-05-08-phase-n5-handoff.md @@ -0,0 +1,495 @@ +# Phase N.5 β€” Modern Rendering Path β€” Cold-Start Handoff + +**Created:** 2026-05-08, immediately after N.4 ship. +**Audience:** the next agent picking up rendering perf work. +**Purpose:** give you everything you need to start N.5 cold, without +spelunking through five months of session history. + +--- + +## TL;DR + +N.4 just shipped: WB's `ObjectMeshManager` is now acdream's production +mesh pipeline, and `WbDrawDispatcher` is the production draw path. It +works (Holtburg renders correctly, FPS substantially improved over the +naΓ―ve dual-pipeline state we hit during week 4 verification) but it's +still doing per-group state changes (`glBindTexture`, `glBindBuffer` +for the IBO, `glDrawElementsInstancedBaseVertexBaseInstance` per group) +and a fresh `glBufferData` upload per frame. + +**N.5's job: lift the dispatcher onto WB's modern rendering primitives +that we're already paying GPU-feature-detection cost for.** Two big +wins, paired: + +1. **Bindless textures** (`GL_ARB_bindless_texture`) β€” WB already + populates `ObjectRenderBatch.BindlessTextureHandle`. Switch our + shader to read texture handles from a per-instance attribute + (`uvec2` β†’ `sampler2D` via the bindless extension). Eliminates + 100% of `glBindTexture` calls. +2. **Multi-draw indirect** (`glMultiDrawElementsIndirect`) β€” build a + buffer of `DrawElementsIndirectCommand` structs (one per group), + upload once, fire ONE `glMultiDrawElementsIndirect` call per pass. + The driver pulls everything from the indirect buffer. + +Together they target a 2-5Γ— CPU win on draw-heavy scenes (Holtburg +courtyard, Foundry, dense dungeons). They're packaged together because +both are "modern path" extensions we already gate on, both require +the same shader rewrite, and they pair naturally β€” multi-draw indirect +is a no-op CPU-win without bindless because per-group `glBindTexture` +calls would still serialize. + +**Estimated scope: 2-3 weeks.** Plan + spec to be written by the +brainstorm + spec steps below. + +--- + +## Where N.4 left things + +### Branch state + +If this handoff is being read on `main` after merging the N.4 worktree: +N.4 commits land at the head of main. The relevant final commits: + +- `c445364` β€” N.4 SHIP (flag default-on, plan final, roadmap, memory) +- `573526d` β€” perf pass 1-4 (drop dead lookup, sort, cull, hash memo) +- `7b41efc` β€” FirstIndex/BaseVertex + Issue #47 + grouped instanced +- `943652d` β€” load triggers + `batch.Key.SurfaceId` source +- `01cff41` β€” Tasks 22+23 (`WbDrawDispatcher` + side-table) + +If the worktree branch (`claude/tender-mcclintock-a16839`) hasn't been +merged yet, that's where the work is. Verify with `git log --oneline`. + +### What works in N.4 + +- `ACDREAM_USE_WB_FOUNDATION=1` is default-on. WB's `ObjectMeshManager` + loads, decodes, and uploads every entity mesh. Our existing + `TextureCache` decodes textures (palette-aware, per-instance overrides + via `GetOrUploadWithPaletteOverride`). +- `WbDrawDispatcher.Draw`: + - Walks visible entities (per-landblock AABB cull + per-entity AABB + cull + portal visibility) + - Buckets every (entity Γ— meshRef Γ— batch) tuple by + `GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency)` + - Single `glBufferData` upload of all matrices for the frame + - Per group: `glActiveTexture(0) + glBindTexture(2D, handle) + glBindBuffer(EBO, ibo) + glDrawElementsInstancedBaseVertexBaseInstance(..., FirstInstance)` + - Two passes: opaque (front-to-back sorted) + translucent +- 940/948 tests pass (8 pre-existing failures unrelated to rendering). +- Visual verification at Holtburg passed: scenery + characters render + correctly with full close-detail geometry (Issue #47 preserved). + +### What N.5 inherits + +These are levers N.5 will pull on: + +- **WB's modern rendering is already active.** `OpenGLGraphicsDevice` + detected GL 4.3 + bindless on first run; WB's `_useModernRendering` + is true; every mesh lives in WB's single `GlobalMeshBuffer` (one VAO, + one VBO, one IBO). +- **Bindless handles are already populated.** `ObjectRenderBatch.BindlessTextureHandle` + is non-zero for batches WB owns the texture for. (See gotcha #2 + below for entities with palette overrides β€” those use acdream's + `TextureCache` which doesn't expose bindless handles yet.) +- **The instance VBO is acdream-owned** (`WbDrawDispatcher._instanceVbo`) + with locations 3-6 patched onto WB's global VAO. Stride 64 bytes + (one mat4). N.5 expands this to (mat4 + uvec2 handle) = 80 bytes. + +### Three load-bearing WB API gotchas N.4 surfaced + +These bit us hard during Task 26 visual verification. Documented in +CLAUDE.md "WB integration cribs" + plan adjustments 7-9 + +`memory/project_phase_n4_state.md`. Re-stating here because they +reshape the design space: + +1. **`ObjectMeshManager.IncrementRefCount(id)` is NOT lifecycle-aware.** + It only bumps a usage counter. Mesh loading is fired separately + via `PrepareMeshDataAsync(id, isSetup)`. The result auto-enqueues + to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`); our + existing `WbMeshAdapter.Tick()` drains it. `WbMeshAdapter.IncrementRefCount` + already calls `PrepareMeshDataAsync`. **N.5 doesn't need to change + this β€” just don't break it.** + +2. **`ObjectRenderBatch.SurfaceId` is unset.** WB constructs batches + with `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct + that has a `SurfaceId` field) but never populates the top-level + `SurfaceId` property. Read `batch.Key.SurfaceId`. **N.5 keeps this + pattern.** + +3. **WB's modern rendering packs every mesh into ONE global + VAO/VBO/IBO.** Each batch's `IBO` field points to the global IBO; + the batch's actual slice is identified by `FirstIndex` (offset into + IBO, in *indices*) and `BaseVertex` (offset into VBO, in *vertices*). + N.4's draw uses `glDrawElementsInstancedBaseVertexBaseInstance` + with those offsets. **N.5's `DrawElementsIndirectCommand` per-group + record will carry `firstIndex` + `baseVertex` for the same reason.** + +--- + +## What N.5 is β€” technical detail + +### The two-feature pairing + +**Bindless textures** (`GL_ARB_bindless_texture`): +- Each texture handle is a 64-bit integer (`uvec2` in GLSL). +- Shader declares `layout(bindless_sampler) uniform sampler2D ...` or + receives the handle as a per-vertex-attribute `uvec2`. +- No `glBindTexture` needed at draw time β€” the handle IS the binding. +- Handle generation: `glGetTextureHandleARB(textureId)` followed by + `glMakeTextureHandleResidentARB(handle)` (the texture must be + resident on the GPU; non-resident handles produce GPU faults). + +**Multi-draw indirect** (`glMultiDrawElementsIndirect`): +- Indirect command struct layout (must match `DrawElementsIndirectCommand`): + ```c + struct { + uint count; // index count for this draw + uint instanceCount; // number of instances + uint firstIndex; // offset into IBO, in indices + int baseVertex; // vertex offset into VBO + uint baseInstance; // first instance ID (offsets per-instance attribs) + }; + ``` +- Build a buffer of N of these structs (one per group), upload once, + fire one GL call: `glMultiDrawElementsIndirect(mode, indexType, ptr, drawcount, stride)`. +- The driver issues all N draws in one shot. Effectively zero CPU + overhead per draw beyond uploading the indirect buffer. + +**Why pair them.** Multi-draw indirect doesn't let you change uniform +state between draws. So if textures are bound via `glBindTexture` per +group, you'd still need N CPU-side setup steps before each indirect +call β€” defeating the purpose. Bindless removes that constraint by +encoding the texture handle as per-instance data the shader reads +directly. With both, the modern render loop becomes: + +``` +1. Upload instance buffer (mat4 + uvec2 handle, per-instance) β€” once per frame +2. Upload indirect command buffer (one DEIC per group) β€” once per frame +3. glBindVertexArray(globalVAO) β€” once +4. glMultiDrawElementsIndirect(...) β€” ONCE per pass +``` + +That's it. No per-group state changes. + +### Instance attribute layout + +Currently (N.4): location 3-6 = mat4 model matrix (16 floats = 64 bytes). + +N.5 (proposed): location 3-6 = mat4 + location 7 = uvec2 bindless +handle = 16 floats + 2 uints = 72 bytes (16-aligned to 80 bytes per +WB's `InstanceData` precedent). + +Or use std140-aligned struct: +```c +struct InstanceData { + mat4 transform; // locations 3-6 + uvec2 textureHandle; // location 7 + uvec2 _pad; // padding to 80 +}; +``` + +Brainstorm should decide if we copy WB's `InstanceData` struct (Pack=16, +80 bytes including CellId/Flags fields we don't use) or define our own +minimal version. The 80-byte stride matches WB's so global VAO state +configured by WB stays compatible if the legacy WB draw path ever runs. + +### Per-instance entity texture handles + +Here's the wrinkle. N.4 uses `WbDrawDispatcher.ResolveTexture` to map +each (entity, batch) to a GL texture handle: + +- Tree (no overrides): `_textures.GetOrUpload(surfaceId)` β†’ 2D texture handle +- NPC with palette override: `_textures.GetOrUploadWithPaletteOverride(...)` β†’ composite-cached 2D texture handle +- Anything with surface override: `_textures.GetOrUploadWithOrigTextureOverride(...)` β†’ composite-cached 2D texture handle + +Those are all `GLuint` 32-bit GL texture *names*, not bindless handles. +**N.5 needs `TextureCache` to publish bindless handles for everything +it owns, not just WB-owned textures.** + +Implementation sketch: +- `TextureCache` adds a parallel cache keyed identically but storing + 64-bit bindless handles. On first request, generate via + `glGetTextureHandleARB(textureId)` + make resident. +- New API: `GetBindlessHandle(uint surfaceId, ...)` returns the handle. +- Or: change every `GetOrUpload*` method to return both the GL name + and the bindless handle (or just the handle; let GL name fall out + if anyone needs it later). + +WB's `ObjectRenderBatch.BindlessTextureHandle` covers the atlas-tier +case. For per-instance entities, we use `TextureCache`'s handle. + +### The new shader + +Reuse WB's `StaticObjectModern.vert` / `StaticObjectModern.frag` as a +template. Read those files cold. They already do bindless + the +instance-data layout. Adapt to acdream's `mesh_instanced.vert/frag` +conventions: + +- Keep the `uViewProjection` uniform, lighting UBO at binding=1, fog + uniforms. +- Add `#version 430 core` + `#extension GL_ARB_bindless_texture : require`. +- Replace `uniform sampler2D uDiffuse` with a `uvec2` per-vertex + attribute (location 7) β†’ reconstruct sampler in vertex shader OR + pass through to fragment via flat varying. +- Drop `uTranslucencyKind` uniform, OR keep it (still set per-pass β€” + multi-draw indirect doesn't break uniforms; only state that varies + per-draw is the constraint). + +### Translucency + +Multi-draw indirect can't change blend state mid-draw. Solution: +**still use two passes** (opaque + translucent), but within translucent +keep the per-blendfunc sub-passes (additive, alpha-blend, inv-alpha). +Three sub-passes within translucent. Each sub-pass = one +`glMultiDrawElementsIndirect` over its filtered groups. + +Or: if perf allows, fold all four blend modes into the shader via +per-instance blendmode int, sort all translucent groups by blendmode +in the indirect buffer, switch blend state at sub-pass boundaries. +Brainstorm decides the cleanest pattern. + +--- + +## Files to read before brainstorming + +In rough order: + +1. **N.4 plan + spec** β€” `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md` + (status: Final). Adjustments 7-10 capture the gotchas. Spec at + `docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`. + +2. **N.4 dispatcher source** β€” `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`. + This is what you're modifying. Read end-to-end. + +3. **WB's modern rendering shaders** β€” `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObjectModern.vert` + + `StaticObjectModern.frag`. The template you're adapting from. + +4. **WB's `ObjectMeshManager.UploadGfxObjMeshData`** β€” lines ~1654-1780 + of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`. + Shows how WB sets up the modern path's VBO/IBO/VAO. Especially note + how it patches in instance attribute slots (locations 3-6) on the + global VAO and configures location 7+ for bindless handles. + +5. **WB's `ObjectRenderBatch`** β€” same file, lines ~166-184. Note the + `BindlessTextureHandle` field β€” already populated when `_useModernRendering` + is on. + +6. **Our `TextureCache`** β€” `src/AcDream.App/Rendering/TextureCache.cs`. + Three composite caches: by surface id, by surface+origTex, by + surface+origTex+palette. N.5 adds parallel bindless-handle caches. + +7. **CLAUDE.md "WB integration cribs"** section. Lines ~28-80. The + three gotchas + the integration architecture in plain language. + +8. **Memory: `project_phase_n4_state.md`** β€” same content from a + different angle. Reading both helps lock in the gotchas. + +--- + +## Brainstorm questions + +These are the questions to resolve in the brainstorm step. Don't +prejudge them β€” bring them to the user with options + recommendation: + +1. **Instance attribute layout.** Match WB's `InstanceData` struct + (80 bytes including CellId/Flags fields we don't use) for global + VAO compatibility, or define a minimal acdream-specific version + (mat4 + handle = ~72 bytes padded to 80)? + +2. **Bindless handle generation strategy.** + - At texture upload time? (Eager β€” every texture that lands in + `TextureCache` gets a handle. Memory cost ~per-texture state.) + - On first draw lookup? (Lazy β€” cache fills as scene exercises + content. Possible first-use stall.) + - At spawn time via the spawn adapter? (Tied to lifecycle. Cleanest + but requires touching the spawn path.) + +3. **Translucent pass structure.** Three sub-indirect-draws (one per + blend mode) or a single sorted indirect buffer with per-instance + blend mode + state-flip at sub-pass boundaries? Or: just iterate + per-group like N.4 for translucent only (translucent groups are a + small fraction of total)? + +4. **Persistent-mapped indirect + instance buffers.** Use + `GL_ARB_buffer_storage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT`? + Triple-buffered ring + sync object? Or stick with `glBufferData` + (still one upload per frame, just larger)? Persistent mapping is + ~2-5% per-frame win in our context but adds buffer-management + complexity. + +5. **Shader unification.** Keep `mesh_instanced` for legacy + add + `mesh_indirect` for modern, or replace `mesh_instanced` entirely? + Replacement requires the legacy `InstancedMeshRenderer` (escape + hatch under `ACDREAM_USE_WB_FOUNDATION=0`) to also use the new + shader, which... probably doesn't matter if we delete legacy in + N.6 anyway. Brainstorm. + +6. **Conformance test strategy.** N.4 used visual verification at + Holtburg as the gate. N.5's gate is "no visual regression vs N.4 + AND measurable CPU win." How do we measure CPU? `[WB-DIAG]` + counters give draw count + group count; we need frame-time + counters too. Add to the dispatcher? Use a profiler? + +7. **Per-instance entity bindless.** `TextureCache.GetOrUpload*` + returns a GL name. The dispatcher (or `TextureCache` itself) needs + to convert that to a bindless handle. Design questions: + - Where does the conversion happen? + - When is the texture made resident? (Residency is global state; + too many resident textures hits driver limits.) + - What about palette/surface overrides β€” same caching key as the + name, just a parallel handle dictionary? + +8. **Escape hatch.** N.4 keeps `ACDREAM_USE_WB_FOUNDATION=0` as a + fallback. N.5 needs to decide: does the new shader REPLACE the + N.4 dispatcher's draw path (so flag-on means N.5 modern path, + flag-off means legacy `InstancedMeshRenderer`)? Or do we add a + separate flag (`ACDREAM_USE_MODERN_DRAW`) so users can toggle + N.4 vs N.5 vs legacy independently? Three-way flag is more + complex but useful for A/B during rollout. + +--- + +## Spec structure + +After the brainstorm, the spec doc covers: + +1. **Architecture diagram** β€” how `WbDrawDispatcher` changes shape. + Where the indirect buffer lives. Where bindless handles flow from. +2. **Instance data layout** β€” exact struct, byte offsets, GL attribute + pointer setup. +3. **TextureCache changes** β€” new methods, new cache, residency + policy. +4. **Shader files** β€” name(s), version, extensions, in/out variables. +5. **Conformance tests** β€” what to write, what coverage to claim. +6. **Acceptance criteria** β€” visual identity to N.4 + measured CPU + delta. +7. **Risks** β€” driver bugs in bindless / indirect, residency limits, + shader compile issues on weird GPUs, the legacy escape hatch + breaking. + +Spec lives at: `docs/superpowers/specs/2026-05-XX-phase-n5-modern-rendering-design.md`. + +## Plan structure + +After the spec, the plan doc lays out the week-by-week task list. +Match N.4's plan structure (living document, task checkboxes, commit +SHAs appended, adjustments documented inline). Plan lives at: +`docs/superpowers/plans/2026-05-XX-phase-n5-modern-rendering.md`. + +Suggested initial breakdown (brainstorm + spec will refine): + +- **Week 1** β€” Plumbing: bindless handle generation in `TextureCache`, + shader rewrite (compile + bind), instance-attrib layout updated to + mat4+handle. Dispatcher still uses per-group draws but reads + textures bindless. Validate: visual identical to N.4. +- **Week 2** β€” Indirect: build `DrawElementsIndirectCommand` buffer + per frame, switch to `glMultiDrawElementsIndirect`. Three-pass + translucent (or whatever brainstorm decides). Validate: visual + identical, draw-call count drops to 2-4 per frame. +- **Week 3** β€” Polish + ship: persistent-mapped buffers if brainstorm + voted yes, profiler/counters, visual verification, flag flip, plan + finalization. + +--- + +## Acceptance criteria for the whole phase + +- Visual output identical to N.4 (no character regressions, no + scenery missing, no z-fighting introduced) +- `[WB-DIAG]` shows `drawsIssued` ≀ ~5 per frame (down from N.4's + few hundred) +- Frame time measurably lower in dense scenes (specify what scenes + to test in the spec β€” probably Holtburg courtyard + Foundry + interior) +- All tests still green (940/948 + any new conformance tests) +- `ACDREAM_USE_WB_FOUNDATION=0` escape hatch still works +- Plan doc finalized, roadmap updated, memory captured if N.5 + surfaces durable lessons (it almost certainly will β€” bindless + + indirect both have well-known driver gotchas) + +--- + +## What you'll be doing in the first 30 minutes + +1. Read this handoff in full. +2. Read CLAUDE.md "WB integration cribs" section. +3. Read `WbDrawDispatcher.cs` end-to-end. +4. Skim WB's `StaticObjectModern.vert/frag` + `ObjectMeshManager.UploadGfxObjMeshData` + to ground the reference. +5. Verify build is green: `dotnet build`. +6. Verify N.4 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|MatrixComposition"` + should produce 60 passing tests, 0 failures. +7. Invoke the `superpowers:brainstorming` skill with the user. Walk + through the 8 brainstorm questions above. Capture decisions in a + spec. +8. Write the spec at the path above. +9. Write the plan at the path above. +10. Begin Week 1 implementation per the plan. + +Don't skip the brainstorm. Multi-draw indirect + bindless have several +real driver-compatibility / API-shape decisions that need user input, +not "the agent makes a call and goes." This phase is structurally the +same shape as N.4 β€” brainstorm β†’ spec β†’ plan β†’ tasks-with-checkboxes β†’ +commits-update-checkboxes β†’ final SHIP commit. + +--- + +## Things to NOT do + +- **Don't delete the legacy `InstancedMeshRenderer`.** It's the N.4 + escape hatch. N.6 retires it after N.5 is proven default-on. +- **Don't fork WB.** N.4 deliberately avoided fork patches by using + the side-table pattern (`AcSurfaceMetadataTable`). Stay on that + path. If you need data WB doesn't expose, add a side-table or + decode it yourself from dats. +- **Don't try to make per-instance entities use WB's `TextureAtlasManager`.** + That's N.6+ territory. acdream's `TextureCache` owns palette/surface + overrides because WB's atlas is keyed by `(surfaceId, paletteId, + stippling, isSolid)` and our overrides don't fit cleanly. Bindless + handles let us escape that mismatch β€” handles for both atlas-tier + AND per-instance-tier textures, no atlas adoption needed. +- **Don't skip visual verification.** N.4 surfaced three bugs at + visual verification that no test caught. Don't trust "build green + + tests pass" β€” exercise the rendering path with the local ACE server. +- **Don't extend the phase scope.** N.5 is bindless + indirect on + the existing rendering pipeline. Texture array atlas, GPU-side + culling, terrain wiring β€” all of those are subsequent phases. If + the brainstorm tries to expand, push back. + +--- + +## Reference: the N.4 dispatcher flow you're modifying + +``` +Draw(camera, landblockEntries, frustum, ...) { + // Phase 1: walk entities, build groups + foreach (entity, meshRef, batch) { + cull, classify into _groups[GroupKey] + } + + // Phase 2: lay matrices contiguously + // Phase 3: glBufferData(_instanceVbo, allMatrices) + // Phase 4: bind global VAO once + // Phase 5: opaque pass (sorted) + foreach (group in _opaqueDraws) { + glBindTexture(group.handle) + glBindBuffer(EBO, group.ibo) + glDrawElementsInstancedBaseVertexBaseInstance(...) + } + // Phase 6: translucent pass +} +``` + +After N.5, Phases 5 and 6 collapse to: + +``` +glBindBuffer(DRAW_INDIRECT_BUFFER, _opaqueIndirect) +glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, 0, opaqueGroups.Count, sizeof(DEIC)) +glBindBuffer(DRAW_INDIRECT_BUFFER, _translucentIndirect) +// 3 sub-calls for translucent or 1 if shader-folded +glMultiDrawElementsIndirect(...) +``` + +That's the destination. Get there cleanly. + +Good luck. Holler at the user if any of the brainstorm questions feel +genuinely ambiguous after reading the references β€” they care about +this phase landing right and will engage on design questions. diff --git a/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md b/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md new file mode 100644 index 00000000..57d6f26f --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md @@ -0,0 +1,721 @@ +# Phase N.3 β€” Texture Decoding via WorldBuilder 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:** Replace acdream's hand-rolled pixel-format decoders in `SurfaceDecoder` with calls to WorldBuilder's `TextureHelpers.Fill*` methods for every format WB covers (INDEX16, P8, A8R8G8B8, R8G8B8, A8, A8Additive, R5G6B5, A4R4G4B4). Keep our decoders for formats WB lacks (X8R8G8B8, DXT1/3/5 with clipmap postprocess, SolidColor with translucency). Add conformance tests proving byte-identical output for each substituted format. Add the two previously-unsupported formats (R5G6B5, A4R4G4B4) as a bonus. + +**Architecture:** In-place substitution inside `SurfaceDecoder`. Each private `Decode*` method that has a WB equivalent gets rewritten to allocate a `byte[]`, call `TextureHelpers.Fill*` into it, and return a `DecodedTexture`. The critical A8 divergence is resolved by adding an `isAdditive` parameter to `DecodeRenderSurface` β€” callers that know the `SurfaceType` pass it, terrain alpha callers (which always use the additive/replicate path) pass `isAdditive: true`. No feature flag β€” conformance tests prove equivalence before substitution, so the old code is deleted in the same pass. + +**Tech Stack:** .NET 10 / C# 13, `Chorizite.OpenGLSDLBackend` (already referenced via `AcDream.Core.csproj`), `DatReaderWriter` for `RenderSurface` / `Palette` / `PixelFormat` types, `BCnEncoder.Net` for DXT (stays ours), xUnit for tests. + +**Spec:** `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md` +**Inventory:** `docs/architecture/worldbuilder-inventory.md` +**Handoff:** `docs/research/2026-05-08-phase-n3-handoff.md` + +**Prerequisite:** Phase N.0 shipped (submodule wired), Phase N.1 shipped (scenery migration). `AcDream.Core.csproj` already references `Chorizite.OpenGLSDLBackend`. + +--- + +## Audit Summary + +| # | Our function | WB equivalent | Action | +|---|---|---|---| +| 1 | `DecodeIndex16` | `TextureHelpers.FillIndex16` | **Substitute** | +| 2 | `DecodeP8` | `TextureHelpers.FillP8` | **Substitute** | +| 3 | `DecodeA8R8G8B8` | `TextureHelpers.FillA8R8G8B8` | **Substitute** | +| 4 | `DecodeR8G8B8` | `TextureHelpers.FillR8G8B8` | **Substitute** | +| 5 | `DecodeA8` | `TextureHelpers.FillA8` + `FillA8Additive` | **Substitute** (additive-aware) | +| 6 | `DecodeX8R8G8B8` | None | **Keep ours** | +| 7 | `DecodeBc` (DXT1/3/5) | None in TextureHelpers | **Keep ours** | +| 8 | `DecodeSolidColor` | Different semantics | **Keep ours** | +| 9 | (missing) | `TextureHelpers.FillR5G6B5` | **Add new** | +| 10 | (missing) | `TextureHelpers.FillA4R4G4B4` | **Add new** | + +### A8 divergence detail + +- **Our current `DecodeA8`:** R=G=B=A=val (all four channels = alpha byte) +- **WB `FillA8`:** R=G=B=255, A=val (white + alpha) +- **WB `FillA8Additive`:** R=G=B=A=val (same as our current behavior) + +WB dispatches based on `surface.Type.HasFlag(SurfaceType.Additive)`: +- Additive surfaces β†’ `FillA8Additive` (R=G=B=A=val) +- Non-additive surfaces β†’ `FillA8` (R=G=B=255, A=val) + +Our current code always does the additive path. This is correct for terrain alpha masks (used as blend weights where `.r` channel = `.a` channel matters) but diverges from WB for non-additive A8 entity textures. Resolution: thread an `isAdditive` flag through the decode API. + +--- + +## File Plan + +| File | Disposition | Responsibility | +|---|---|---| +| `src/AcDream.Core/Textures/SurfaceDecoder.cs` | MODIFY | Replace 5 private decode methods with WB `TextureHelpers.Fill*` calls. Add `isAdditive` parameter to `DecodeRenderSurface`. Add R5G6B5 + A4R4G4B4 format cases. Keep X8R8G8B8, DXT, SolidColor. | +| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | Pass `surface.Type.HasFlag(SurfaceType.Additive)` as `isAdditive` to `SurfaceDecoder.DecodeRenderSurface`. | +| `src/AcDream.App/Rendering/TerrainAtlas.cs` | MODIFY | Pass `isAdditive: true` to `SurfaceDecoder.DecodeRenderSurface` in `TryDecodeAlphaMap` (terrain alpha masks always use the replicate-all-channels path). | +| `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs` | NEW | Per-format conformance tests: synthetic byte arrays decoded by both our old logic and WB's `TextureHelpers.Fill*`, asserting byte-identical output. | + +--- + +## Task 1: Conformance tests for the 5 clean substitutions + +Write tests first, run them to prove our current output matches WB's output for each format. These tests lock in the equivalence BEFORE any code changes β€” if any test fails, we know the formats actually diverge and must investigate. + +**Files:** +- Create: `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs` + +- [ ] **Step 1.1: Create the conformance test file with INDEX16 test** + +Create `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`: + +```csharp +using Chorizite.OpenGLSDLBackend.Lib; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Textures; + +/// +/// Conformance tests proving WorldBuilder's TextureHelpers.Fill* methods +/// produce byte-identical output to our SurfaceDecoder private methods +/// for each pixel format. These tests run BEFORE the substitution β€” if +/// one fails, the formats diverge and we must investigate, not "fix" the test. +/// +public sealed class TextureDecodeConformanceTests +{ + [Fact] + public void FillIndex16_MatchesOurDecodeIndex16() + { + // 2x2 INDEX16 texture: 4 pixels, each a 16-bit LE palette index. + // Palette: index 0 = (R=10, G=20, B=30, A=255), index 1 = (R=40, G=50, B=60, A=200) + // Pixel data: [0x0000, 0x0100, 0x0100, 0x0000] (indices 0, 1, 1, 0) + byte[] src = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00]; + int w = 2, h = 2; + + var palette = new Palette(); + palette.Colors.Add(new ColorARGB { Red = 10, Green = 20, Blue = 30, Alpha = 255 }); + palette.Colors.Add(new ColorARGB { Red = 40, Green = 50, Blue = 60, Alpha = 200 }); + + // Our decode + byte[] ours = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + int si = i * 2; + ushort idx = (ushort)(src[si] | (src[si + 1] << 8)); + var c = palette.Colors[idx]; + int di = i * 4; + ours[di + 0] = c.Red; + ours[di + 1] = c.Green; + ours[di + 2] = c.Blue; + ours[di + 3] = c.Alpha; + } + + // WB decode + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h); + + Assert.Equal(ours, wb); + } + + [Fact] + public void FillIndex16_ClipMap_MatchesOurClipMapBehavior() + { + // Index 3 (< 8) should be transparent, index 10 should be normal + byte[] src = [0x03, 0x00, 0x0A, 0x00]; + int w = 2, h = 1; + + var palette = new Palette(); + for (int i = 0; i < 16; i++) + palette.Colors.Add(new ColorARGB { Red = (byte)(i * 10), Green = (byte)(i * 15), Blue = (byte)(i * 5), Alpha = 255 }); + + // Our clipmap decode: index < 8 β†’ all zeros + byte[] ours = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + int si = i * 2; + ushort idx = (ushort)(src[si] | (src[si + 1] << 8)); + int di = i * 4; + if (idx < 8) + { + ours[di] = ours[di + 1] = ours[di + 2] = ours[di + 3] = 0; + } + else + { + var c = palette.Colors[idx]; + ours[di + 0] = c.Red; + ours[di + 1] = c.Green; + ours[di + 2] = c.Blue; + ours[di + 3] = c.Alpha; + } + } + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h, isClipMap: true); + + Assert.Equal(ours, wb); + } + + [Fact] + public void FillP8_MatchesOurDecodeP8() + { + // 2x2 P8 texture: 4 pixels, each a single-byte palette index. + byte[] src = [0, 1, 1, 0]; + int w = 2, h = 2; + + var palette = new Palette(); + palette.Colors.Add(new ColorARGB { Red = 100, Green = 110, Blue = 120, Alpha = 255 }); + palette.Colors.Add(new ColorARGB { Red = 200, Green = 210, Blue = 220, Alpha = 180 }); + + byte[] ours = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + var c = palette.Colors[src[i]]; + int di = i * 4; + ours[di + 0] = c.Red; + ours[di + 1] = c.Green; + ours[di + 2] = c.Blue; + ours[di + 3] = c.Alpha; + } + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillP8(src, palette, wb.AsSpan(), w, h); + + Assert.Equal(ours, wb); + } + + [Fact] + public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8() + { + // 2x1 A8R8G8B8: on-disk order is B, G, R, A per pixel + byte[] src = [0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD]; + int w = 2, h = 1; + + // Our decode: swap B,G,R,A β†’ R,G,B,A + byte[] ours = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + int s = i * 4; + ours[s + 0] = src[s + 2]; // R + ours[s + 1] = src[s + 1]; // G + ours[s + 2] = src[s + 0]; // B + ours[s + 3] = src[s + 3]; // A + } + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillA8R8G8B8(src, wb.AsSpan(), w, h); + + Assert.Equal(ours, wb); + } + + [Fact] + public void FillR8G8B8_MatchesOurDecodeR8G8B8() + { + // 2x1 R8G8B8: on-disk order is B, G, R per pixel (3 bytes) + byte[] src = [0x10, 0x20, 0x30, 0xAA, 0xBB, 0xCC]; + int w = 2, h = 1; + + // Our decode: swap B,G,R β†’ R,G,B,255 + byte[] ours = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + int si = i * 3; + int di = i * 4; + ours[di + 0] = src[si + 2]; // R + ours[di + 1] = src[si + 1]; // G + ours[di + 2] = src[si + 0]; // B + ours[di + 3] = 0xFF; + } + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillR8G8B8(src, wb.AsSpan(), w, h); + + Assert.Equal(ours, wb); + } + + [Fact] + public void FillA8Additive_MatchesOurDecodeA8() + { + // 4x1 A8: each byte replicated to all four channels (our current behavior) + byte[] src = [0x00, 0x80, 0xFF, 0x42]; + int w = 4, h = 1; + + byte[] ours = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + byte a = src[i]; + int d = i * 4; + ours[d + 0] = a; + ours[d + 1] = a; + ours[d + 2] = a; + ours[d + 3] = a; + } + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillA8Additive(src, wb.AsSpan(), w, h); + + Assert.Equal(ours, wb); + } + + [Fact] + public void FillA8_NonAdditive_ProducesWhitePlusAlpha() + { + // WB's non-additive A8: R=G=B=255, A=val + // This is DIFFERENT from our current DecodeA8 (which does R=G=B=A=val). + // This test documents the WB behavior we're adopting for non-additive surfaces. + byte[] src = [0x00, 0x80, 0xFF, 0x42]; + int w = 4, h = 1; + + byte[] expected = new byte[w * h * 4]; + for (int i = 0; i < w * h; i++) + { + int d = i * 4; + expected[d + 0] = 255; + expected[d + 1] = 255; + expected[d + 2] = 255; + expected[d + 3] = src[i]; + } + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillA8(src, wb.AsSpan(), w, h); + + Assert.Equal(expected, wb); + } + + [Fact] + public void FillR5G6B5_ProducesExpectedRgba() + { + // R5G6B5: 16-bit packed RGB. Not currently handled by our decoder. + // White (0xFFFF) β†’ R=248,G=252,B=248,A=255 (bit expansion truncation) + // Black (0x0000) β†’ R=0,G=0,B=0,A=255 + byte[] src = [0xFF, 0xFF, 0x00, 0x00]; + int w = 2, h = 1; + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillR5G6B5(src, wb.AsSpan(), w, h); + + // Pixel 0: white-ish + Assert.Equal(248, wb[0]); // R: 31 << 3 + Assert.Equal(252, wb[1]); // G: 63 << 2 + Assert.Equal(248, wb[2]); // B: 31 << 3 + Assert.Equal(255, wb[3]); // A + + // Pixel 1: black + Assert.Equal(0, wb[4]); + Assert.Equal(0, wb[5]); + Assert.Equal(0, wb[6]); + Assert.Equal(255, wb[7]); + } + + [Fact] + public void FillA4R4G4B4_ProducesExpectedRgba() + { + // A4R4G4B4: 16-bit packed ARGB. Not currently handled by our decoder. + // 0xF8C4 β†’ A=15*17=255, R=8*17=136, G=12*17=204, B=4*17=68 + byte[] src = [0xC4, 0xF8]; + int w = 1, h = 1; + + byte[] wb = new byte[w * h * 4]; + TextureHelpers.FillA4R4G4B4(src, wb.AsSpan(), w, h); + + Assert.Equal(136, wb[0]); // R: ((0xF8C4 >> 8) & 0x0F) * 17 = 8*17 + Assert.Equal(204, wb[1]); // G: ((0xF8C4 >> 4) & 0x0F) * 17 = 12*17 + Assert.Equal(68, wb[2]); // B: (0xF8C4 & 0x0F) * 17 = 4*17 + Assert.Equal(255, wb[3]); // A: ((0xF8C4 >> 12) & 0x0F) * 17 = 15*17 + } +} +``` + +- [ ] **Step 1.2: Run tests to verify they pass** + +Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal` + +Expected: All 9 tests PASS. These tests compare our current algorithm inline against WB's `TextureHelpers` β€” if any fail, it means the algorithms actually diverge and we must investigate before proceeding. + +- [ ] **Step 1.3: Commit** + +``` +git add tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs +git commit -m "test(N.3): conformance tests proving WB TextureHelpers matches our decode + +Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8, +A8Additive (matches our current DecodeA8), A8 non-additive (documents +the divergence), R5G6B5, A4R4G4B4. All run before any substitution β€” +they prove equivalence, not test the substitution. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 2: Add `isAdditive` parameter to SurfaceDecoder and wire A8 split + +Thread the `isAdditive` flag through the decode API so the A8 format can dispatch to either WB path. Update all three callers. + +**Files:** +- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs` +- Modify: `src/AcDream.App/Rendering/TextureCache.cs` +- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs` + +- [ ] **Step 2.1: Add `isAdditive` parameter to `DecodeRenderSurface`** + +In `src/AcDream.Core/Textures/SurfaceDecoder.cs`, change the main public overload signature from: + +```csharp +public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false) +``` + +to: + +```csharp +public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) +``` + +And update the `PFID_A8`/`PFID_CUSTOM_LSCAPE_ALPHA` case in the switch from: + +```csharp +PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs), +``` + +to: + +```csharp +PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive), +``` + +And update the no-palette overload from: + +```csharp +public static DecodedTexture DecodeRenderSurface(RenderSurface rs) + => DecodeRenderSurface(rs, palette: null); +``` + +to: + +```csharp +public static DecodedTexture DecodeRenderSurface(RenderSurface rs) + => DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false); +``` + +- [ ] **Step 2.2: Split `DecodeA8` into additive vs non-additive** + +In `SurfaceDecoder.cs`, change the `DecodeA8` method signature and add the split: + +```csharp +private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive) +{ + int expected = rs.Width * rs.Height; + if (rs.SourceData.Length < expected) + return DecodedTexture.Magenta; + + var rgba = new byte[expected * 4]; + if (isAdditive) + { + // Additive: R=G=B=A=val (current behavior, matches WB FillA8Additive) + for (int i = 0; i < expected; i++) + { + byte a = rs.SourceData[i]; + int d = i * 4; + rgba[d + 0] = a; + rgba[d + 1] = a; + rgba[d + 2] = a; + rgba[d + 3] = a; + } + } + else + { + // Non-additive: R=G=B=255, A=val (matches WB FillA8) + for (int i = 0; i < expected; i++) + { + int d = i * 4; + rgba[d + 0] = 255; + rgba[d + 1] = 255; + rgba[d + 2] = 255; + rgba[d + 3] = rs.SourceData[i]; + } + } + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 2.3: Update TextureCache to pass `isAdditive`** + +In `src/AcDream.App/Rendering/TextureCache.cs`, in `DecodeFromDats`, change line 203 from: + +```csharp +return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap); +``` + +to: + +```csharp +bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive); +return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive); +``` + +- [ ] **Step 2.4: Update TerrainAtlas to pass `isAdditive: true`** + +In `src/AcDream.App/Rendering/TerrainAtlas.cs`, in `TryDecodeAlphaMap`, change line 322 from: + +```csharp +var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); +``` + +to: + +```csharp +var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true); +``` + +The terrain alpha masks MUST use the additive path (R=G=B=A=val) because our terrain blending shader reads from `.r` for the blend weight. + +- [ ] **Step 2.5: Build and test** + +Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal` + +Expected: Build green, all 9 conformance tests still pass. + +- [ ] **Step 2.6: Commit** + +``` +git add src/AcDream.Core/Textures/SurfaceDecoder.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/Rendering/TerrainAtlas.cs +git commit -m "refactor(N.3): thread isAdditive through A8 decode path + +SurfaceDecoder.DecodeRenderSurface now accepts isAdditive parameter. +A8/CUSTOM_LSCAPE_ALPHA format splits: +- isAdditive=true: R=G=B=A=val (terrain alpha, additive entity textures) +- isAdditive=false: R=G=B=255, A=val (non-additive entity textures) + +TextureCache passes surface.Type.HasFlag(SurfaceType.Additive). +TerrainAtlas passes isAdditive:true (alpha masks always replicate). +This aligns with WB ObjectMeshManager's dispatch logic. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 3: Substitute 5 decode methods with WB TextureHelpers calls + +Replace the body of each private decode method with a call to the corresponding WB `TextureHelpers.Fill*` method. Add the two new format cases (R5G6B5, A4R4G4B4). + +**Files:** +- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs` + +- [ ] **Step 3.1: Add WB using directive** + +At the top of `SurfaceDecoder.cs`, add: + +```csharp +using Chorizite.OpenGLSDLBackend.Lib; +``` + +- [ ] **Step 3.2: Replace `DecodeIndex16`** + +Replace the body of `DecodeIndex16` with: + +```csharp +private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap) +{ + int expectedBytes = rs.Width * rs.Height * 2; + if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 3.3: Replace `DecodeP8`** + +Replace the body of `DecodeP8` with: + +```csharp +private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap) +{ + int expectedBytes = rs.Width * rs.Height; + if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 3.4: Replace `DecodeA8R8G8B8`** + +Replace the body of `DecodeA8R8G8B8` with: + +```csharp +private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs) +{ + int expected = rs.Width * rs.Height * 4; + if (rs.SourceData.Length < expected) + return DecodedTexture.Magenta; + + var rgba = new byte[expected]; + TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 3.5: Replace `DecodeR8G8B8`** + +Replace the body of `DecodeR8G8B8` with: + +```csharp +private static DecodedTexture DecodeR8G8B8(RenderSurface rs) +{ + int expectedBytes = rs.Width * rs.Height * 3; + if (rs.SourceData.Length < expectedBytes) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 3.6: Replace `DecodeA8`** + +Replace the body of `DecodeA8` with: + +```csharp +private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive) +{ + int expected = rs.Width * rs.Height; + if (rs.SourceData.Length < expected) + return DecodedTexture.Magenta; + + var rgba = new byte[expected * 4]; + if (isAdditive) + TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + else + TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 3.7: Add R5G6B5 and A4R4G4B4 cases to the format switch** + +In the `DecodeRenderSurface` switch, add two new cases before the `_ => DecodedTexture.Magenta` default: + +```csharp +PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs), +PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs), +``` + +And add the two new private methods: + +```csharp +private static DecodedTexture DecodeR5G6B5(RenderSurface rs) +{ + int expectedBytes = rs.Width * rs.Height * 2; + if (rs.SourceData.Length < expectedBytes) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} + +private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs) +{ + int expectedBytes = rs.Width * rs.Height * 2; + if (rs.SourceData.Length < expectedBytes) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); +} +``` + +- [ ] **Step 3.8: Build and run all tests** + +Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet` + +Expected: Build green, 873+ tests pass, 8 pre-existing failures unchanged. + +- [ ] **Step 3.9: Commit** + +``` +git add src/AcDream.Core/Textures/SurfaceDecoder.cs +git commit -m "phase(N.3): substitute 5 decode methods with WB TextureHelpers + +INDEX16, P8, A8R8G8B8, R8G8B8, A8 now delegate to +TextureHelpers.FillIndex16/FillP8/FillA8R8G8B8/FillR8G8B8/ +FillA8/FillA8Additive. Validation + DecodedTexture wrapping stays ours. +X8R8G8B8, DXT1/3/5, SolidColor remain our implementations (no WB equiv). + +Bonus: R5G6B5 + A4R4G4B4 formats now handled (previously fell to magenta). + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 4: Update roadmap + ISSUES, final cleanup + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` β€” mark N.3 shipped +- Modify: `docs/ISSUES.md` β€” file any cosmetic deltas found + +- [ ] **Step 4.1: Update roadmap** + +In the roadmap, update the Phase N.3 entry to show shipped status with today's date and commit hash (obtain from `git log -1 --format='%h'`). + +- [ ] **Step 4.2: File any ISSUES** + +If the A8 non-additive behavioral change surfaces any visual delta at Holtburg during verification, file it in `docs/ISSUES.md`. Example: + +```markdown +### #NN: A8 non-additive textures now render white+alpha instead of gray+alpha + +**Status:** OPEN +**Phase:** N.3 +**Symptom:** [describe if applicable] +**Root cause:** WB's FillA8 outputs R=G=B=255,A=val; our old DecodeA8 output R=G=B=A=val. For non-additive surfaces this is a behavioral change. +**Impact:** [assess after visual verification] +``` + +If no visual delta is observed, skip this step β€” no issue to file. + +- [ ] **Step 4.3: Commit** + +``` +git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md +git commit -m "docs: mark Phase N.3 shipped, update ISSUES if applicable + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 5: Visual verification (human-in-the-loop) + +This task requires the user to launch the client and inspect textures at Holtburg. + +- [ ] **Step 5.1: Build and launch** + +```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" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" +``` + +- [ ] **Step 5.2: Visual checks** + +Walk around Holtburg and verify: +1. **Terrain textures** β€” grass, dirt, sand transitions look correct (not magenta, not discolored) +2. **Tree/bush textures** β€” scenery objects textured correctly (clipmap alpha works) +3. **Building textures** β€” walls, roofs, doors look right +4. **Sky/clouds** β€” if A8 textures are involved, verify they still render +5. **Particles** β€” rain/aurora if weather is active + +If all look correct, N.3 is done. If regressions found, file in ISSUES.md per the handoff doc's "whackamole stops the migration" rule. 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 00000000..4b4e4018 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -0,0 +1,2737 @@ +# 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: **Final state at 2026-05-08 β€” phase shipped.** All tasks +complete; `ACDREAM_USE_WB_FOUNDATION` flipped default-on. Visual +verification at Holtburg passed. Three bugs surfaced + resolved during +Task 26 are documented as Adjustments 7-9 below and as gotchas in +CLAUDE.md. Followup work moves to N.5 (modern rendering path: bindless ++ multi-draw indirect). + +**Progress (2026-05-08):** Weeks 1 + 2 + 3 βœ… COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural β€” same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only. + +**Next: Task 22** (Week 4) β€” `WbDrawDispatcher` full draw loop. The first task that actually draws through WB and unlocks the dual-pipeline-cost mitigation from Adjustment 3. + +| Task | Status | Commit | +|---|---|---| +| 1 β€” WbFoundationFlag scaffold | βœ… | `81b5ed8` | +| 2 β€” AcSurfaceMetadata + Table | βœ… | `46deed6` | +| 3 β€” Mesh-extraction conformance | βœ… | `ed73fc5` | +| 4 β€” Setup-flatten conformance | βœ… | `ed73fc5` | +| 5 β€” WbMeshAdapter stub + IWbMeshAdapter | βœ… | (post-`ed73fc5`) | +| 6 β€” WbDatReaderAdapter | βœ… OBSOLETED (Adj. 1) | `502c3a8` | +| 7 β€” GameWindow wiring under flag | βœ… | `502c3a8` | +| 8 β€” CLAUDE.md pointer | βœ… | `506b86b` (preemptive) | +| 9 β€” Real WB pipeline + InstancedMeshRenderer routing | βœ… partial / Adj. 2 reverted | `4ad7a98` + `4f318bc` | +| 10 β€” Week 1 wrap-up | βœ… | `c49c6ed` | +| 11 β€” LandblockSpawnAdapter | βœ… | `669768d` | +| 12 β€” Wire into GpuWorldState | βœ… | `931a690` | +| 13 β€” Memory budget verification | βœ… deferred to Task 22 (Adj. 3) | β€” | +| 14 β€” Pending-spawn integration test | βœ… | `f4f0101` | +| Tick β€” drain WB pipeline queues | βœ… added per Adj. 3 | `bf53cb4` | +| 15 β€” Week 2 wrap-up | βœ… | `36f7a60` | +| 16+18+19 β€” AnimatedEntityState + AnimPartChange + HiddenParts | βœ… | `ce72c57` | +| 17 β€” EntitySpawnAdapter | βœ… + Adj. 4 | `c02c307` | +| 20 β€” Per-instance decode conformance | βœ… structural (Adj. 5) | (no test file) | +| 21 β€” Week 3 wrap-up | βœ… | (this commit) | +| 22+23 β€” WbDrawDispatcher + side-table population | βœ… | `01cff41` | +| 22+23 fixup β€” load triggers + SurfaceId source | βœ… | `943652d` | +| 22+23 perf β€” FirstIndex/BaseVertex + #47 + grouped instanced | βœ… | `7b41efc` | +| 22+23 perf 1-4 β€” drop dead lookup, sort, cull, hash memo | βœ… | `573526d` | +| 24 β€” Sky-pass preservation check | βœ… structural (independent) | `5df9135` | +| 25 β€” Component micro-tests round-out | βœ… all spec tests covered | β€” | +| 26 β€” Visual verification + flag default-on | βœ… | (this commit) | +| 27 β€” Delete legacy code paths | ⚠️ deferred to N.6 (legacy retained as flag-off escape hatch) | β€” | +| 28 β€” Update memory + ISSUES + finalize plan | βœ… | (this commit) | + +--- + +## 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~~ β€” OBSOLETED 2026-05-08 + +**Adjustment 1 (2026-05-08):** discovered during pre-Task-6 grep that +WB ships `WorldBuilder.Shared.Services.DefaultDatReaderWriter`, a +concrete `IDatReaderWriter` implementation that takes a dat-directory +path and constructs all four databases (Portal / HighRes / Language + +CellRegions) internally. We can instantiate it directly with the same +`%USERPROFILE%\Documents\Asheron's Call` path acdream's `DatCollection` +uses; both will open the same dat files with separate handles. Memory +cost: ~50-100 MB of duplicate index caches, acceptable for foundation +work. Task 9 incorporates the construction step directly. + +If memory pressure surfaces during week 2 stress testing, revisit by +writing a real bridge that shares index caches with our `DatCollection`. + +**No work for this task β€” skip and proceed to Task 7.** + +--- + +### Adjustment 2 (2026-05-08): Task 9 routing reverted β€” tier decision belongs at spawn-callback layer + +**Discovered during Week 1 visual smoke test**: with flag on, characters / +NPCs disappeared along with static scenery. Root cause: Task 9 routed +**all** `InstancedMeshRenderer.EnsureUploaded` calls through +`WbMeshAdapter.IncrementRefCount` and marked their cache entries with +`WbManagedSentinel`. But `InstancedMeshRenderer` is used for both tiers +in production: + +- **Atlas-tier** call sites: `_pendingCellMeshes` drain + ([GameWindow.cs:5137](../../../src/AcDream.App/Rendering/GameWindow.cs:5137)), + per-MeshRef GfxObj loop on `lb.Entities` + ([:5155](../../../src/AcDream.App/Rendering/GameWindow.cs:5155)). +- **Per-instance-tier** call sites: per-part loop in spawn handling + ([:2302](../../../src/AcDream.App/Rendering/GameWindow.cs:2302)) β€” this is + character / creature rendering driven by server `CreateObject`. + +The renderer is **tier-blind by design**: it doesn't know spawn source. +Putting routing logic there violates separation of concerns. The spec's +Data-Flow section already specifies the right placement β€” routing happens +at the **spawn-callback layer**: + +- `LandblockSpawnAdapter.OnLandblockLoaded(...)` (Task 11) calls + `IncrementRefCount` per unique GfxObj β€” atlas-tier only. +- `EntitySpawnAdapter.OnCreate(entity)` (Task 17) routes through + per-instance path (`TextureCache.GetOrUploadWithPaletteOverride`) β€” + never calls `IncrementRefCount` for atlas. + +**Resolution:** reverted Task 9's renderer-level routing. Removed the +sentinel logic and the 4 sentinel-skip checks in +`InstancedMeshRenderer`. **Kept** the `_wbMeshAdapter` constructor +parameter (unused for now) so `GameWindow.cs` doesn't shift when +later tasks need adapter access. Kept all the real WB pipeline +construction in `WbMeshAdapter` (verified working under flag-off). + +**Week 1 endpoint shifts:** "WB infrastructure constructed; flag-on and +flag-off visually identical." Routing arrives in Week 2 (Task 11) at +the correct layer. Smoke verification is now: flag-on === flag-off. + +--- + +### Adjustment 3 (2026-05-08): flag-on FPS regression β€” root-caused, deferred to Task 22 + +**Discovered during Task 13 stress test** (radius 7, flag-on). Visible +FPS drop + rising frame latency vs flag-off baseline. Initial guess +was the staged-upload queue leaking memory; we shipped +`WbMeshAdapter.Tick()` (commit `bf53cb4`) to drain +`_meshManager.StagedMeshData` + `_graphicsDevice._glThreadQueue` per +frame. Result: leak fixed, but **FPS unchanged**. + +**Real cause: dual-pipeline cost.** Flag-on runs both rendering +pipelines in parallel without yet collecting any savings: + +1. **Background workers (4-wide).** `ObjectMeshManager` spins up + `MaxParallelLoads = 4` worker threads decoding GfxObj polygons, + building texture atlases, encoding batches. Contends with the + render thread for CPU cores. +2. **Duplicate GL upload.** `Tick()` calls `UploadMeshData` per + staged mesh, creating VAO/VBO/IBO + atlas texture uploads. Real + per-call GL state churn on the render thread. +3. **Duplicate I/O.** `DefaultDatReaderWriter` opens its own dat file + handles and rebuilds its own index cache (~50-100 MB) alongside + our existing `DatCollection`. Memory bandwidth + GC churn. +4. **Legacy renderer keeps doing the same work.** Per Adjustment 2, + `InstancedMeshRenderer` is tier-blind β€” it still uploads VAO / + VBO / IBO for the same atlas-tier content WB is also building. + **We literally double the prep cost** for every atlas-tier GfxObj. + +The savings from WB's atlas batching only materialize when **Task 22 +(`WbDrawDispatcher`) lands and the legacy renderer can short-circuit +its upload for atlas-tier content**. At that point WB owns atlas-tier +draw and `InstancedMeshRenderer` skips its own upload + draw work +for those entities. Until then, flag-on pays both costs. + +**Decision: do not fix now.** Plan Risk #5 explicitly anticipated this: + +> Performance regression during integration of week 1's "atlas for +> static scenery, old path for everything else" mixed state. +> Mitigation: keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1` +> during weeks 1-3; default-off until week 4 visual verification. + +Default-off (the user's daily experience) is byte-identical to +pre-N.4. Flag-on is dev-only until Week 4. Task 22 must wire the +legacy-renderer short-circuit for atlas-tier content as part of +landing the dispatcher; the cost cannot be amortized any earlier +without violating Adjustment 2's tier-blind-renderer principle. + +`Tick()` stays β€” it fixed a real memory leak and is required +infrastructure for Task 22 anyway. We just paid for it without +seeing FPS recovery yet. + +--- + +### Adjustment 4 (2026-05-08): WorldEntity lacks HiddenParts + AnimPartChange fields β€” deferred plumbing + +**Discovered during Task 17 implementation.** `EntitySpawnAdapter.OnCreate` +needed to populate `AnimatedEntityState` with the entity's `HiddenParts` +mask + `AnimPartChange` override map. But: `WorldEntity` (the per-frame +render-side struct) does not currently expose either field. Both pieces +of customization data live on the network-layer spawn record and are +consumed before the `WorldEntity` is built. + +**Resolution.** Task 17 ships the adapter scaffolding with a TODO comment +acknowledging the gap. The created `AnimatedEntityState` always has an +empty override map + zero hidden mask. Per-instance customizations like +"hide this character's head" won't take effect with flag-on until the +plumbing lands. + +**Why this is safe to defer.** No production path consumes +`AnimatedEntityState`'s override / hidden data yet β€” Task 22's +`WbDrawDispatcher` is the first consumer. By the time Task 22 lands, we +either: +1. Add `HiddenPartsMask` + `AnimPartChanges` fields to `WorldEntity` and + populate them at spawn time. Small change to the network β†’ render + pipeline. +2. Inject them into `EntitySpawnAdapter.OnCreate` via a separate + parameter that the spawn handler provides directly (sidesteps the + `WorldEntity` change). + +Option 1 is cleaner long-term; Option 2 is faster for landing Task 22 +without touching WorldEntity. Decision deferred to Task 22 brainstorm. + +### Adjustment 5 (2026-05-08): Task 20 (per-instance decode conformance) is structural, not byte-comparison + +**Original plan.** Task 20 was supposed to compare RGBA8 output of +"old path" (`TextureCache.GetOrUploadWithPaletteOverride` direct) vs +"new path" (`EntitySpawnAdapter` β†’ `ITextureCachePerInstance` β†’ +`TextureCache.GetOrUploadWithPaletteOverride`) to prove byte-identity. + +**Reality.** Both paths call the **same function**. The new path adds a +seam interface (`ITextureCachePerInstance`) for testability but does +not modify the decode logic β€” the bytes are identical by construction. +A test asserting byte-equality would be tautological. + +**Resolution.** Existing `EntitySpawnAdapterTests` cover the routing +behavior (does the adapter call the cache with the right args?). The +decode-byte conformance is structural: same function = same output. +Mark Task 20 βœ… structurally; no separate test file. + +### Adjustment 6 (2026-05-08): Resolved Adjustment 4 β€” Option A (fields on WorldEntity) + +**Context.** Adjustment 4 deferred the `HiddenPartsMask` + `AnimPartChanges` +plumbing decision to Task 22. Two options: +- **A**: add fields to `WorldEntity`, populate at spawn time +- **B**: thread as separate args into `EntitySpawnAdapter.OnCreate` + +**Decision: Option A.** Reasoning: +1. The data is already computed at spawn time in GameWindow's CreateObject + handler β€” adding two fields is a 4-line change. +2. Option B would spread network-layer types across the streaming subsystem, + violating the same separation-of-concerns principle as Adjustment 2. +3. The 0xF625 ObjDescEvent (appearance update) replays through the same + spawn path, so WorldEntity fields work automatically for hot-swap updates. + +**Implementation:** +- `WorldEntity` gains `PartOverrides: IReadOnlyList` (default + empty) and `HiddenPartsMask: ulong` (default 0). +- `PartOverride(byte PartIndex, uint GfxObjId)` is a lightweight record struct + in Core.World that decouples from the network-layer `CreateObject.AnimPartChange`. +- `EntitySpawnAdapter.OnCreate` now calls `state.HideParts(entity.HiddenPartsMask)` + and `state.SetPartOverride(...)` for each override. +- GameWindow's CreateObject handler builds the `PartOverride[]` from the + server-sent `AnimPartChanges` list. + +### Adjustment 7 (2026-05-08, Task 26 visual verification): IncrementRefCount doesn't trigger mesh load + +**Discovered when** Task 26's first launch showed only terrain β€” zero entities visible. Diagnostic counters (added the same launch via `ACDREAM_WB_DIAG=1`) showed `entitiesSeen=14M, entitiesDrawn=14M, drawsIssued=0` β€” every entity was visited but no draws were issued because `TryGetRenderData` returned null for everything. + +**Root cause.** WB's `ObjectMeshManager.IncrementRefCount(id)` only bumps a usage counter β€” it does NOT trigger mesh loading. Loading is fired separately by `PrepareMeshDataAsync(id, isSetup)`, which dispatches to a background worker pool; the result auto-enqueues to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`) which our existing `WbMeshAdapter.Tick()` already drains. + +The N.4 plan assumed `IncrementRefCount` was lifecycle-aware (it isn't). `LandblockSpawnAdapter` and the original `EntitySpawnAdapter` both called `IncrementRefCount` and stopped β€” meshes never loaded. + +**Fix** (commit `943652d`): +- `WbMeshAdapter.IncrementRefCount` now calls `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` on first registration. `isSetup: false` is correct because acdream's MeshRefs already carry expanded per-part GfxObj ids (0x01XXXXXX) β€” WB's Setup-expansion path is unused. +- `EntitySpawnAdapter` gained an optional `IWbMeshAdapter` constructor parameter. Per-instance entities (server-spawned characters / NPCs) had been entirely skipped by `LandblockSpawnAdapter` (which filters `ServerGuid != 0`); their GfxObjs now get registered + loaded at `OnCreate` and decremented at `OnRemove`. Includes both `MeshRefs.GfxObjId` AND `PartOverrides.GfxObjId` so weapon/clothing/helmet swaps load too. + +**Lesson preserved.** Future cross-session work touching WB: **`IncrementRefCount` is not lifecycle-aware. Call `PrepareMeshDataAsync` to trigger loads.** Documented in CLAUDE.md "WB integration cribs" section. + +### Adjustment 8 (2026-05-08, Task 26 visual verification): SurfaceId lives in batch.Key.SurfaceId + +**Discovered when** the second Task 26 launch showed `drawsIssued=4.8M/5s` (draws ARE happening) but ZERO entities visible. Inspection of `ResolveTexture` showed it was returning early because `batch.SurfaceId == 0` for every batch. + +**Root cause.** WB's `ObjectMeshManager.UploadGfxObjMeshData` (line 1746 of `ObjectMeshManager.cs`) constructs `ObjectRenderBatch` and sets `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct that contains a `SurfaceId` field) but does NOT populate the top-level `ObjectRenderBatch.SurfaceId` property. That property exists on the type but stays at its default 0. + +**Fix** (commit `943652d`): `WbDrawDispatcher.ResolveTexture` reads `batch.Key.SurfaceId` instead of `batch.SurfaceId`. Also handles the dummy `0xFFFFFFFF` case used by WB's environment edge wireframes. + +**Lesson preserved.** **`ObjectRenderBatch.SurfaceId` is not populated by WB. Read `batch.Key.SurfaceId`.** Documented in CLAUDE.md. + +### Adjustment 9 (2026-05-08, Task 26 visual verification): Modern rendering uses one global VAO/VBO/IBO + +**Discovered when** the third Task 26 launch finally showed real draws β€” but as "exploded" character body parts scattered around the world with no scenery. Visual was completely broken even though the GL pipeline was clearly issuing draws and binding textures correctly. + +**Root cause.** WB's `ObjectMeshManager` has two rendering paths controlled by `_useModernRendering = HasOpenGL43 && HasBindless`. On any modern GPU (which is everything we target), modern is true and ALL meshes share a single `GlobalMeshBuffer` β€” one VAO, one VBO, one IBO. Each batch's `IBO` field points to that ONE global IBO; the batch's actual slice is identified by `FirstIndex` (offset into IBO, in indices) and `BaseVertex` (offset into VBO, in vertices). The dispatcher was issuing `glDrawElementsInstanced` with `indices=0` and no base vertex β€” so every entity drew the same first triangle of the global mesh starting at offset 0. That produced exactly the "exploded parts at scattered positions" symptom. + +**Fix** (commit `7b41efc`): switch to `glDrawElementsInstancedBaseVertexBaseInstance`, pass `(void*)(batch.FirstIndex * sizeof(ushort))` as the indices argument, pass `(int)batch.BaseVertex` as base vertex. The grouped-instanced refactor in the same commit additionally uses `BaseInstance` to slice into the shared instance VBO per group. + +**Bonus discovery:** because all meshes share one VAO under modern rendering, the dispatcher only needs to bind the VAO ONCE per frame (not per draw). Every draw goes to the same VAO. Significant CPU savings. + +**Lesson preserved.** **WB's modern rendering path packs everything into one global VAO/VBO/IBO. Honor `FirstIndex` and `BaseVertex`.** Documented in CLAUDE.md. + +### Adjustment 10 (2026-05-08, Task 26 visual verification): AnimatedEntityState overrides clobber Issue #47 close-detail mesh + +**Discovered when** Task 26's fourth launch showed scenery + connected characters β€” but characters were "bulky and missing detail" compared to the legacy renderer. Recognized as a re-occurrence of Issue #47 (resolved 2026-05-06 via `GfxObjDegradeResolver`). + +**Root cause.** Adjustment 6 stored AnimPartChanges on `WorldEntity.PartOverrides` using the raw `NewModelId` from the network packet β€” without applying `GfxObjDegradeResolver`. GameWindow's spawn path correctly resolves base GfxObjs (e.g., upper arm `0x01000055`, 14 verts/17 polys) to their close-detail equivalents (`0x01001795`, 32 verts/60 polys) and bakes the result into `MeshRefs`. But `WbDrawDispatcher` then called `animState.ResolvePartGfxObj(partIdx, meshRefGfxObjId)` which returned the raw (low-detail) override from `PartOverrides`, undoing the degrade. + +**Fix** (commit `7b41efc`): the dispatcher trusts `MeshRefs` as the source of truth and does NOT re-apply `animState.ResolvePartGfxObj` at draw time. `AnimatedEntityState` overrides become relevant only for hot-swap appearance updates (0xF625 `ObjDescEvent`) which today rebuild MeshRefs anyway. `IsPartHidden` similarly skipped β€” `HiddenPartsMask` is never populated by spawn code (legacy renderer also doesn't check it). + +**Lesson preserved.** **`MeshRefs` is the source of truth at draw time** β€” GameWindow's spawn path bakes overrides + degrades into it. Don't re-apply overrides downstream. + +### Task 6 (original β€” kept for history) + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs` +- Test: `tests/AcDream.Core.Tests/Rendering/Wb/WbDatReaderAdapterTests.cs` + +WB's `ObjectMeshManager` constructor takes `IDatReaderWriter` (from `Chorizite.DatReaderWriter`). We use `DatReaderWriter.DatCollection` (vendored as a separate library). The two interfaces are similar but not identical. This task builds the adapter. + +- [ ] **Step 6.1: Read WB's `IDatReaderWriter` interface** + +Run: `grep -n "interface IDatReaderWriter" references/WorldBuilder/` + +Read the interface to identify which methods are actually called by `ObjectMeshManager`. Likely just `Portal.TryGet(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) 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 00000000..18c68b43 --- /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) diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index c8a473bb..e93dab82 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -9,6 +9,9 @@ AcDream.App true + + + @@ -26,6 +29,12 @@ + + + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b00345d8..1048e024 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -28,6 +28,11 @@ public sealed class GameWindow : IDisposable private InstancedMeshRenderer? _staticMesh; private Shader? _meshShader; private TextureCache? _textureCache; + /// Phase N.4: WB-backed rendering pipeline adapter. Non-null only + /// when ACDREAM_USE_WB_FOUNDATION=1 is set; null otherwise. + private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter; + private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; + private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private SamplerCache? _samplerCache; private DebugLineRenderer? _debugLines; // K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder @@ -62,7 +67,7 @@ public sealed class GameWindow : IDisposable // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; - private readonly AcDream.App.Streaming.GpuWorldState _worldState = new(); + private AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.StreamingController? _streamingController; private int _streamingRadius = 2; // default 5Γ—5 private uint? _lastLivePlayerLandblockId; @@ -1421,7 +1426,81 @@ public sealed class GameWindow : IDisposable // WorldBuilder reference at // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. _samplerCache = new SamplerCache(_gl); - _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); + + // Phase N.4 β€” WB rendering pipeline foundation. Constructed only when + // ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer + // path stays in charge. The full ObjectMeshManager bring-up lives in + // WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter + // + ObjectMeshManager. WbMeshAdapter opens its own file handles for + // the dat files (independent of our DatCollection). + if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled) + { + var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger); + Console.WriteLine("[N.4] WbFoundation flag is ENABLED β€” routing static content through ObjectMeshManager."); + } + + // Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag + // and rebuild _worldState so it threads the adapter in. _worldState starts + // as an unadorned GpuWorldState (field initializer); here we replace it with + // one that carries the adapter so AddLandblock/RemoveLandblock notify WB. + // Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned + // per-instance content under the same flag. + { + AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null; + AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null; + if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null) + { + wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter); + // Sequencer factory: look up Setup + MotionTable from dats and build + // an AnimationSequencer. Falls back to a no-op sequencer when the + // entity has no motion table (static props, etc.). Uses _animLoader + // which is initialised at line 1004; it is non-null here because + // OnLoad wires _dats + _animLoader before this block runs. + var capturedDats = _dats; + var capturedAnimLoader = _animLoader; + AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e) + { + if (capturedDats is not null && capturedAnimLoader is not null) + { + var setup = capturedDats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + uint mtableId = (uint)setup.DefaultMotionTable; + if (mtableId != 0) + { + var mtable = capturedDats.Get(mtableId); + if (mtable is not null) + return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader); + } + // Setup exists but no motion table β€” no-op sequencer. + return new AcDream.Core.Physics.AnimationSequencer( + setup, + new DatReaderWriter.DBObjs.MotionTable(), + capturedAnimLoader); + } + } + // Complete fallback: empty setup + empty motion table + null loader. + return new AcDream.Core.Physics.AnimationSequencer( + new DatReaderWriter.DBObjs.Setup(), + new DatReaderWriter.DBObjs.MotionTable(), + new NullAnimLoader()); + } + wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( + _textureCache, SequencerFactory, _wbMeshAdapter); + _wbEntitySpawnAdapter = wbEntitySpawnAdapter; + } + _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter); + } + + _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter); + + if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled + && _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null) + { + _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( + _gl, _meshShader, _textureCache, _wbMeshAdapter, _wbEntitySpawnAdapter); + } // Phase G.1 sky renderer β€” its own shader (sky.vert / sky.frag) // with depth writes off + far plane 1e6 so celestial meshes @@ -2338,6 +2417,19 @@ public sealed class GameWindow : IDisposable SubPalettes: ranges); } + AcDream.Core.World.PartOverride[] entityPartOverrides; + if (animPartChanges.Count == 0) + { + entityPartOverrides = Array.Empty(); + } + else + { + entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count]; + for (int i = 0; i < animPartChanges.Count; i++) + entityPartOverrides[i] = new AcDream.Core.World.PartOverride( + animPartChanges[i].PartIndex, animPartChanges[i].NewModelId); + } + var entity = new AcDream.Core.World.WorldEntity { Id = _liveEntityIdCounter++, @@ -2347,6 +2439,7 @@ public sealed class GameWindow : IDisposable Rotation = rot, MeshRefs = meshRefs, PaletteOverride = paletteOverride, + PartOverrides = entityPartOverrides, }; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( @@ -6048,6 +6141,12 @@ public sealed class GameWindow : IDisposable _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + // Phase N.4: drain WB pipeline queues (staged mesh data + + // GL thread queue). Must happen before any draw work so that + // resources uploaded this frame are available immediately. + // No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null). + _wbMeshAdapter?.Tick(); + // Phase D.2a β€” begin ImGui frame. Paired with the Render() call // after the scene draws (below). ImGuiController.Update() // consumes buffered Silk.NET input events and calls ImGui.NewFrame. @@ -6237,10 +6336,20 @@ public sealed class GameWindow : IDisposable animatedIds.Add(k); } - _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds, - animatedEntityIds: animatedIds); + if (_wbDrawDispatcher is not null) + { + _wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); + } + else + { + _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); + } // Phase G.1 / E.3: draw all live particles after opaque // scene geometry so alpha blending composites correctly. @@ -8621,10 +8730,13 @@ public sealed class GameWindow : IDisposable _combatChatTranslator?.Dispose(); _liveSession?.Dispose(); _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context + _wbDrawDispatcher?.Dispose(); _staticMesh?.Dispose(); _skyRenderer?.Dispose(); // depends on sampler cache; dispose first _samplerCache?.Dispose(); _textureCache?.Dispose(); + _wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation β€” null when flag off + _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); @@ -8709,4 +8821,16 @@ public sealed class GameWindow : IDisposable _ => $"Room 0x{roomId:X8}", }; } + + /// + /// Fallback for the + /// sequencer + /// factory when neither _dats nor the entity's setup is available. + /// Returns null for all animation lookups so the sequencer silently has + /// no data (same behaviour as a new empty Setup). + /// + private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader + { + public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; + } } diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 92e8f5c4..5b0c9eb4 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -20,6 +20,7 @@ // needs to update the shader and uniform setup at the call sites. using System.Numerics; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Wb; using AcDream.Core.Meshing; using AcDream.Core.Terrain; using AcDream.Core.World; @@ -33,6 +34,17 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable private readonly Shader _shader; private readonly TextureCache _textures; + /// + /// Optional WB adapter. Held but currently unused β€” Phase N.4 Adjustment 2 + /// (2026-05-08) reverted Task 9's renderer-level routing. Tier-routing decisions + /// (atlas vs per-instance) belong at the spawn-callback layer (Task 11 + /// LandblockSpawnAdapter for atlas-tier; Task 17 EntitySpawnAdapter for + /// per-instance), not in the renderer which is intentionally tier-blind. The + /// constructor parameter is preserved so GameWindow's wire-up doesn't shift + /// when later tasks need adapter access. + /// + private readonly WbMeshAdapter? _wbMeshAdapter; + // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes. private readonly Dictionary> _gpuByGfxObj = new(); @@ -67,11 +79,13 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature); - public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures) + public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures, + WbMeshAdapter? wbMeshAdapter = null) { _gl = gl; _shader = shader; _textures = textures; + _wbMeshAdapter = wbMeshAdapter; _instanceVbo = _gl.GenBuffer(); } @@ -83,6 +97,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; + // Phase N.4 Adjustment 2 (2026-05-08): renderer is tier-blind. Tier-routing + // (atlas vs per-instance) lives at the spawn-callback layer (Tasks 11 + 17), + // not here. Smoke-test of the original Task 9 routing showed it caught + // characters / NPCs (server-spawned, per-instance tier) along with static + // scenery, because EnsureUploaded is called from both spawn paths. var list = new List(subMeshes.Count); foreach (var sm in subMeshes) list.Add(UploadSubMesh(sm)); @@ -419,7 +438,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable foreach (var meshRef in entity.MeshRefs) { - if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId)) + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes)) continue; var model = meshRef.PartTransform * entityRoot; diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 6e8584a3..faa3a6e9 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -316,10 +316,10 @@ public sealed unsafe class TerrainAtlas : IDisposable return false; // Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha - // format) or the more generic PFID_A8; SurfaceDecoder routes both - // through the same "replicate single byte to RGBA" path. Palette is - // not used. - var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + // format) or the more generic PFID_A8; terrain blending alpha masks + // MUST use isAdditive=true so R=G=B=A=val β€” the terrain fragment shader + // reads .r for the blend weight. Palette is not used. + var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true); if (ReferenceEquals(d, DecodedTexture.Magenta)) return false; diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 077a12cb..6d102003 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -8,7 +8,7 @@ using SurfaceType = DatReaderWriter.Enums.SurfaceType; namespace AcDream.App.Rendering; -public sealed unsafe class TextureCache : IDisposable +public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable { private readonly GL _gl; private readonly DatCollection _dats; @@ -123,10 +123,23 @@ public sealed unsafe class TextureCache : IDisposable uint surfaceId, uint? overrideOrigTextureId, PaletteOverride paletteOverride) + => GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride, + HashPaletteOverride(paletteOverride)); + + /// + /// Overload that accepts a precomputed palette hash. Lets callers (e.g. + /// the WB draw dispatcher) compute the hash ONCE per entity and reuse + /// it across every (part, batch) lookup, avoiding the per-batch + /// FNV-1a fold over . + /// + public uint GetOrUploadWithPaletteOverride( + uint surfaceId, + uint? overrideOrigTextureId, + PaletteOverride paletteOverride, + ulong precomputedPaletteHash) { - ulong hash = HashPaletteOverride(paletteOverride); uint origTexKey = overrideOrigTextureId ?? 0; - var key = (surfaceId, origTexKey, hash); + var key = (surfaceId, origTexKey, precomputedPaletteHash); if (_handlesByPalette.TryGetValue(key, out var h)) return h; @@ -138,9 +151,10 @@ public sealed unsafe class TextureCache : IDisposable /// /// Cheap 64-bit hash over a palette override's identity so two - /// entities with the same palette setup share a decode. + /// entities with the same palette setup share a decode. Internal so + /// the WB dispatcher can compute it once per entity. /// - private static ulong HashPaletteOverride(PaletteOverride p) + internal static ulong HashPaletteOverride(PaletteOverride p) { // Not cryptographic β€” just needs to distinguish override setups // for caching. Start with base palette id, fold in each entry. @@ -199,8 +213,9 @@ public sealed unsafe class TextureCache : IDisposable // Clipmap surfaces use palette indices 0..7 as transparent sentinels. bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive); - return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap); + return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive); } /// diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs new file mode 100644 index 00000000..4e6e325c --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs @@ -0,0 +1,21 @@ +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); diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs new file mode 100644 index 00000000..20b9278c --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs @@ -0,0 +1,27 @@ +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(); +} diff --git a/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs b/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs new file mode 100644 index 00000000..913b7bf1 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Per-entity render state for animated entities (characters, creatures, +/// equipped items). Holds AC-specific per-instance customizations the WB +/// atlas cache doesn't carry: AnimPartChange override map + +/// HiddenParts bitmask. Also holds a reference to acdream's existing +/// β€” Phase N.4 explicitly does not touch +/// the sequencer; we just route through it at draw time. +/// +/// +/// Lifecycle: created by EntitySpawnAdapter.OnCreate (Task 17) when +/// a server CreateObject is processed; destroyed by +/// EntitySpawnAdapter.OnRemove on RemoveObject. The mesh +/// data backing each part is cached in WB's ObjectMeshManager; +/// per-instance customizations don't go through the atlas β€” they overlay +/// at draw time. +/// +/// +public sealed class AnimatedEntityState +{ + private readonly Dictionary _partGfxObjOverrides = new(); + private ulong _hiddenMask = 0; + + /// Reference to acdream's existing animation sequencer. + /// Phase N.4 doesn't touch the sequencer; the draw dispatcher consumes + /// per-part transforms it produces per frame. + public AnimationSequencer Sequencer { get; } + + public AnimatedEntityState(AnimationSequencer sequencer) + { + System.ArgumentNullException.ThrowIfNull(sequencer); + Sequencer = sequencer; + } + + /// Set the HiddenParts bitmask for this entity. Bit + /// i set hides part i at draw time. + public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask; + + /// True if part partIdx should be skipped at draw + /// time. Returns false for part indices outside [0, 63]. + public bool IsPartHidden(int partIdx) + { + if (partIdx < 0 || partIdx >= 64) return false; + return (_hiddenMask & (1ul << partIdx)) != 0; + } + + /// Override the GfxObj id for a Setup part. Used for + /// AnimPartChange β€” e.g. wielding a weapon swaps the hand-part's + /// GfxObj. + public void SetPartOverride(int partIdx, ulong gfxObjId) + => _partGfxObjOverrides[partIdx] = gfxObjId; + + /// Look up the GfxObj override for a part. Returns false if + /// no override is set (caller should fall back to Setup default). + public bool TryGetPartOverride(int partIdx, out ulong gfxObjId) + => _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId); + + /// Resolve the GfxObj id for : + /// override if set, else . Used by the + /// draw dispatcher to pick the right cached mesh data per part. + public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault) + => TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault; +} diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs new file mode 100644 index 00000000..eb05d92a --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Routes server-spawned (CreateObject) entities through the +/// per-instance rendering path. Server entities always carry per-instance +/// customizations (palette overrides, texture changes, part swaps) that +/// don't fit WB's atlas key, so they bypass the atlas and use the existing +/// +/// path which already hash-keys overrides for caching. +/// +/// +/// Companion to : that adapter handles +/// atlas-tier (procedural) entities; this one handles per-instance-tier +/// (server-spawned). The boundary is ServerGuid != 0 on +/// . +/// +/// +/// +/// Per-entity texture decode: when entity.PaletteOverride is +/// non-null, the adapter calls +/// +/// once per surface id that is known at spawn time (those on +/// ). Surfaces whose ids are only +/// discoverable by opening the GfxObj dat are decoded lazily by the draw +/// dispatcher (Task 22) on first use β€” that matches the existing +/// StaticMeshRenderer behavior. +/// +/// +/// +/// Sequencer factory: the adapter is constructed with a +/// Func<WorldEntity, AnimationSequencer> factory so tests can +/// inject a stub without needing a live DatCollection or MotionTable. +/// Production callers supply a factory that fetches MotionTable from dats. +/// +/// +/// +/// Adjustment 6 (resolved Adjustment 4): now +/// carries and +/// . applies +/// both to the created . +/// +/// +public sealed class EntitySpawnAdapter +{ + private readonly ITextureCachePerInstance _textureCache; + private readonly Func _sequencerFactory; + private readonly IWbMeshAdapter? _meshAdapter; + + // Per-server-guid state. Written on OnCreate, released on OnRemove. + // Single-threaded: called only from the render thread (same as GpuWorldState). + private readonly Dictionary _stateByGuid = new(); + + // Per-server-guid set of GfxObj ids registered with the mesh adapter, + // so OnRemove can decrement each. Per-instance entities don't go through + // LandblockSpawnAdapter, so without this their meshes would never load + // (WB doesn't know they exist). + private readonly Dictionary> _meshIdsByGuid = new(); + + /// + /// Per-instance texture decode path. In production this is the + /// instance (which implements + /// ); in tests it is a capturing mock. + /// + /// + /// Factory that builds an for a given + /// entity. Receives the full so it can look up + /// the Setup + MotionTable from the entity's SourceGfxObjOrSetupId + /// and server-supplied motion table override. Tests pass a lambda that + /// returns a stub sequencer. + /// + /// + /// Optional WB mesh adapter. When non-null, + /// registers each unique MeshRef.GfxObjId with the adapter so WB + /// background-loads the mesh data; decrements the + /// matching ref counts. When null, the adapter only tracks per-instance + /// state without driving WB lifecycle (test mode + flag-off mode). + /// + public EntitySpawnAdapter( + ITextureCachePerInstance textureCache, + Func sequencerFactory, + IWbMeshAdapter? meshAdapter = null) + { + ArgumentNullException.ThrowIfNull(textureCache); + ArgumentNullException.ThrowIfNull(sequencerFactory); + _textureCache = textureCache; + _sequencerFactory = sequencerFactory; + _meshAdapter = meshAdapter; + } + + /// + /// Process a server-spawned entity. Returns the created + /// for the entity, or null if + /// is atlas-tier (ServerGuid == 0). + /// + public AnimatedEntityState? OnCreate(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + + // Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0) + // are handled by LandblockSpawnAdapter, not here. + if (entity.ServerGuid == 0) return null; + + // Pre-warm the per-instance texture cache for surfaces whose ids are + // already known at spawn time (those appearing as keys in + // MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't + // covered by SurfaceOverrides are decoded lazily by the draw + // dispatcher on first use β€” consistent with StaticMeshRenderer. + if (entity.PaletteOverride is { } paletteOverride) + { + foreach (var meshRef in entity.MeshRefs) + { + if (meshRef.SurfaceOverrides is null) continue; + + // SurfaceOverrides maps surfaceId β†’ origTextureOverride (may be 0 + // meaning "no texture swap, just the palette override applies"). + foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides) + { + _textureCache.GetOrUploadWithPaletteOverride( + surfaceId, + origTexOverride == 0 ? null : origTexOverride, + paletteOverride); + } + } + } + + // Build the per-entity AnimatedEntityState. The sequencer factory + // may return a stub (in tests) or a fully-constructed sequencer from + // the MotionTable (in production). Factory must not return null β€” + // if the entity has no motion table the factory should construct a + // no-op sequencer (Setup + empty MotionTable + NullAnimationLoader). + var sequencer = _sequencerFactory(entity); + var state = new AnimatedEntityState(sequencer); + + // Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask. + state.HideParts(entity.HiddenPartsMask); + foreach (var po in entity.PartOverrides) + state.SetPartOverride(po.PartIndex, po.GfxObjId); + + _stateByGuid[entity.ServerGuid] = state; + + // Register each unique GfxObj id with WB so the meshes background-load. + // Includes both the entity's natural MeshRefs AND any server-sent + // PartOverride GfxObjs (weapons, clothing, helmets) β€” those replace the + // Setup default and need their own mesh data uploaded. + if (_meshAdapter is not null) + { + var unique = new HashSet(); + foreach (var meshRef in entity.MeshRefs) + unique.Add((ulong)meshRef.GfxObjId); + foreach (var po in entity.PartOverrides) + unique.Add((ulong)po.GfxObjId); + + _meshIdsByGuid[entity.ServerGuid] = unique; + foreach (var id in unique) _meshAdapter.IncrementRefCount(id); + } + + return state; + } + + /// + /// Release the per-entity state for . Called + /// on RemoveObject. Unknown guids (never spawned, or already + /// removed) are silently ignored. + /// + public void OnRemove(uint serverGuid) + { + _stateByGuid.Remove(serverGuid); + + if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids)) + { + foreach (var id in ids) _meshAdapter.DecrementRefCount(id); + _meshIdsByGuid.Remove(serverGuid); + } + } + + /// + /// Look up the for a server guid. + /// Returns null if the entity was never spawned or has already + /// been removed. + /// + public AnimatedEntityState? GetState(uint serverGuid) + => _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null; +} diff --git a/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs b/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs new file mode 100644 index 00000000..491f11d4 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs @@ -0,0 +1,22 @@ +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Seam interface over the per-instance palette-override decode path in +/// . Extracted so +/// can be tested without a live GL context. +/// +public interface ITextureCachePerInstance +{ + /// + /// Decode (or return cached) the palette-overridden texture for + /// . Delegates to + /// in + /// production. + /// + uint GetOrUploadWithPaletteOverride( + uint surfaceId, + uint? overrideOrigTextureId, + PaletteOverride paletteOverride); +} diff --git a/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs new file mode 100644 index 00000000..3ea4853b --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs @@ -0,0 +1,12 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Mockable interface over so adapters that +/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter) +/// can be unit-tested without a real WB pipeline behind them. +/// +public interface IWbMeshAdapter +{ + void IncrementRefCount(ulong id); + void DecrementRefCount(ulong id); +} diff --git a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs new file mode 100644 index 00000000..ec16b7c2 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Bridges landblock streaming events to 's +/// reference-count lifecycle. Tier-aware by design: only atlas-tier +/// entities (procedural / dat-hydrated, identified by +/// ServerGuid == 0) drive ref counts. Server-spawned entities +/// (per-instance tier) are skipped β€” those go through +/// EntitySpawnAdapter + TextureCache.GetOrUploadWithPaletteOverride +/// (see Phase N.4 spec, Architecture β†’ Two-tier rendering split). +/// +/// +/// On load: walks the landblock's atlas-tier entities, collects unique +/// GfxObj ids from their MeshRefs, calls +/// IncrementRefCount per id. Snapshots the id-set per landblock so +/// unload can match the load 1:1. +/// +/// +/// +/// On unload: looks up the snapshot, calls DecrementRefCount per id, +/// drops the snapshot. Unknown / never-loaded landblocks no-op. +/// +/// +/// +/// Idempotency: a duplicate load for the same landblock is a no-op on +/// ref-counting (the snapshot is already present). Defensive guard against +/// streaming-controller bugs. +/// +/// +/// +/// Thread safety: the underlying implementation +/// uses ConcurrentDictionary, so the streaming worker thread may call +/// this safely. The internal snapshot dictionary is NOT thread-safe and must +/// be called from a single streaming thread (the same thread that fires +/// AddLandblock / RemoveLandblock events). +/// +/// +public sealed class LandblockSpawnAdapter +{ + private readonly IWbMeshAdapter _adapter; + + // Maps landblock id β†’ unique GfxObj ids registered for that landblock. + // Written on load, read+cleared on unload. Single-threaded (streaming worker). + private readonly Dictionary> _idsByLandblock = new(); + + public LandblockSpawnAdapter(IWbMeshAdapter adapter) + { + System.ArgumentNullException.ThrowIfNull(adapter); + _adapter = adapter; + } + + /// + /// Called when a landblock finishes streaming in. + /// Registers a ref-count increment with WB for each unique atlas-tier + /// GfxObj id in the landblock. Duplicate loads for the same landblock id + /// are silently ignored. + /// + public void OnLandblockLoaded(LoadedLandblock landblock) + { + System.ArgumentNullException.ThrowIfNull(landblock); + + // Idempotency: already-loaded landblock is a no-op. + if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return; + + var unique = new HashSet(); + foreach (var entity in landblock.Entities) + { + // Atlas-tier filter: server-spawned entities (ServerGuid != 0) + // belong to the per-instance path and are NOT registered with WB. + if (entity.ServerGuid != 0) continue; + + foreach (var meshRef in entity.MeshRefs) + unique.Add((ulong)meshRef.GfxObjId); + } + + _idsByLandblock[landblock.LandblockId] = unique; + foreach (var id in unique) _adapter.IncrementRefCount(id); + } + + /// + /// Called when a landblock is unloaded from the streaming window. + /// Releases the ref-count for every GfxObj id that was registered on load. + /// Unknown landblock ids (never loaded, or already unloaded) are no-ops. + /// + public void OnLandblockUnloaded(uint landblockId) + { + if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return; + foreach (var id in unique) _adapter.DecrementRefCount(id); + _idsByLandblock.Remove(landblockId); + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs new file mode 100644 index 00000000..4644f716 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Meshing; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using Chorizite.OpenGLSDLBackend.Lib; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Draws entities using WB's (a single global +/// VAO/VBO/IBO under modern rendering) with acdream's +/// for texture resolution and for +/// translucency classification. +/// +/// +/// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's +/// via . +/// Textures resolve through using the batch's +/// SurfaceId. +/// +/// +/// +/// Per-instance-tier entities (ServerGuid != 0): mesh data also from +/// WB, but textures resolve through with palette and +/// surface overrides applied. is currently +/// unused at draw time β€” GameWindow's spawn path already bakes AnimPartChanges + +/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into MeshRefs. +/// +/// +/// +/// GL strategy: GROUPED instanced drawing. All visible (entity, batch) +/// pairs are bucketed by ; within a group a single +/// glDrawElementsInstancedBaseVertexBaseInstance renders all instances. +/// All matrices for the frame land in one shared instance VBO via a single +/// BufferData upload. This drops draw calls from O(entitiesΓ—batches) +/// to O(unique GfxObjΓ—batchΓ—texture) β€” typically two orders of magnitude fewer. +/// +/// +/// +/// Shader: reuses mesh_instanced (vert locations 0-2 = Position/ +/// Normal/UV from WB's VertexPositionNormalTexture; locations 3-6 = instance +/// matrix from our VBO). WB's 32-byte vertex stride is compatible. +/// +/// +/// +/// Modern rendering assumption: WB's _useModernRendering path (GL +/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses +/// FirstIndex + BaseVertex per batch. The dispatcher honors those +/// offsets via DrawElementsInstancedBaseVertex(BaseInstance). The legacy +/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there. +/// +/// +public sealed unsafe class WbDrawDispatcher : IDisposable +{ + private readonly GL _gl; + private readonly Shader _shader; + private readonly TextureCache _textures; + private readonly WbMeshAdapter _meshAdapter; + private readonly EntitySpawnAdapter _entitySpawnAdapter; + + private readonly uint _instanceVbo; + private readonly HashSet _patchedVaos = new(); + + // Per-frame scratch β€” reused across frames to avoid per-frame allocation. + private readonly Dictionary _groups = new(); + private readonly List _opaqueDraws = new(); + private readonly List _translucentDraws = new(); + private float[] _instanceBuffer = new float[256 * 16]; // grow on demand, never shrink + + // Per-entity-cull AABB radius. Conservative β€” covers most entities; large + // outliers (long banners, tall columns) are still landblock-culled. + private const float PerEntityCullRadius = 5.0f; + + private bool _disposed; + + // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. + private int _entitiesSeen; + private int _entitiesDrawn; + private int _meshesMissing; + private int _drawsIssued; + private int _instancesIssued; + private long _lastLogTick; + + public WbDrawDispatcher( + GL gl, + Shader shader, + TextureCache textures, + WbMeshAdapter meshAdapter, + EntitySpawnAdapter entitySpawnAdapter) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(shader); + ArgumentNullException.ThrowIfNull(textures); + ArgumentNullException.ThrowIfNull(meshAdapter); + ArgumentNullException.ThrowIfNull(entitySpawnAdapter); + + _gl = gl; + _shader = shader; + _textures = textures; + _meshAdapter = meshAdapter; + _entitySpawnAdapter = entitySpawnAdapter; + + _instanceVbo = _gl.GenBuffer(); + } + + public static Matrix4x4 ComposePartWorldMatrix( + Matrix4x4 entityWorld, + Matrix4x4 animOverride, + Matrix4x4 restPose) + => restPose * animOverride * entityWorld; + + public void Draw( + ICamera camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, + FrustumPlanes? frustum = null, + uint? neverCullLandblockId = null, + HashSet? visibleCellIds = null, + HashSet? animatedEntityIds = null) + { + _shader.Use(); + var vp = camera.View * camera.Projection; + _shader.SetMatrix4("uViewProjection", vp); + + bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); + + // Camera world-space position for front-to-back sort (perf #2). The view + // matrix is the inverse of the camera's world transform, so the world + // translation lives in the inverse's translation row. + Vector3 camPos = Vector3.Zero; + if (Matrix4x4.Invert(camera.View, out var invView)) + camPos = invView.Translation; + + // ── Phase 1: clear groups, walk entities, build groups ────────────── + foreach (var grp in _groups.Values) grp.Matrices.Clear(); + + var metaTable = _meshAdapter.MetadataTable; + uint anyVao = 0; + + foreach (var entry in landblockEntries) + { + bool landblockVisible = frustum is null + || entry.LandblockId == neverCullLandblockId + || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax); + + if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0)) + continue; + + foreach (var entity in entry.Entities) + { + if (entity.MeshRefs.Count == 0) continue; + + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + if (!landblockVisible && !isAnimated) continue; + + if (entity.ParentCellId.HasValue && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)) + continue; + + // Per-entity AABB frustum cull (perf #3). Skips work for distant + // entities even when their landblock is visible. Animated + // entities bypass β€” they're tracked at landblock level + need + // per-frame work for animation regardless. Conservative 5m + // radius covers typical entity bounds. + if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) + { + var p = entity.Position; + var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius); + var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius); + if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) + continue; + } + + if (diag) _entitiesSeen++; + + var entityWorld = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + + // Compute palette-override hash ONCE per entity (perf #4). + // Reused across every (part, batch) lookup so the FNV-1a fold + // over SubPalettes runs once instead of N times. Zero when the + // entity has no palette override (trees, scenery). + ulong palHash = 0; + if (entity.PaletteOverride is not null) + palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); + + bool drewAny = false; + for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) + { + // Note: GameWindow's spawn path already applies + // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix β€” + // close-detail mesh swap for humanoids) to MeshRefs. We + // trust MeshRefs as the source of truth here. AnimatedEntityState's + // overrides become relevant only for hot-swap (0xF625 + // ObjDescEvent) which today rebuilds MeshRefs anyway. + var meshRef = entity.MeshRefs[partIdx]; + ulong gfxObjId = meshRef.GfxObjId; + + var renderData = _meshAdapter.TryGetRenderData(gfxObjId); + if (renderData is null) + { + if (diag) _meshesMissing++; + continue; + } + drewAny = true; + if (anyVao == 0) anyVao = renderData.VAO; + + if (renderData.IsSetup && renderData.SetupParts.Count > 0) + { + foreach (var (partGfxObjId, partTransform) in renderData.SetupParts) + { + var partData = _meshAdapter.TryGetRenderData(partGfxObjId); + if (partData is null) continue; + + var model = ComposePartWorldMatrix( + entityWorld, meshRef.PartTransform, partTransform); + + ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); + } + } + else + { + var model = meshRef.PartTransform * entityWorld; + ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); + } + } + + if (diag && drewAny) _entitiesDrawn++; + } + } + + // Nothing visible β€” skip the GL pass entirely. + if (anyVao == 0) + { + if (diag) MaybeFlushDiag(); + return; + } + + // ── Phase 2: lay matrices out contiguously, assign per-group offsets, + // split into opaque/translucent + compute sort keys ───────── + int totalInstances = 0; + foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count; + if (totalInstances == 0) + { + if (diag) MaybeFlushDiag(); + return; + } + + int needed = totalInstances * 16; + if (_instanceBuffer.Length < needed) + _instanceBuffer = new float[needed + 256 * 16]; // headroom + + _opaqueDraws.Clear(); + _translucentDraws.Clear(); + + int cursor = 0; + foreach (var grp in _groups.Values) + { + if (grp.Matrices.Count == 0) continue; + + grp.FirstInstance = cursor; + grp.InstanceCount = grp.Matrices.Count; + + // Use the first instance's translation as the group's representative + // position for front-to-back sort (perf #2). Cheap heuristic; works + // well when instances of one group are spatially coherent + // (typical for trees in one landblock area, NPCs at one spawn). + var firstM = grp.Matrices[0]; + var grpPos = new Vector3(firstM.M41, firstM.M42, firstM.M43); + grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos); + + for (int i = 0; i < grp.Matrices.Count; i++) + { + WriteMatrix(_instanceBuffer, cursor * 16, grp.Matrices[i]); + cursor++; + } + + if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap) + _opaqueDraws.Add(grp); + else + _translucentDraws.Add(grp); + } + + // Front-to-back sort for opaque pass: nearer groups draw first so the + // depth test rejects fragments hidden behind them, reducing fragment + // shader cost from overdraw on dense scenes (Holtburg courtyard, + // Foundry interior). + _opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance)); + + // ── Phase 3: one upload of all matrices ───────────────────────────── + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); + fixed (float* p = _instanceBuffer) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw); + + // ── Phase 4: bind VAO once (modern rendering shares one global VAO) ── + EnsureInstanceAttribs(anyVao); + _gl.BindVertexArray(anyVao); + + // ── Phase 5: opaque + ClipMap pass (front-to-back sorted) ─────────── + if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal)) + _gl.Disable(EnableCap.CullFace); + + foreach (var grp in _opaqueDraws) + { + _shader.SetInt("uTranslucencyKind", (int)grp.Translucency); + DrawGroup(grp); + } + + // ── Phase 6: translucent pass ─────────────────────────────────────── + _gl.Enable(EnableCap.Blend); + _gl.DepthMask(false); + + if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal)) + { + _gl.Disable(EnableCap.CullFace); + } + else + { + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); + _gl.FrontFace(FrontFaceDirection.Ccw); + } + + foreach (var grp in _translucentDraws) + { + switch (grp.Translucency) + { + case TranslucencyKind.Additive: + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + break; + case TranslucencyKind.InvAlpha: + _gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha); + break; + default: + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + break; + } + + _shader.SetInt("uTranslucencyKind", (int)grp.Translucency); + DrawGroup(grp); + } + + _gl.DepthMask(true); + _gl.Disable(EnableCap.Blend); + _gl.Disable(EnableCap.CullFace); + _gl.BindVertexArray(0); + + if (diag) + { + _drawsIssued += _opaqueDraws.Count + _translucentDraws.Count; + _instancesIssued += totalInstances; + MaybeFlushDiag(); + } + } + + private void DrawGroup(InstanceGroup grp) + { + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, grp.TextureHandle); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, grp.Ibo); + + // BaseInstance offsets the per-instance attribute fetches into our + // shared instance VBO so each group reads its own slice. Requires + // GL_ARB_base_instance (GL 4.2+); WB requires 4.3 so this is available. + _gl.DrawElementsInstancedBaseVertexBaseInstance( + PrimitiveType.Triangles, + (uint)grp.IndexCount, + DrawElementsType.UnsignedShort, + (void*)(grp.FirstIndex * sizeof(ushort)), + (uint)grp.InstanceCount, + grp.BaseVertex, + (uint)grp.FirstInstance); + } + + private void MaybeFlushDiag() + { + long now = Environment.TickCount64; + if (now - _lastLogTick > 5000) + { + Console.WriteLine( + $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count}"); + _entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0; + _lastLogTick = now; + } + } + + private void ClassifyBatches( + ObjectRenderData renderData, + ulong gfxObjId, + Matrix4x4 model, + WorldEntity entity, + MeshRef meshRef, + ulong palHash, + AcSurfaceMetadataTable metaTable) + { + for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) + { + var batch = renderData.Batches[batchIdx]; + + TranslucencyKind translucency; + if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta)) + { + translucency = meta.Translucency; + } + else + { + translucency = batch.IsAdditive ? TranslucencyKind.Additive + : batch.IsTransparent ? TranslucencyKind.AlphaBlend + : TranslucencyKind.Opaque; + } + + uint texHandle = ResolveTexture(entity, meshRef, batch, palHash); + if (texHandle == 0) continue; + + var key = new GroupKey( + batch.IBO, batch.FirstIndex, (int)batch.BaseVertex, + batch.IndexCount, texHandle, translucency); + + if (!_groups.TryGetValue(key, out var grp)) + { + grp = new InstanceGroup + { + Ibo = batch.IBO, + FirstIndex = batch.FirstIndex, + BaseVertex = (int)batch.BaseVertex, + IndexCount = batch.IndexCount, + TextureHandle = texHandle, + Translucency = translucency, + }; + _groups[key] = grp; + } + grp.Matrices.Add(model); + } + } + + private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash) + { + // WB stores the surface id on batch.Key.SurfaceId (TextureKey struct); + // batch.SurfaceId is unset (zero) for batches built by ObjectMeshManager. + uint surfaceId = batch.Key.SurfaceId; + if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0; + + uint overrideOrigTex = 0; + bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null + && meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex); + uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null; + + if (entity.PaletteOverride is not null) + { + // perf #4: pass the entity-precomputed palette hash so TextureCache + // can skip its internal HashPaletteOverride for repeat lookups + // within the same character. + return _textures.GetOrUploadWithPaletteOverride( + surfaceId, origTexOverride, entity.PaletteOverride, palHash); + } + else if (hasOrigTexOverride) + { + return _textures.GetOrUploadWithOrigTextureOverride(surfaceId, overrideOrigTex); + } + else + { + return _textures.GetOrUpload(surfaceId); + } + } + + private void EnsureInstanceAttribs(uint vao) + { + if (!_patchedVaos.Add(vao)) return; + + _gl.BindVertexArray(vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); + for (uint row = 0; row < 4; row++) + { + uint loc = 3 + row; + _gl.EnableVertexAttribArray(loc); + _gl.VertexAttribPointer(loc, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16)); + _gl.VertexAttribDivisor(loc, 1); + } + } + + private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m) + { + buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14; + buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24; + buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34; + buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _gl.DeleteBuffer(_instanceVbo); + } + + private readonly record struct GroupKey( + uint Ibo, + uint FirstIndex, + int BaseVertex, + int IndexCount, + uint TextureHandle, + TranslucencyKind Translucency); + + private sealed class InstanceGroup + { + public uint Ibo; + public uint FirstIndex; + public int BaseVertex; + public int IndexCount; + public uint TextureHandle; + public TranslucencyKind Translucency; + public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) + public int InstanceCount; + public float SortDistance; // squared distance from camera to first instance, for opaque sort + public readonly List Matrices = new(); + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs new file mode 100644 index 00000000..c3fd006e --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs @@ -0,0 +1,39 @@ +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). +/// +/// +/// Default-on as of Phase N.4 ship (2026-05-08). The WB foundation +/// (WbMeshAdapter + WbDrawDispatcher) is the production +/// rendering path. Set ACDREAM_USE_WB_FOUNDATION=0 to fall back +/// to the legacy InstancedMeshRenderer path β€” kept as an escape +/// hatch until N.6 fully replaces it. +/// +/// +/// +/// Per-instance customized content (server CreateObject entities +/// with palette / texture overrides) routes through +/// regardless +/// of the flag β€” the flag controls which DRAW path consumes those +/// textures. +/// +/// +public static class WbFoundationFlag +{ + private static bool _isEnabled = + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0"; + + public static bool IsEnabled => _isEnabled; + + /// + /// FOR TESTS ONLY. Forces to true so + /// integration tests can exercise the WB adapter path without having to + /// set the env var before static initialisation. Never call from + /// production code. + /// + internal static void ForTestsOnly_ForceEnable() => _isEnabled = true; +} diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs new file mode 100644 index 00000000..b57e0431 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Meshing; +using Chorizite.OpenGLSDLBackend; +using Chorizite.OpenGLSDLBackend.Lib; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Silk.NET.OpenGL; +using WorldBuilder.Shared.Models; +using WorldBuilder.Shared.Services; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Single seam between acdream and WB's render pipeline. Owns the +/// ObjectMeshManager instance and exposes a stable acdream-shaped API +/// so the rest of the renderer doesn't need to know about WB's types directly. +/// +/// +/// The adapter constructs its own DefaultDatReaderWriter internally; it +/// does NOT share file handles with our DatCollection. This duplicates +/// index-cache memory (~50–100 MB) but keeps the two subsystems fully decoupled. +/// Acceptable for Phase N.4 foundation work (plan Adjustment 1). +/// +/// +public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter +{ + private readonly OpenGLGraphicsDevice? _graphicsDevice; + private readonly DefaultDatReaderWriter? _wbDats; + private readonly ObjectMeshManager? _meshManager; + private readonly DatCollection? _dats; + private readonly AcSurfaceMetadataTable _metadataTable = new(); + private readonly HashSet _metadataPopulated = new(); + + /// + /// True when this instance was created via ; + /// all public methods no-op when uninitialized. + /// + private readonly bool _isUninitialized; + + private bool _disposed; + + /// + /// Constructs the full WB pipeline: OpenGLGraphicsDevice β†’ DefaultDatReaderWriter + /// β†’ ObjectMeshManager. + /// + /// Active Silk.NET GL context. Must be bound to the current + /// thread (construction runs GL queries; call from OnLoad). + /// Path to the dat directory (same as the one supplied + /// to our DatCollection). DefaultDatReaderWriter opens its own file handles. + /// acdream's DatCollection, used to populate the surface + /// metadata side-table via GfxObjMesh.Build. Shares file handles with + /// the rest of the client; read-only access from the render thread. + /// Logger for the adapter; ObjectMeshManager uses + /// NullLogger internally. + public WbMeshAdapter(GL gl, string datDir, DatCollection dats, ILogger logger) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(datDir); + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(logger); + + _dats = dats; + _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); + _wbDats = new DefaultDatReaderWriter(datDir); + _meshManager = new ObjectMeshManager( + _graphicsDevice, + _wbDats, + NullLogger.Instance); + } + + private WbMeshAdapter() + { + // Uninitialized constructor β€” only for tests / flag-off cases where + // the caller wants a Dispose-safe no-op instance. + _isUninitialized = true; + } + + /// Test/init helper β€” produces a Dispose-safe instance with no + /// underlying mesh manager. Public methods are all no-ops. + public static WbMeshAdapter CreateUninitialized() => new(); + + /// + /// The surface metadata side-table populated on each first + /// . Queried by the draw dispatcher + /// to determine translucency, luminosity, and fog behavior per batch. + /// + public AcSurfaceMetadataTable MetadataTable => _metadataTable; + + /// + /// Returns the WB render data for , or null if not + /// yet uploaded or if this adapter is uninitialized. Increments WB's + /// internal usage counter β€” use for + /// render-loop lookups that should not affect lifecycle. + /// + public ObjectRenderData? GetRenderData(ulong id) + { + if (_isUninitialized || _meshManager is null) return null; + return _meshManager.GetRenderData(id); + } + + /// + /// Returns the WB render data for without + /// modifying reference counts. Returns null if the mesh is not yet + /// uploaded. Safe for render-loop lookups. + /// + public ObjectRenderData? TryGetRenderData(ulong id) + { + if (_isUninitialized || _meshManager is null) return null; + return _meshManager.TryGetRenderData(id); + } + + /// + public void IncrementRefCount(ulong id) + { + if (_isUninitialized || _meshManager is null) return; + _meshManager.IncrementRefCount(id); + + if (_metadataPopulated.Add(id)) + { + PopulateMetadata(id); + + // WB's IncrementRefCount alone only bumps a usage counter; it does + // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync + // so the background workers actually decode the GfxObj. The result + // auto-enqueues into _stagedMeshData (ObjectMeshManager line 510), + // which Tick() drains onto the GPU. Until that completes, + // TryGetRenderData(id) returns null and the dispatcher silently + // skips the entity β€” standard streaming flicker. + // + // isSetup: false β€” acdream's MeshRefs already carry expanded + // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is + // unused. + _ = _meshManager.PrepareMeshDataAsync(id, isSetup: false); + } + } + + /// + public void DecrementRefCount(ulong id) + { + if (_isUninitialized || _meshManager is null) return; + _meshManager.DecrementRefCount(id); + } + + /// + /// Per-frame drain of the WB pipeline's main-thread work queues. MUST be + /// called once per frame from the render thread. Without this, the staged + /// mesh data queue grows unbounded (memory leak) and queued GL actions + /// never execute. + /// + /// + /// Order matters: ProcessGLQueue runs first to apply any pending GL + /// state changes (e.g., texture uploads queued by background workers + /// during mesh prep). Then we drain staged mesh data, calling + /// UploadMeshData on each item to materialize the actual GL VAO / + /// VBO / IBO resources. After Tick, GetRenderData for any id + /// previously passed to IncrementRefCount may return non-null. + /// + /// + /// + /// No-op when the adapter is uninitialized (e.g., flag is off and the + /// adapter was constructed via CreateUninitialized). + /// + /// + public void Tick() + { + if (_isUninitialized) return; + if (_disposed) return; + + _graphicsDevice!.ProcessGLQueue(); + while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) + { + _meshManager.UploadMeshData(meshData); + } + } + + private void PopulateMetadata(ulong id) + { + if (_dats is null) return; + if (!_dats.Portal.TryGet((uint)id, out var gfxObj)) return; + + var subMeshes = 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)); + } + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _meshManager?.Dispose(); + _wbDats?.Dispose(); + _graphicsDevice?.Dispose(); + } +} diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f3448ef5..7f6d2281 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using AcDream.App.Rendering.Wb; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -38,6 +39,17 @@ namespace AcDream.App.Streaming; /// public sealed class GpuWorldState { + private readonly LandblockSpawnAdapter? _wbSpawnAdapter; + private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; + + public GpuWorldState( + LandblockSpawnAdapter? wbSpawnAdapter = null, + EntitySpawnAdapter? wbEntitySpawnAdapter = null) + { + _wbSpawnAdapter = wbSpawnAdapter; + _wbEntitySpawnAdapter = wbEntitySpawnAdapter; + } + private readonly Dictionary _loaded = new(); private readonly Dictionary _aabbs = new(); @@ -132,6 +144,8 @@ public sealed class GpuWorldState } _loaded[landblock.LandblockId] = landblock; + if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null) + _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); RebuildFlatView(); } @@ -181,6 +195,9 @@ public sealed class GpuWorldState public void RemoveLandblock(uint landblockId) { + if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null) + _wbSpawnAdapter.OnLandblockUnloaded(landblockId); + // Rescue persistent entities before removal. These get appended // to the _persistentRescued list; the caller is responsible for // re-injecting them (via AppendLiveEntity) into whatever landblock @@ -233,6 +250,10 @@ public sealed class GpuWorldState { if (serverGuid == 0) return; + // Phase N.4 Task 17: release per-instance state for server-spawned + // entities. No-op for atlas-tier entities (never registered). + _wbEntitySpawnAdapter?.OnRemove(serverGuid); + bool rebuiltLoaded = false; // Scan loaded landblocks. ToArray() so we can mutate _loaded inside. @@ -288,6 +309,11 @@ public sealed class GpuWorldState /// public void AppendLiveEntity(uint landblockId, WorldEntity entity) { + // Phase N.4 Task 17: route server-spawned entities through the + // per-instance adapter. Atlas-tier entities (ServerGuid == 0) are + // skipped by OnCreate β€” it returns null immediately for those. + _wbEntitySpawnAdapter?.OnCreate(entity); + uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_loaded.TryGetValue(canonicalLandblockId, out var lb)) diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index e48b9a40..8b3158f5 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -1,5 +1,6 @@ using BCnEncoder.Decoder; using BCnEncoder.Shared; +using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -16,7 +17,7 @@ public static class SurfaceDecoder /// when a palette is available. /// public static DecodedTexture DecodeRenderSurface(RenderSurface rs) - => DecodeRenderSurface(rs, palette: null); + => DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false); /// /// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support. @@ -24,8 +25,11 @@ public static class SurfaceDecoder /// 16-bit value in SourceData is treated as an index into . /// When is true on an indexed surface, palette indices /// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention). + /// When is true, A8/CUSTOM_LSCAPE_ALPHA surfaces + /// replicate the byte into all four channels (R=G=B=A=val, for terrain alpha masks + /// and additive surfaces). When false, R=G=B=255, A=val (WB FillA8 semantics). /// - public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false) + public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) { if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) return DecodedTexture.Magenta; @@ -40,9 +44,11 @@ public static class SurfaceDecoder PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap), PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap), PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), - PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs), + PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive), PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), + PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs), + PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs), _ => DecodedTexture.Magenta, }; } @@ -59,33 +65,7 @@ public static class SurfaceDecoder return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; - int paletteMax = palette.Colors.Count - 1; - for (int i = 0; i < rs.Width * rs.Height; i++) - { - // Read each 16-bit value little-endian as a palette index - int src = i * 2; - ushort idx = (ushort)(rs.SourceData[src] | (rs.SourceData[src + 1] << 8)); - if (idx > paletteMax) idx = 0; - var c = palette.Colors[idx]; - - int dst = i * 4; - // Clipmap alpha-key convention (ACViewer: if (isClipMap && color < 8) r=g=b=a=0): - // palette indices 0..7 on clipmap surfaces represent transparent pixels. - if (isClipMap && idx < 8) - { - rgba[dst + 0] = 0; - rgba[dst + 1] = 0; - rgba[dst + 2] = 0; - rgba[dst + 3] = 0; - } - else - { - rgba[dst + 0] = c.Red; - rgba[dst + 1] = c.Green; - rgba[dst + 2] = c.Blue; - rgba[dst + 3] = c.Alpha; - } - } + TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); return new DecodedTexture(rgba, rs.Width, rs.Height); } @@ -109,30 +89,22 @@ public static class SurfaceDecoder } /// - /// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) - /// into RGBA8 by replicating each alpha byte into all four channels. AC's - /// terrain blending alpha masks are stored as PFID_CUSTOM_LSCAPE_ALPHA and - /// other generic 8-bit alpha surfaces use PFID_A8; the bit layout is - /// identical so one decoder handles both. Replicating into all four - /// channels lets the fragment shader pull "the blend amount" from either - /// .a or .r without special-casing. + /// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8. + /// When is true: R=G=B=A=val (terrain alpha masks and + /// additive entity textures β€” the shader reads .r for the blend weight). When false: + /// R=G=B=255, A=val (WB FillA8 semantics for non-additive entity textures). /// - private static DecodedTexture DecodeA8(RenderSurface rs) + private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive) { int expected = rs.Width * rs.Height; if (rs.SourceData.Length < expected) return DecodedTexture.Magenta; var rgba = new byte[expected * 4]; - for (int i = 0; i < expected; i++) - { - byte a = rs.SourceData[i]; - int d = i * 4; - rgba[d + 0] = a; - rgba[d + 1] = a; - rgba[d + 2] = a; - rgba[d + 3] = a; - } + if (isAdditive) + TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + else + TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } @@ -143,15 +115,7 @@ public static class SurfaceDecoder return DecodedTexture.Magenta; var rgba = new byte[expected]; - // Source layout per pixel: B, G, R, A β†’ swap to R, G, B, A - for (int i = 0; i < rs.Width * rs.Height; i++) - { - int s = i * 4; - rgba[s + 0] = rs.SourceData[s + 2]; // R <- R - rgba[s + 1] = rs.SourceData[s + 1]; // G <- G - rgba[s + 2] = rs.SourceData[s + 0]; // B <- B - rgba[s + 3] = rs.SourceData[s + 3]; // A <- A - } + TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } @@ -168,29 +132,7 @@ public static class SurfaceDecoder return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; - int paletteMax = palette.Colors.Count - 1; - for (int i = 0; i < rs.Width * rs.Height; i++) - { - int idx = rs.SourceData[i]; - if (idx > paletteMax) idx = 0; - var c = palette.Colors[idx]; - - int dst = i * 4; - if (isClipMap && idx < 8) - { - rgba[dst + 0] = 0; - rgba[dst + 1] = 0; - rgba[dst + 2] = 0; - rgba[dst + 3] = 0; - } - else - { - rgba[dst + 0] = c.Red; - rgba[dst + 1] = c.Green; - rgba[dst + 2] = c.Blue; - rgba[dst + 3] = c.Alpha; - } - } + TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); return new DecodedTexture(rgba, rs.Width, rs.Height); } @@ -207,16 +149,7 @@ public static class SurfaceDecoder return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; - for (int i = 0; i < rs.Width * rs.Height; i++) - { - int src = i * 3; - int dst = i * 4; - // On-disk byte order: B, G, R (little-endian 24-bit BGR, same as DX PFID_R8G8B8) - rgba[dst + 0] = rs.SourceData[src + 2]; // R - rgba[dst + 1] = rs.SourceData[src + 1]; // G - rgba[dst + 2] = rs.SourceData[src + 0]; // B - rgba[dst + 3] = 0xFF; // A = opaque - } + TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } @@ -245,6 +178,28 @@ public static class SurfaceDecoder return new DecodedTexture(rgba, rs.Width, rs.Height); } + private static DecodedTexture DecodeR5G6B5(RenderSurface rs) + { + int expectedBytes = rs.Width * rs.Height * 2; + if (rs.SourceData.Length < expectedBytes) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); + } + + private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs) + { + int expectedBytes = rs.Width * rs.Height * 2; + if (rs.SourceData.Length < expectedBytes) + return DecodedTexture.Magenta; + + var rgba = new byte[rs.Width * rs.Height * 4]; + TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); + return new DecodedTexture(rgba, rs.Width, rs.Height); + } + private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap) { var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 33a4b2ca..d1dfed4a 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -55,4 +55,27 @@ public sealed class WorldEntity /// visible trunk, producing "partial passthrough" bugs. /// public float Scale { get; init; } = 1.0f; + + /// + /// Server-sent part-swap overrides from AnimPartChange. Each entry + /// replaces a Setup part's GfxObj with an alternate model (clothing, weapons, + /// helmets). Carried on the entity so EntitySpawnAdapter can populate + /// AnimatedEntityState's override map at spawn time. Empty for atlas- + /// tier entities. + /// + public IReadOnlyList PartOverrides { get; init; } = Array.Empty(); + + /// + /// Bitmask of hidden Setup parts. Bit i set hides part i at + /// draw time. Sourced from the server's CreateObject record when + /// present. Zero (no parts hidden) is the default. + /// + public ulong HiddenPartsMask { get; init; } } + +/// +/// Lightweight value type for a server-sent AnimPartChange (part index +/// β†’ replacement GfxObj id). Decouples WorldEntity (Core) from the +/// network-layer CreateObject.AnimPartChange type. +/// +public readonly record struct PartOverride(byte PartIndex, uint GfxObjId); diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs new file mode 100644 index 00000000..23aa231e --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs @@ -0,0 +1,72 @@ +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 _)); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs new file mode 100644 index 00000000..d603ccd3 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs @@ -0,0 +1,62 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class AnimPartChangeTests +{ + [Fact] + public void SetPartOverride_ResolvedAtLookup() + { + var state = MakeState(); + + state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul); + + Assert.True(state.TryGetPartOverride(5, out var got)); + Assert.Equal(0x01001234ul, got); + Assert.False(state.TryGetPartOverride(6, out _)); + } + + [Fact] + public void SetPartOverride_TwiceForSamePart_TakesLatest() + { + var state = MakeState(); + + state.SetPartOverride(0, 0x01000001ul); + state.SetPartOverride(0, 0x01999999ul); + + Assert.True(state.TryGetPartOverride(0, out var got)); + Assert.Equal(0x01999999ul, got); + } + + [Fact] + public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault() + { + var state = MakeState(); + + Assert.Equal(0x01000001ul, + state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul)); + } + + [Fact] + public void ResolvePartGfxObj_WithOverride_ReturnsOverride() + { + var state = MakeState(); + state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul); + + Assert.Equal(0x01999999ul, + state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul)); + } + + private static AnimatedEntityState MakeState() => new(MakeSequencer()); + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs new file mode 100644 index 00000000..aae14ddc --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs @@ -0,0 +1,45 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class AnimatedEntityStateTests +{ + [Fact] + public void DefaultState_HasNoOverridesAndNoHiddenParts() + { + var state = MakeState(); + + Assert.False(state.IsPartHidden(0)); + Assert.False(state.IsPartHidden(63)); + Assert.False(state.TryGetPartOverride(0, out _)); + } + + [Fact] + public void Sequencer_AccessibleAsProperty() + { + var sequencer = MakeSequencer(); + var state = new AnimatedEntityState(sequencer); + + Assert.Same(sequencer, state.Sequencer); + } + + [Fact] + public void Construct_WithNullSequencer_ThrowsArgumentNull() + { + Assert.Throws( + () => new AnimatedEntityState(null!)); + } + + private static AnimatedEntityState MakeState() => new(MakeSequencer()); + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs new file mode 100644 index 00000000..016ce651 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class EntitySpawnAdapterTests +{ + // ── Happy-path: server-spawned entity ───────────────────────────────── + + [Fact] + public void OnCreate_ServerSpawnedEntity_RegistersAnimatedEntityState() + { + var cache = new CapturingTextureCache(); + var adapter = MakeAdapter(cache); + var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0001u); + + var state = adapter.OnCreate(entity); + + Assert.NotNull(state); + Assert.Same(state, adapter.GetState(0xDEAD0001u)); + } + + [Fact] + public void OnCreate_ServerSpawnedEntity_SequencerIsNotNull() + { + var adapter = MakeAdapter(); + var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0002u); + + var state = adapter.OnCreate(entity); + + Assert.NotNull(state!.Sequencer); + } + + // ── Atlas-tier filter ───────────────────────────────────────────────── + + [Fact] + public void OnCreate_ProceduralEntity_ReturnsNullAndRegistersNothing() + { + var cache = new CapturingTextureCache(); + var adapter = MakeAdapter(cache); + // ServerGuid == 0 β†’ atlas-tier, must not be processed here. + var entity = MakeEntity(id: 2, serverGuid: 0u); + + var state = adapter.OnCreate(entity); + + Assert.Null(state); + Assert.Null(adapter.GetState(0u)); + // No texture decode should have been triggered. + Assert.Empty(cache.Calls); + } + + // ── Palette-override texture decode ─────────────────────────────────── + + [Fact] + public void OnCreate_WithPaletteOverrideAndSurfaceOverrides_TriggersTextureCacheDecode() + { + var cache = new CapturingTextureCache(); + var adapter = MakeAdapter(cache); + + var palette = new PaletteOverride( + BasePaletteId: 0x04001234u, + SubPalettes: new[] + { + new PaletteOverride.SubPaletteRange(0x04002000u, 0, 2), + }); + + // Entity carries two parts each with one surface override. + var entity = new WorldEntity + { + Id = 10, + ServerGuid = 0xBEEF0001u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + PaletteOverride = palette, + MeshRefs = new[] + { + new MeshRef(0x01000010u, Matrix4x4.Identity) + { + SurfaceOverrides = new Dictionary + { + { 0x08000100u, 0u }, // surfaceId β†’ origTex (0 = none) + }, + }, + new MeshRef(0x01000020u, Matrix4x4.Identity) + { + SurfaceOverrides = new Dictionary + { + { 0x08000200u, 0x05000300u }, // with origTex override + }, + }, + }, + }; + + adapter.OnCreate(entity); + + // One call per surface-with-override: (0x08000100, null) and (0x08000200, 0x05000300). + Assert.Equal(2, cache.Calls.Count); + + Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000100u + && c.OrigTexOverride == null + && c.Palette == palette); + Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000200u + && c.OrigTexOverride == 0x05000300u + && c.Palette == palette); + } + + [Fact] + public void OnCreate_WithPaletteOverrideButNoSurfaceOverrides_DoesNotCallCache() + { + // Surfaces without SurfaceOverrides == null are decoded lazily at draw + // time; the adapter only pre-warms what it knows at spawn time. + var cache = new CapturingTextureCache(); + var adapter = MakeAdapter(cache); + + var entity = new WorldEntity + { + Id = 11, + ServerGuid = 0xBEEF0002u, + SourceGfxObjOrSetupId = 0x02000002u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + PaletteOverride = new PaletteOverride(0x04001235u, Array.Empty()), + // MeshRef with NO SurfaceOverrides. + MeshRefs = new[] { new MeshRef(0x01000011u, Matrix4x4.Identity) }, + }; + + adapter.OnCreate(entity); + + Assert.Empty(cache.Calls); + } + + [Fact] + public void OnCreate_WithoutPaletteOverride_DoesNotCallCache() + { + var cache = new CapturingTextureCache(); + var adapter = MakeAdapter(cache); + var entity = MakeEntity(id: 12, serverGuid: 0xBEEF0003u); + + adapter.OnCreate(entity); + + Assert.Empty(cache.Calls); + } + + // ── OnRemove ───────────────────────────────────────────────────────── + + [Fact] + public void OnRemove_ReleasesPerEntityState() + { + var adapter = MakeAdapter(); + var entity = MakeEntity(id: 20, serverGuid: 0xCAFE0001u); + + adapter.OnCreate(entity); + Assert.NotNull(adapter.GetState(0xCAFE0001u)); + + adapter.OnRemove(0xCAFE0001u); + Assert.Null(adapter.GetState(0xCAFE0001u)); + } + + [Fact] + public void OnRemove_UnknownGuid_NoOps() + { + var adapter = MakeAdapter(); + + // Must not throw. + adapter.OnRemove(0xDEADBEEFu); + } + + // ── Multiple entities ───────────────────────────────────────────────── + + [Fact] + public void OnCreate_MultipleEntities_EachGetsOwnState() + { + var adapter = MakeAdapter(); + var e1 = MakeEntity(id: 30, serverGuid: 0x11110001u); + var e2 = MakeEntity(id: 31, serverGuid: 0x11110002u); + + var s1 = adapter.OnCreate(e1); + var s2 = adapter.OnCreate(e2); + + Assert.NotNull(s1); + Assert.NotNull(s2); + Assert.NotSame(s1, s2); + Assert.Same(s1, adapter.GetState(0x11110001u)); + Assert.Same(s2, adapter.GetState(0x11110002u)); + } + + [Fact] + public void OnRemove_OnlyReleasesTargetGuid() + { + var adapter = MakeAdapter(); + var e1 = MakeEntity(id: 40, serverGuid: 0x22220001u); + var e2 = MakeEntity(id: 41, serverGuid: 0x22220002u); + + adapter.OnCreate(e1); + adapter.OnCreate(e2); + adapter.OnRemove(0x22220001u); + + Assert.Null(adapter.GetState(0x22220001u)); + Assert.NotNull(adapter.GetState(0x22220002u)); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static EntitySpawnAdapter MakeAdapter(ITextureCachePerInstance? cache = null) + { + cache ??= new CapturingTextureCache(); + return new EntitySpawnAdapter(cache, _ => MakeSequencer()); + } + + private static WorldEntity MakeEntity(uint id, uint serverGuid) + => new WorldEntity + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) }, + }; + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + // ── Mocks / stubs ───────────────────────────────────────────────────── + + /// + /// Capture every call to GetOrUploadWithPaletteOverride so tests can + /// assert without a live GL context. + /// + private sealed class CapturingTextureCache : ITextureCachePerInstance + { + public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette); + public List Calls { get; } = new(); + + public uint GetOrUploadWithPaletteOverride( + uint surfaceId, + uint? overrideOrigTextureId, + PaletteOverride paletteOverride) + { + Calls.Add(new Call(surfaceId, overrideOrigTextureId, paletteOverride)); + return 1u; // Fake GL handle. + } + } + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs new file mode 100644 index 00000000..63c29f7b --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs @@ -0,0 +1,56 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +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 = MakeState(); + state.HideParts(mask); + Assert.Equal(expected, state.IsPartHidden(partIdx)); + } + + [Fact] + public void IsPartHidden_NegativeIdx_ReturnsFalse() + { + var state = MakeState(); + state.HideParts(0xFFFF_FFFF_FFFF_FFFFul); + Assert.False(state.IsPartHidden(-1)); + } + + [Fact] + public void IsPartHidden_PartIdxOver64_ReturnsFalse() + { + var state = MakeState(); + state.HideParts(0xFFFF_FFFF_FFFF_FFFFul); + Assert.False(state.IsPartHidden(64)); + } + + [Fact] + public void HideParts_DefaultsToNoneHidden() + { + var state = MakeState(); + for (int i = 0; i < 64; i++) + Assert.False(state.IsPartHidden(i)); + } + + private static AnimatedEntityState MakeState() => new(MakeSequencer()); + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs new file mode 100644 index 00000000..85af235b --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class LandblockSpawnAdapterTests +{ + [Fact] + public void OnLandblockLoaded_RegistersIncrementForEachUniqueAtlasGfxObj() + { + var captured = new CapturingAdapterMock(); + var adapter = new LandblockSpawnAdapter(captured); + + // Two procedural (ServerGuid=0) entities with different GfxObj ids. + var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[] + { + MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }), + MakeAtlasEntity(id: 2, gfxObjIds: new[] { 0x01000030u }), + }); + + adapter.OnLandblockLoaded(lb); + + // Three unique ids registered. + Assert.Equal(3, captured.IncrementCalls.Count); + Assert.Contains(0x01000010ul, captured.IncrementCalls); + Assert.Contains(0x01000020ul, captured.IncrementCalls); + Assert.Contains(0x01000030ul, captured.IncrementCalls); + } + + [Fact] + public void OnLandblockLoaded_DedupsSharedIdsAcrossEntities() + { + var captured = new CapturingAdapterMock(); + var adapter = new LandblockSpawnAdapter(captured); + + var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[] + { + MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }), + MakeAtlasEntity(id: 2, gfxObjIds: new[] { 0x01000010u, 0x01000020u }), + }); + + adapter.OnLandblockLoaded(lb); + + // Two unique ids despite two entities sharing both. + Assert.Equal(2, captured.IncrementCalls.Count); + } + + [Fact] + public void OnLandblockLoaded_SkipsServerSpawnedEntities() + { + var captured = new CapturingAdapterMock(); + var adapter = new LandblockSpawnAdapter(captured); + + var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[] + { + MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }), + // ServerGuid != 0 β†’ per-instance tier β†’ must NOT register. + MakePerInstanceEntity(id: 2, serverGuid: 0xCAFE0001u, gfxObjIds: new[] { 0x01000020u }), + }); + + adapter.OnLandblockLoaded(lb); + + // Only the atlas-tier entity's GfxObj is registered. + Assert.Single(captured.IncrementCalls); + Assert.Contains(0x01000010ul, captured.IncrementCalls); + Assert.DoesNotContain(0x01000020ul, captured.IncrementCalls); + } + + [Fact] + public void OnLandblockUnloaded_RegistersMatchingDecrements() + { + var captured = new CapturingAdapterMock(); + var adapter = new LandblockSpawnAdapter(captured); + + var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[] + { + MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }), + }); + + adapter.OnLandblockLoaded(lb); + adapter.OnLandblockUnloaded(0x12340000u); + + Assert.Equal(captured.IncrementCalls.OrderBy(x => x), captured.DecrementCalls.OrderBy(x => x)); + } + + [Fact] + public void OnLandblockUnloaded_UnknownLandblock_NoOps() + { + var captured = new CapturingAdapterMock(); + var adapter = new LandblockSpawnAdapter(captured); + + adapter.OnLandblockUnloaded(0xDEADBEEFu); + + Assert.Empty(captured.DecrementCalls); + } + + [Fact] + public void OnLandblockLoaded_SameLandblockTwice_DedupesAtTheLandblockLevel() + { + // If a landblock load fires twice (e.g. a streaming-controller bug), + // we should not double-register. Second load is treated as a no-op + // for ref-counting purposes. + var captured = new CapturingAdapterMock(); + var adapter = new LandblockSpawnAdapter(captured); + + var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[] + { + MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }), + }); + + adapter.OnLandblockLoaded(lb); + adapter.OnLandblockLoaded(lb); + + // One unique id, one increment β€” not two. + Assert.Single(captured.IncrementCalls); + } + + // ── Test helpers ────────────────────────────────────────────────────── + + 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(uint landblockId, WorldEntity[] entities) + => new LoadedLandblock( + LandblockId: landblockId, + Heightmap: new DatReaderWriter.DBObjs.LandBlock(), // empty default + Entities: entities); + + private static WorldEntity MakeAtlasEntity(uint id, uint[] gfxObjIds) + => MakeEntity(id, serverGuid: 0u, gfxObjIds); + + private static WorldEntity MakePerInstanceEntity(uint id, uint serverGuid, uint[] gfxObjIds) + => MakeEntity(id, serverGuid, gfxObjIds); + + private static WorldEntity MakeEntity(uint id, uint serverGuid, uint[] gfxObjIds) + { + var meshRefs = gfxObjIds + .Select(g => new MeshRef(g, Matrix4x4.Identity)) + .ToList(); + return new WorldEntity + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = gfxObjIds.FirstOrDefault(), + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = meshRefs, + }; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs new file mode 100644 index 00000000..76715745 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs @@ -0,0 +1,64 @@ +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); + var restPose = Matrix4x4.CreateTranslation(1, 0, 0); + + var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose); + + var expected = restPose * animOverride * entityWorld; + AssertMatrixEqual(expected, result); + } + + [Fact] + public void Compose_IdentityAnim_EqualsRestTimesEntity() + { + var entityWorld = Matrix4x4.CreateFromQuaternion( + Quaternion.CreateFromYawPitchRoll(0.5f, 0, 0)) * + Matrix4x4.CreateTranslation(10, 20, 30); + var restPose = Matrix4x4.CreateTranslation(0.5f, -0.3f, 0.1f); + + var result = WbDrawDispatcher.ComposePartWorldMatrix( + entityWorld, Matrix4x4.Identity, restPose); + + var expected = restPose * entityWorld; + AssertMatrixEqual(expected, result); + } + + [Fact] + public void Compose_AllIdentity_ReturnsIdentity() + { + var result = WbDrawDispatcher.ComposePartWorldMatrix( + Matrix4x4.Identity, Matrix4x4.Identity, Matrix4x4.Identity); + + AssertMatrixEqual(Matrix4x4.Identity, result); + } + + private static void AssertMatrixEqual(Matrix4x4 expected, Matrix4x4 actual, float eps = 1e-5f) + { + Assert.Equal(expected.M11, actual.M11, eps); + Assert.Equal(expected.M12, actual.M12, eps); + Assert.Equal(expected.M13, actual.M13, eps); + Assert.Equal(expected.M14, actual.M14, eps); + Assert.Equal(expected.M21, actual.M21, eps); + Assert.Equal(expected.M22, actual.M22, eps); + Assert.Equal(expected.M23, actual.M23, eps); + Assert.Equal(expected.M24, actual.M24, eps); + Assert.Equal(expected.M31, actual.M31, eps); + Assert.Equal(expected.M32, actual.M32, eps); + Assert.Equal(expected.M33, actual.M33, eps); + Assert.Equal(expected.M34, actual.M34, eps); + Assert.Equal(expected.M41, actual.M41, eps); + Assert.Equal(expected.M42, actual.M42, eps); + Assert.Equal(expected.M43, actual.M43, eps); + Assert.Equal(expected.M44, actual.M44, eps); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs new file mode 100644 index 00000000..726f789f --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs @@ -0,0 +1,136 @@ +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib; +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(); + var poly = gfxObj.Polygons[0]; + poly.Stippling = StipplingType.Both; + // NegSurface=0 so the neg side references a valid surface entry. + poly.NegSurface = 0; + + var ours = GfxObjMesh.Build(gfxObj, dats: null); + + Assert.Equal(2, ours.Count); + } + + [Fact] + public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide() + { + var gfxObj = MakeUnitQuadGfxObj(); + var poly = gfxObj.Polygons[0]; + poly.Stippling = StipplingType.None; + poly.SidesType = CullMode.Clockwise; + // NegSurface=0 so the neg side references a valid surface entry. + 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; + // NegSurface=0 so the neg side references a valid surface entry. + poly.NegSurface = 0; + + var ours = GfxObjMesh.Build(gfxObj, dats: null); + + Assert.Single(ours); + } + + /// + /// Build a synthetic 1Γ—1 quad GfxObj with vertex sequence [0,1,2,3] + /// at corners (0,0,0)/(1,0,0)/(1,1,0)/(0,1,0). PosSurface=0, + /// NegSurface=-1 (invalid β€” pos side only by default). + /// No Stippling flags set by default β€” caller may add them per test. + /// + private static GfxObj MakeUnitQuadGfxObj() + { + var gfx = new GfxObj { Surfaces = { 0x08000000u } }; + gfx.VertexArray = new VertexArray + { + VertexType = VertexType.CSWVertexType, + Vertices = + { + [0] = new SWVertex + { + Origin = new Vector3(0, 0, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 0, V = 0 } }, + }, + [1] = new SWVertex + { + Origin = new Vector3(1, 0, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 1, V = 0 } }, + }, + [2] = new SWVertex + { + Origin = new Vector3(1, 1, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 1, V = 1 } }, + }, + [3] = new SWVertex + { + Origin = new Vector3(0, 1, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 0, V = 1 } }, + }, + }, + }; + + var poly = new Polygon + { + VertexIds = { 0, 1, 2, 3 }, + PosUVIndices = { 0, 0, 0, 0 }, + PosSurface = 0, + NegSurface = -1, // invalid index β€” pos side only + Stippling = StipplingType.None, + SidesType = CullMode.None, + }; + gfx.Polygons[0] = poly; + return gfx; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs new file mode 100644 index 00000000..a02f0808 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.World; + +namespace AcDream.Core.Tests.Rendering.Wb; + +/// +/// Integration: verifies the pending-spawn list mechanism keeps working +/// after Task 12 wired LandblockSpawnAdapter into GpuWorldState. Server- +/// spawned entities (ServerGuid != 0) park in pending β†’ drain on +/// AddLandblock β†’ end up in the flat view, but they are NEVER registered +/// with the WB adapter (they're per-instance tier). +/// +/// The adapter SHOULD see atlas-tier entities (ServerGuid == 0) that +/// arrived in the AddLandblock's payload directly. +/// +public sealed class PendingSpawnIntegrationTests +{ + /// + /// Force-enable WbFoundationFlag for this test class. + /// GpuWorldState gates its adapter calls on this static-cached flag; + /// calling the internal test hook lets us exercise the full integration + /// path without needing the env var set before process startup. + /// + static PendingSpawnIntegrationTests() + { + WbFoundationFlag.ForTestsOnly_ForceEnable(); + } + + [Fact] + public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter() + { + var captured = new CapturingAdapterMock(); + var spawnAdapter = new LandblockSpawnAdapter(captured); + var state = new GpuWorldState(spawnAdapter); + + // Park a live (server-spawned) entity for landblock 0x1234FFFF BEFORE + // the landblock streams in. ServerGuid != 0 makes this per-instance-tier. + var liveEntity = MakeServerSpawned( + id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u); + // AppendLiveEntity takes the raw cell-form id; it canonicalises internally. + state.AppendLiveEntity(0x12340011u, liveEntity); + + Assert.Equal(1, state.PendingLiveEntityCount); + Assert.Empty(captured.IncrementCalls); // not registered yet β€” landblock not loaded + + // Now landblock arrives with ONE atlas-tier entity that brings its own + // GfxObj, plus the pending live entity drains into it. + var atlasEntity = MakeAtlas(id: 2, gfxObjId: 0x01000010u); + var lb = new LoadedLandblock( + LandblockId: 0x1234FFFFu, + Heightmap: new DatReaderWriter.DBObjs.LandBlock(), + Entities: new[] { atlasEntity }); + state.AddLandblock(lb); + + // Pending drained. + Assert.Equal(0, state.PendingLiveEntityCount); + + // Flat view contains both: the atlas one from the load + the drained pending. + var allIds = state.Entities.Select(e => e.Id).ToHashSet(); + Assert.Contains(1u, allIds); // pending entity + Assert.Contains(2u, allIds); // landblock entity + + // Adapter only saw the atlas-tier GfxObj. The pending server-spawned + // entity's GfxObj is NOT registered (filtered by ServerGuid != 0 in + // LandblockSpawnAdapter). + Assert.Single(captured.IncrementCalls); + Assert.Contains(0x01000010ul, captured.IncrementCalls); + Assert.DoesNotContain(0x01000099ul, captured.IncrementCalls); + } + + [Fact] + public void LiveEntity_AfterLandblock_RegistersImmediatelyWithoutAdapterCall() + { + // When a CreateObject arrives for an already-loaded landblock, it goes + // straight into the flat view (not through pending). Adapter is NOT + // re-invoked because the landblock load already happened. + var captured = new CapturingAdapterMock(); + var spawnAdapter = new LandblockSpawnAdapter(captured); + var state = new GpuWorldState(spawnAdapter); + + var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u); + var lb = new LoadedLandblock( + LandblockId: 0x1234FFFFu, + Heightmap: new DatReaderWriter.DBObjs.LandBlock(), + Entities: new[] { atlasEntity }); + state.AddLandblock(lb); + + Assert.Single(captured.IncrementCalls); // atlas registered + + // Now a live entity arrives β€” landblock is already loaded. + var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u); + state.AppendLiveEntity(0x12340022u, liveEntity); + + // Adapter not invoked again β€” AppendLiveEntity doesn't drive ref counts. + Assert.Single(captured.IncrementCalls); + Assert.Equal(0, state.PendingLiveEntityCount); + } + + [Fact] + public void LandblockUnload_ReleasesAtlasIds_PendingDoesNotRegress() + { + var captured = new CapturingAdapterMock(); + var spawnAdapter = new LandblockSpawnAdapter(captured); + var state = new GpuWorldState(spawnAdapter); + + var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u); + var lb = new LoadedLandblock( + LandblockId: 0x1234FFFFu, + Heightmap: new DatReaderWriter.DBObjs.LandBlock(), + Entities: new[] { atlasEntity }); + state.AddLandblock(lb); + state.RemoveLandblock(0x1234FFFFu); + + Assert.Equal( + captured.IncrementCalls.OrderBy(x => x), + captured.DecrementCalls.OrderBy(x => x)); + } + + // ── Test helpers ────────────────────────────────────────────────────── + + 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 WorldEntity MakeAtlas(uint id, uint gfxObjId) + => MakeEntity(id, serverGuid: 0u, gfxObjId); + + private static WorldEntity MakeServerSpawned(uint id, uint serverGuid, uint gfxObjId) + => MakeEntity(id, serverGuid, gfxObjId); + + private static WorldEntity MakeEntity(uint id, uint serverGuid, uint gfxObjId) + => new WorldEntity + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = gfxObjId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) }, + }; +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs new file mode 100644 index 00000000..07bc8b19 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs @@ -0,0 +1,105 @@ +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 = { 0x01000001u } }; + // PlacementFrames deliberately empty β€” no DefaultScale entry either, + // so scale defaults to Vector3.One and the fallback frame is + // (Origin=Zero, Orientation=Identity) β†’ Identity matrix. + + var refs = SetupMesh.Flatten(setup); + + Assert.Single(refs); + Assert.Equal(0x01000001u, refs[0].GfxObjId); + Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform); + } + + [Fact] + public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation() + { + var setup = new Setup { Parts = { 0x01000001u } }; + setup.PlacementFrames[Placement.Default] = new AnimationFrame(1) + { + Frames = + { + new Frame + { + Origin = new Vector3(10, 20, 30), + Orientation = Quaternion.Identity, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation); + } + + [Fact] + public void Flatten_WithRestingFrame_PrefersRestingOverDefault() + { + var setup = new Setup { Parts = { 0x01000001u } }; + setup.PlacementFrames[Placement.Default] = new AnimationFrame(1) + { + Frames = { new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity } }, + }; + setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1) + { + Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } }, + }; + + var refs = SetupMesh.Flatten(setup); + + Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation); + } + + [Fact] + public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting() + { + var setup = new Setup { Parts = { 0x01000001u } }; + setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1) + { + Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } }, + }; + + var motionOverride = new AnimationFrame(1) + { + 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 = { 0x01000001u, 0x01000002u }, + DefaultScale = { new Vector3(2, 2, 2), new Vector3(3, 3, 3) }, + }; + // No placement frames β€” fallback frame is identity pose; scale still applies. + + var refs = SetupMesh.Flatten(setup); + + Assert.Equal(2f, refs[0].PartTransform.M11); + Assert.Equal(3f, refs[1].PartTransform.M11); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs new file mode 100644 index 00000000..1053f857 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs @@ -0,0 +1,65 @@ +using System; +using AcDream.App.Rendering.Wb; +using Microsoft.Extensions.Logging.Abstractions; +using Silk.NET.OpenGL; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class WbMeshAdapterTests +{ + [Fact] + public void Construct_WithNullGl_ThrowsArgumentNull() + { + // GL is the first guarded parameter; verifies the constructor validates inputs. + // We can't pass a real GL (no context in tests), so we verify only the + // null-GL guard. The real pipeline is tested via integration. + Assert.Throws(() => + new WbMeshAdapter(gl: null!, datDir: "some/path", dats: null!, logger: NullLogger.Instance)); + } + + [Fact] + public void Dispose_OnUninitializedAdapter_DoesNotThrow() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + adapter.Dispose(); // no-op when fields are null + adapter.Dispose(); // idempotent + } + + [Fact] + public void IncrementRefCount_OnUninitializedAdapter_NoOps() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + // Should not throw, even though there's no underlying mesh manager. + adapter.IncrementRefCount(0x01000001ul); + } + + [Fact] + public void DecrementRefCount_OnUninitializedAdapter_NoOps() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + adapter.DecrementRefCount(0x01000001ul); + } + + [Fact] + public void GetRenderData_OnUninitializedAdapter_ReturnsNull() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + Assert.Null(adapter.GetRenderData(0x01000001ul)); + } + + [Fact] + public void Tick_OnUninitializedAdapter_DoesNotThrow() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + adapter.Tick(); // no-op, no throw + adapter.Tick(); // idempotent + } + + [Fact] + public void Tick_AfterDispose_DoesNotThrow() + { + var adapter = WbMeshAdapter.CreateUninitialized(); + adapter.Dispose(); + adapter.Tick(); // no-op, no throw + } +} diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index 8110bfce..7a440e0a 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -56,12 +56,10 @@ public class SurfaceDecoderTests } [Fact] - public void Decode_A8_ExpandsSingleByteToRgbaWithAlphaInAllChannels() + public void Decode_A8_NonAdditive_ProducesWhitePlusAlpha() { - // PFID_A8 is single-byte-per-pixel alpha. AC terrain blending alpha maps - // are stored this way. WorldBuilder's GetExpandedAlphaTexture replicates - // the byte into all four RGBA channels so fragment shaders can read the - // blend value from any channel (convention: the alpha channel). + // Default (isAdditive: false) = WB FillA8 semantics: R=G=B=255, A=val. + // Used for non-additive entity surfaces where A8 is a pure alpha channel. var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image var rs = new RenderSurface { @@ -76,7 +74,34 @@ public class SurfaceDecoderTests Assert.Equal(2, decoded.Width); Assert.Equal(2, decoded.Height); Assert.Equal(16, decoded.Rgba8.Length); - // Each input byte expands to (b, b, b, b) in RGBA output + // Each input byte expands to (255, 255, 255, val) β€” white with varying alpha + Assert.Equal(new byte[] + { + 255, 255, 255, 0x00, + 255, 255, 255, 0x40, + 255, 255, 255, 0x80, + 255, 255, 255, 0xFF, + }, decoded.Rgba8); + } + + [Fact] + public void Decode_A8_Additive_ReplicatesByteToAllChannels() + { + // isAdditive=true = WB FillA8Additive semantics: R=G=B=A=val. + // Used for terrain blending alpha masks (TerrainAtlas always passes isAdditive:true). + var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = PixelFormat.PFID_A8, + SourceData = src, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true); + + Assert.Equal(16, decoded.Rgba8.Length); + // Each input byte fans out to all four channels Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00, @@ -92,7 +117,7 @@ public class SurfaceDecoderTests // PFID_CUSTOM_LSCAPE_ALPHA (0xF4) is AC's custom format for terrain // blending alpha maps. Pixel layout is identical to PFID_A8 β€” one // byte of alpha per pixel β€” so the decoder routes both through the - // same DecodeA8 implementation. + // same DecodeA8 implementation. Default (isAdditive:false) β†’ R=G=B=255, A=val. var src = new byte[] { 0x10, 0x20, 0x30, 0x40 }; // 2x2 var rs = new RenderSurface { @@ -107,10 +132,10 @@ public class SurfaceDecoderTests Assert.Equal(16, decoded.Rgba8.Length); Assert.Equal(new byte[] { - 0x10, 0x10, 0x10, 0x10, - 0x20, 0x20, 0x20, 0x20, - 0x30, 0x30, 0x30, 0x30, - 0x40, 0x40, 0x40, 0x40, + 255, 255, 255, 0x10, + 255, 255, 255, 0x20, + 255, 255, 255, 0x30, + 255, 255, 255, 0x40, }, decoded.Rgba8); } diff --git a/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs b/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs new file mode 100644 index 00000000..b7fb62a4 --- /dev/null +++ b/tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs @@ -0,0 +1,369 @@ +using Chorizite.OpenGLSDLBackend.Lib; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Textures; + +/// +/// Conformance tests proving byte-identical output between our hand-rolled +/// SurfaceDecoder paths and WorldBuilder's TextureHelpers.Fill* methods. +/// These tests run BEFORE any substitution β€” they prove equivalence first. +/// If a test fails, the formats diverge and that's a real finding. +/// +public class TextureDecodeConformanceTests +{ + // ---- helpers --------------------------------------------------------------- + + private static Palette MakePalette(params ColorARGB[] colors) + { + var pal = new Palette(); + foreach (var c in colors) + pal.Colors.Add(c); + return pal; + } + + private static ColorARGB Rgba(byte r, byte g, byte b, byte a = 0xFF) + => new ColorARGB { Red = r, Green = g, Blue = b, Alpha = a }; + + // Inline our current DecodeIndex16 logic for the conformance baseline. + private static byte[] OurDecodeIndex16(byte[] src, Palette palette, int width, int height, bool isClipMap = false) + { + var rgba = new byte[width * height * 4]; + int paletteMax = palette.Colors.Count - 1; + for (int i = 0; i < width * height; i++) + { + int s = i * 2; + ushort idx = (ushort)(src[s] | (src[s + 1] << 8)); + if (idx > paletteMax) idx = 0; + var c = palette.Colors[idx]; + int d = i * 4; + if (isClipMap && idx < 8) + { + rgba[d + 0] = 0; + rgba[d + 1] = 0; + rgba[d + 2] = 0; + rgba[d + 3] = 0; + } + else + { + rgba[d + 0] = c.Red; + rgba[d + 1] = c.Green; + rgba[d + 2] = c.Blue; + rgba[d + 3] = c.Alpha; + } + } + return rgba; + } + + // Inline our current DecodeP8 logic. + private static byte[] OurDecodeP8(byte[] src, Palette palette, int width, int height, bool isClipMap = false) + { + var rgba = new byte[width * height * 4]; + int paletteMax = palette.Colors.Count - 1; + for (int i = 0; i < width * height; i++) + { + int idx = src[i]; + if (idx > paletteMax) idx = 0; + var c = palette.Colors[idx]; + int d = i * 4; + if (isClipMap && idx < 8) + { + rgba[d + 0] = 0; + rgba[d + 1] = 0; + rgba[d + 2] = 0; + rgba[d + 3] = 0; + } + else + { + rgba[d + 0] = c.Red; + rgba[d + 1] = c.Green; + rgba[d + 2] = c.Blue; + rgba[d + 3] = c.Alpha; + } + } + return rgba; + } + + // Inline our current DecodeA8R8G8B8 logic (BGRA on-disk β†’ RGBA). + private static byte[] OurDecodeA8R8G8B8(byte[] src, int width, int height) + { + var rgba = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + int s = i * 4; + rgba[s + 0] = src[s + 2]; // R + rgba[s + 1] = src[s + 1]; // G + rgba[s + 2] = src[s + 0]; // B + rgba[s + 3] = src[s + 3]; // A + } + return rgba; + } + + // Inline our current DecodeR8G8B8 logic (BGR on-disk β†’ RGBA with A=255). + private static byte[] OurDecodeR8G8B8(byte[] src, int width, int height) + { + var rgba = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + int s = i * 3; + int d = i * 4; + rgba[d + 0] = src[s + 2]; // R + rgba[d + 1] = src[s + 1]; // G + rgba[d + 2] = src[s + 0]; // B + rgba[d + 3] = 0xFF; // A = opaque + } + return rgba; + } + + // Inline our current DecodeA8 logic (R=G=B=A=val β€” "additive" mode). + private static byte[] OurDecodeA8(byte[] src, int width, int height) + { + var rgba = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + byte a = src[i]; + int d = i * 4; + rgba[d + 0] = a; + rgba[d + 1] = a; + rgba[d + 2] = a; + rgba[d + 3] = a; + } + return rgba; + } + + // ---- tests ----------------------------------------------------------------- + + /// + /// Test 1: INDEX16 normal mode β€” 2Γ—2 image with two palette entries. + /// WB's FillIndex16 and our DecodeIndex16 must produce identical RGBA bytes. + /// + [Fact] + public void FillIndex16_MatchesOurDecodeIndex16() + { + // 2Γ—2 INDEX16: pixels 0,1,1,0 (indices into a 2-color palette) + var src = new byte[] + { + 0x00, 0x00, // pixel(0,0) β†’ palette index 0 + 0x01, 0x00, // pixel(1,0) β†’ palette index 1 + 0x01, 0x00, // pixel(0,1) β†’ palette index 1 + 0x00, 0x00, // pixel(1,1) β†’ palette index 0 + }; + var palette = MakePalette( + Rgba(0xFF, 0x00, 0x00), // index 0 = red + Rgba(0x00, 0x00, 0xFF) // index 1 = blue + ); + + var expected = OurDecodeIndex16(src, palette, 2, 2); + + var actual = new byte[2 * 2 * 4]; + TextureHelpers.FillIndex16(src, palette, actual, 2, 2); + + Assert.Equal(expected, actual); + } + + /// + /// Test 2: INDEX16 clipmap mode β€” indices below 8 must produce transparent pixels. + /// Both implementations share the same clipmap alpha-key convention from retail ACViewer. + /// + [Fact] + public void FillIndex16_ClipMap_MatchesOurClipMapBehavior() + { + // 4Γ—1 INDEX16: indices 0, 1, 7, 8 + // In clipmap mode, indices 0..7 β†’ transparent; index 8 β†’ palette color. + var src = new byte[] + { + 0x00, 0x00, // index 0 β†’ transparent + 0x01, 0x00, // index 1 β†’ transparent + 0x07, 0x00, // index 7 β†’ transparent + 0x08, 0x00, // index 8 β†’ opaque + }; + // Build a 16-entry palette so indices 0–8 are all valid. + var palette = new Palette(); + for (int i = 0; i < 16; i++) + palette.Colors.Add(Rgba(0xAA, 0xBB, 0xCC)); + + var expected = OurDecodeIndex16(src, palette, 4, 1, isClipMap: true); + + var actual = new byte[4 * 1 * 4]; + TextureHelpers.FillIndex16(src, palette, actual, 4, 1, isClipMap: true); + + Assert.Equal(expected, actual); + } + + /// + /// Test 3: P8 (8-bit palette index) β€” 2Γ—2 image. + /// WB FillP8 and our DecodeP8 must produce identical RGBA output. + /// + [Fact] + public void FillP8_MatchesOurDecodeP8() + { + // 2Γ—2 P8: bytes are direct palette indices + var src = new byte[] { 0x00, 0x01, 0x01, 0x00 }; + var palette = MakePalette( + Rgba(0x10, 0x20, 0x30), // index 0 + Rgba(0x40, 0x50, 0x60) // index 1 + ); + + var expected = OurDecodeP8(src, palette, 2, 2); + + var actual = new byte[2 * 2 * 4]; + TextureHelpers.FillP8(src, palette, actual, 2, 2); + + Assert.Equal(expected, actual); + } + + /// + /// Test 4: A8R8G8B8 (BGRA on-disk β†’ RGBA) β€” 2Γ—1 image. + /// WB FillA8R8G8B8 and our DecodeA8R8G8B8 both swap B↔R. + /// + [Fact] + public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8() + { + // On-disk layout: B, G, R, A per pixel + var src = new byte[] + { + 0x00, 0x00, 0xFF, 0xFF, // pixel 0: B=0, G=0, R=255, A=255 β†’ red + 0xFF, 0x00, 0x00, 0x80, // pixel 1: B=255, G=0, R=0, A=128 β†’ blue, semi-transparent + }; + + var expected = OurDecodeA8R8G8B8(src, 2, 1); + + var actual = new byte[2 * 1 * 4]; + TextureHelpers.FillA8R8G8B8(src, actual, 2, 1); + + Assert.Equal(expected, actual); + } + + /// + /// Test 5: R8G8B8 (BGR on-disk β†’ RGBA, alpha forced 255) β€” 2Γ—1 image. + /// Both implementations output R,G,B,255 for each 3-byte BGR triple. + /// + [Fact] + public void FillR8G8B8_MatchesOurDecodeR8G8B8() + { + // On-disk layout: B, G, R per pixel (24-bit BGR) + var src = new byte[] + { + 0x00, 0x00, 0xFF, // pixel 0: B=0, G=0, R=255 β†’ red + 0x00, 0xFF, 0x00, // pixel 1: B=0, G=255, R=0 β†’ green + }; + + var expected = OurDecodeR8G8B8(src, 2, 1); + + var actual = new byte[2 * 1 * 4]; + TextureHelpers.FillR8G8B8(src, actual, 2, 1); + + Assert.Equal(expected, actual); + } + + /// + /// Test 6: A8 in additive mode β€” FillA8Additive replicates the byte into all four + /// channels (R=G=B=A=val). This is identical to our current DecodeA8 behavior, + /// which is used for terrain blending alpha masks. + /// + [Fact] + public void FillA8Additive_MatchesOurDecodeA8() + { + // 4Γ—1 single-byte-per-pixel alpha values + var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; + + var expected = OurDecodeA8(src, 4, 1); + + var actual = new byte[4 * 1 * 4]; + TextureHelpers.FillA8Additive(src, actual, 4, 1); + + Assert.Equal(expected, actual); + // Spot-check: each input byte fans out to all four channels + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, actual[0..4]); + Assert.Equal(new byte[] { 0x40, 0x40, 0x40, 0x40 }, actual[4..8]); + Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, actual[12..16]); + } + + /// + /// Test 7: A8 non-additive (FillA8) documents WB's behavior that DIFFERS from ours. + /// WB's FillA8 sets R=G=B=255 and A=input_byte. + /// Our DecodeA8 sets R=G=B=A=input_byte (the additive mode, used for terrain blending). + /// This test proves the divergence exists and documents the WB behavior explicitly. + /// + [Fact] + public void FillA8_NonAdditive_ProducesWhitePlusAlpha() + { + var src = new byte[] { 0x00, 0x80, 0xFF }; // 3Γ—1 + + var actual = new byte[3 * 1 * 4]; + TextureHelpers.FillA8(src, actual, 3, 1); + + // WB non-additive: R=G=B=255, A=input byte + Assert.Equal(new byte[] { 255, 255, 255, 0x00 }, actual[0..4]); // alpha=0 + Assert.Equal(new byte[] { 255, 255, 255, 0x80 }, actual[4..8]); // alpha=128 + Assert.Equal(new byte[] { 255, 255, 255, 0xFF }, actual[8..12]); // alpha=255 + + // Confirm this DIFFERS from our current DecodeA8 behavior (R=G=B=A=val). + var ourDecoded = OurDecodeA8(src, 3, 1); + Assert.NotEqual(ourDecoded, actual); // divergence is intentional β€” both are documented + } + + /// + /// Test 8: R5G6B5 (16-bit packed RGB, no alpha) β€” WB format we don't implement yet. + /// Verifies the expected bit-expansion: 5-bit red β†’ 8-bit by left-shifting 3, + /// 6-bit green β†’ 8-bit by left-shifting 2, 5-bit blue β†’ 8-bit by left-shifting 3. + /// Alpha is always 255. + /// + [Fact] + public void FillR5G6B5_ProducesExpectedRgba() + { + // Encode a single pixel: R=0x1F (31), G=0x3F (63), B=0x1F (31) + // Packed as 16-bit little-endian: bits 15-11=R, 10-5=G, 4-0=B + // val = (0x1F << 11) | (0x3F << 5) | 0x1F = 0xFFFF + var src = new byte[] { 0xFF, 0xFF }; // 1Γ—1 pixel: all channels maxed + + var actual = new byte[1 * 1 * 4]; + TextureHelpers.FillR5G6B5(src, actual, 1, 1); + + // R = (0x1F << 3) = 0xF8, G = (0x3F << 2) = 0xFC, B = (0x1F << 3) = 0xF8, A = 255 + Assert.Equal((byte)0xF8, actual[0]); // R + Assert.Equal((byte)0xFC, actual[1]); // G + Assert.Equal((byte)0xF8, actual[2]); // B + Assert.Equal((byte)255, actual[3]); // A always opaque + + // Test a second pixel: pure red = R=31, G=0, B=0 + // val = (0x1F << 11) = 0xF800 + var srcRed = new byte[] { 0x00, 0xF8 }; // little-endian 0xF800 + var actualRed = new byte[4]; + TextureHelpers.FillR5G6B5(srcRed, actualRed, 1, 1); + Assert.Equal((byte)0xF8, actualRed[0]); // R = 31 << 3 = 0xF8 + Assert.Equal((byte)0x00, actualRed[1]); // G = 0 + Assert.Equal((byte)0x00, actualRed[2]); // B = 0 + Assert.Equal((byte)255, actualRed[3]); // A + } + + /// + /// Test 9: A4R4G4B4 (16-bit packed ARGB, 4 bits per channel) β€” WB format we don't implement yet. + /// Each 4-bit value is expanded to 8-bit by multiplying by 17 (0x11), + /// so 0xF β†’ 255, 0x8 β†’ 136, 0x0 β†’ 0. + /// Bit layout: val bits 15-12=A, 11-8=R, 7-4=G, 3-0=B. + /// + [Fact] + public void FillA4R4G4B4_ProducesExpectedRgba() + { + // Encode one pixel: A=0xF(255), R=0xA(170), G=0x5(85), B=0x0(0) + // val = (0xF << 12) | (0xA << 8) | (0x5 << 4) | 0x0 = 0xFA50 + // little-endian bytes: 0x50, 0xFA + var src = new byte[] { 0x50, 0xFA }; // 1Γ—1 + + var actual = new byte[1 * 1 * 4]; + TextureHelpers.FillA4R4G4B4(src, actual, 1, 1); + + // R = 0xA * 17 = 170, G = 0x5 * 17 = 85, B = 0x0 * 17 = 0, A = 0xF * 17 = 255 + Assert.Equal((byte)(0xA * 17), actual[0]); // R = 170 + Assert.Equal((byte)(0x5 * 17), actual[1]); // G = 85 + Assert.Equal((byte)(0x0 * 17), actual[2]); // B = 0 + Assert.Equal((byte)(0xF * 17), actual[3]); // A = 255 + + // Also test the zero case: all channels 0 + var srcZero = new byte[] { 0x00, 0x00 }; + var actualZero = new byte[4]; + TextureHelpers.FillA4R4G4B4(srcZero, actualZero, 1, 1); + Assert.Equal(new byte[] { 0, 0, 0, 0 }, actualZero); + } +}