Compare commits

..

No commits in common. "c1e31148bbc66e206c02c9a9d1df1253596398d0" and "1978ef9395bf281672bdbe57d041a46ecc481908" have entirely different histories.

40 changed files with 166 additions and 7940 deletions

1
.gitignore vendored
View file

@ -29,7 +29,6 @@ references/*
launch.log launch.log
launch-*.log launch-*.log
launch.utf8.log launch.utf8.log
n4-verify*.log
# ImGui auto-saved window/docking state (per-user, not source) # ImGui auto-saved window/docking state (per-user, not source)
imgui.ini imgui.ini

View file

@ -25,54 +25,19 @@ single source of truth for how the client is structured. All work must
align with this document. When the architecture doc and reality diverge, align with this document. When the architecture doc and reality diverge,
update one or the other — never leave them out of sync. update one or the other — never leave them out of sync.
**WorldBuilder is acdream's rendering + dat-handling base, integrated **WorldBuilder is acdream's rendering + dat-handling base** as of
as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the 2026-05-08. Before re-implementing any AC-specific rendering or
production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher` dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md`
is the production draw path (default-on, see `WbFoundationFlag`). Before FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into
re-implementing any AC-specific rendering or dat-handling algorithm, our fork once wired up), not from retail decomp. WorldBuilder is
**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If MIT-licensed, verified to render the world correctly, and uses the same
WorldBuilder has it, port from WorldBuilder (or call into our fork via Silk.NET stack we target. Re-porting from retail decomp when WB already
the adapter), not from retail decomp. WorldBuilder is MIT-licensed, has a tested port is how subtle bugs (the scenery edge-vertex bug, the
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 triangle-Z bug) keep slipping in. Retail decomp remains the oracle for
network, physics, animation, movement, UI, plugin, audio, chat — see network, physics, animation, movement, UI, plugin, audio, chat — see
the inventory doc's 🔴 list for the full scope of "we still write this the inventory doc's 🔴 list for the full scope of "we still write this
ourselves". 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 **Execution phases:** R1→R8 in the architecture doc. Each phase has clear
goals, test criteria, and builds on the previous. Don't skip phases. goals, test criteria, and builds on the previous. Don't skip phases.
@ -472,20 +437,6 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user acceptance criteria. Do not drift from the spec without explicit user
approval. 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:** **Rules:**
1. Before starting a new phase or sub-piece, re-read the roadmap and the 1. Before starting a new phase or sub-piece, re-read the roadmap and the

View file

@ -46,63 +46,6 @@ Copy this block when adding a new issue:
# Active issues # 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 ## #50 — Road-edge tree at 0xA9B1 visible in acdream but not retail
**Status:** OPEN **Status:** OPEN

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # acdream — strategic roadmap
**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). **Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
**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. **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,8 +58,6 @@
| 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 ✓ | | 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 ✓ | | 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.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: Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost - FlyCamera default speed lowered + Shift-to-boost
@ -575,105 +573,52 @@ for our deletions/additions; merge upstream `master` periodically.
formula was ~180° off from retail's actual `Frame::set_heading` atan2 formula was ~180° off from retail's actual `Frame::set_heading` atan2
round-trip). One known cosmetic difference filed in ISSUES.md round-trip). One known cosmetic difference filed in ISSUES.md
(road-edge tree at landblock 0xA9B1). (road-edge tree at landblock 0xA9B1).
- **N.2 — Terrain math helpers.** ⚠️ **Blocked on N.5 — do not attempt - **N.2 — Terrain math helpers.** Refactor `TerrainSurface.SampleZ` /
in isolation.** Originally scoped as a 1-2 day low-risk substitution `SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight`
of `TerrainSurface.SampleZ` / `SampleSurface` / `SampleSurfacePolygon` / `GetNormal` internally. ~1-2 days. Smallest remaining N phase, low
with WB's `TerrainUtils.GetHeight` / `GetNormal`. Audit during N.3 risk after N.1's conformance proof on GetNormal.
follow-up uncovered that **WB's `CalculateSplitDirection` uses a - **N.3 — Texture decoding.** Replace our `TextureCache` decode
different formula than retail's `FSplitNESW`** (the AC2D-cited pipeline (`src/AcDream.App/Rendering/TextureCache.cs`) with WB's
polynomial `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25` `TextureHelpers` (INDEX16, P8, BGRA, DXT, alpha). Touches every
that our visual terrain mesh and physics already share). The texture path. **Realistic estimate: 3-5 days** (was 2-3) — the GL
formulas pick different cell-diagonals on disputed cells, producing upload path needs adapting and we'll need conformance tests per
up to ~2m Z divergence at the same world position. Substituting texture format. Handoff doc:
physics-side alone would un-sync physics from the still-ours visual `docs/research/2026-05-08-phase-n3-handoff.md`.
mesh — exactly the triangle-Z hover bug class. N.1's conformance - **N.4 — Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs`
test proved WB's `GetNormal` is good enough for slope-filtering with calls to WB's `ObjectMeshManager`. Character-appearance
(boolean walkable check) but NOT that WB's height formula matches behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain
retail. Resolution: fold this work into **N.5** when the visual ours — ACME is the secondary oracle. **Realistic estimate: 1.5-2
mesh switches to WB's renderer in lockstep with physics. Until weeks** (was 1) — character appearance edge cases like N.1's
then, leave `TerrainSurface` alone. See ISSUE #51. rotation bug will surface.
- **✓ SHIPPED — N.3 — Texture decoding.** Shipped 2026-05-08. `SurfaceDecoder` - **N.5 — Terrain rendering.** Replace `TerrainChunkRenderer` +
now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8 to WB's `TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` +
`TextureHelpers.Fill*`. The A8 divergence (our old code did R=G=B=A=val `LandSurfaceManager` + `TerrainGeometryGenerator`. **Realistic
always; WB splits additive vs non-additive) was resolved by threading an estimate: 3-4 weeks** (was 2) — largest single phase, GPU-buffer
`isAdditive` parameter through `DecodeRenderSurface`: terrain alpha masks ownership shifts, integration with our streaming loader is
pass `isAdditive: true` (matches our prior behavior, preserves the non-trivial.
shader's `.r` blend-weight read), entity surfaces pass - **N.6 — Static objects rendering.** Replace `StaticMeshRenderer` +
`surface.Type.HasFlag(SurfaceType.Additive)`. Bonus: R5G6B5 + A4R4G4B4 `InstancedMeshRenderer` with WB's `StaticObjectRenderManager`.
formats now decode (previously fell to magenta). X8R8G8B8, DXT1/3/5, and **Realistic estimate: 2-3 weeks** (was 2) — interacts with N.4
SolidColor remain ours (no WB equivalent). **9 conformance tests prove output.
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 - **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
`EnvCellRenderManager` + `PortalRenderManager` on top of N.4's `EnvCellRenderManager` + `PortalRenderManager`. **Realistic
foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now estimate: 2-3 weeks** (was 2).
that infrastructure is shared).
- **N.8 — Sky + particles.** Replace sky rendering + particle pipeline - **N.8 — Sky + particles.** Replace sky rendering + particle pipeline
(#36 / C.1 work) with WB's `SkyboxRenderManager` + (#36 / C.1 work) with WB's `SkyboxRenderManager` +
`ParticleEmitterRenderer`. **Estimate: ~1 week** (was 1.5-2 — C.1 `ParticleEmitterRenderer`. **Realistic estimate: 1.5-2 weeks**
already shipped most of this; N.8 is glue + sampler-object reuse). (was 1) — visual continuity matters; we just shipped C.1 and that
work flows through here.
- **N.9 — Visibility / culling.** Replace `CellVisibility` + - **N.9 — Visibility / culling.** Replace `CellVisibility` +
`FrustumCuller` with WB's `VisibilityManager`. **Estimate: ~1 week** `FrustumCuller` with WB's `VisibilityManager`. **Realistic
(was 3-5 days, slight bump for streaming-loader interaction). estimate: 1 week** (was 3-5 days) — affects perf and what gets
drawn.
- **N.10 — GL infrastructure consolidation (optional).** Replace our - **N.10 — GL infrastructure consolidation (optional).** Replace our
`Shader` / `TextureCache` / `SamplerCache` plumbing with WB's `Shader` / `TextureCache` / `SamplerCache` plumbing with WB's
`ManagedGL*` wrappers + `OpenGLGraphicsDevice`. **Largely subsumed by `ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week.
N.4** — `OpenGLGraphicsDevice` arrives as the host of `ObjectMeshManager`
and atlas. May not need a dedicated phase; revisit after N.6.
**Estimated calendar:** **2.5-3 months / 9-13 engineering weeks for **Estimated calendar:** **3-4 months / 10-12 engineering weeks for
N.4-N.9 (N.10 likely subsumed; N.2 folded into N.5; N.3 shipped).** N.2-N.9 (skipping N.10).** (Was 2-3 months / 6-8 weeks — revised
Revised 2026-05-08 after recognizing N.4-N.6 are one rendering rebuild upward after N.1 landed; realistic per-phase numbers above.)
on shared infrastructure rather than three independent substitutions.
**Each sub-phase:** **Each sub-phase:**
- Ships behind `ACDREAM_USE_WB_<NAME>=1` flag. - Ships behind `ACDREAM_USE_WB_<NAME>=1` flag.

View file

@ -1,318 +0,0 @@
# 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<ulong>` 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 `<sha>`)".
## 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.

View file

@ -1,495 +0,0 @@
# 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.

View file

@ -1,721 +0,0 @@
# 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;
/// <summary>
/// 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.
/// </summary>
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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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.

View file

@ -1,414 +0,0 @@
# 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<int partIndex, ulong gfxObjId>`.
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<batchKey, AcSurfaceMetadata>` 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)

View file

@ -9,9 +9,6 @@
<RootNamespace>AcDream.App</RootNamespace> <RootNamespace>AcDream.App</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" /> <PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" /> <PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
@ -29,12 +26,6 @@
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" /> <ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" /> <ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" /> <ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
<!-- Phase N.4 Task 9: WbMeshAdapter constructs the WB GL pipeline directly.
AcDream.Core already references these projects, but project references are
not transitive in .NET — AcDream.App must list them explicitly to compile
against Chorizite.OpenGLSDLBackend and WorldBuilder.Shared types. -->
<ProjectReference Include="..\..\references\WorldBuilder\WorldBuilder.Shared\WorldBuilder.Shared.csproj" />
<ProjectReference Include="..\..\references\WorldBuilder\Chorizite.OpenGLSDLBackend\Chorizite.OpenGLSDLBackend.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="Rendering\Shaders\*.*"> <None Update="Rendering\Shaders\*.*">

View file

@ -28,11 +28,6 @@ public sealed class GameWindow : IDisposable
private InstancedMeshRenderer? _staticMesh; private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader; private Shader? _meshShader;
private TextureCache? _textureCache; private TextureCache? _textureCache;
/// <summary>Phase N.4: WB-backed rendering pipeline adapter. Non-null only
/// when <c>ACDREAM_USE_WB_FOUNDATION=1</c> is set; null otherwise.</summary>
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 SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines; private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder // K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
@ -67,7 +62,7 @@ public sealed class GameWindow : IDisposable
// Phase A.1: streaming fields replacing the one-shot _entities list. // Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new(); private readonly AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5 private int _streamingRadius = 2; // default 5×5
private uint? _lastLivePlayerLandblockId; private uint? _lastLivePlayerLandblockId;
@ -1426,81 +1421,7 @@ public sealed class GameWindow : IDisposable
// WorldBuilder reference at // WorldBuilder reference at
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl); _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<AcDream.App.Rendering.Wb.WbMeshAdapter>.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<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(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) // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes // with depth writes off + far plane 1e6 so celestial meshes
@ -2417,19 +2338,6 @@ public sealed class GameWindow : IDisposable
SubPalettes: ranges); SubPalettes: ranges);
} }
AcDream.Core.World.PartOverride[] entityPartOverrides;
if (animPartChanges.Count == 0)
{
entityPartOverrides = Array.Empty<AcDream.Core.World.PartOverride>();
}
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 var entity = new AcDream.Core.World.WorldEntity
{ {
Id = _liveEntityIdCounter++, Id = _liveEntityIdCounter++,
@ -2439,7 +2347,6 @@ public sealed class GameWindow : IDisposable
Rotation = rot, Rotation = rot,
MeshRefs = meshRefs, MeshRefs = meshRefs,
PaletteOverride = paletteOverride, PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
}; };
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
@ -6141,12 +6048,6 @@ public sealed class GameWindow : IDisposable
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _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 // Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update() // after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame. // consumes buffered Silk.NET input events and calls ImGui.NewFrame.
@ -6336,20 +6237,10 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k); animatedIds.Add(k);
} }
if (_wbDrawDispatcher is not null) _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
{ neverCullLandblockId: playerLb,
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum, visibleCellIds: visibility?.VisibleCellIds,
neverCullLandblockId: playerLb, animatedEntityIds: animatedIds);
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 // Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly. // scene geometry so alpha blending composites correctly.
@ -8730,13 +8621,10 @@ public sealed class GameWindow : IDisposable
_combatChatTranslator?.Dispose(); _combatChatTranslator?.Dispose();
_liveSession?.Dispose(); _liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_staticMesh?.Dispose(); _staticMesh?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first _skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose(); _samplerCache?.Dispose();
_textureCache?.Dispose(); _textureCache?.Dispose();
_wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation — null when flag off
_meshShader?.Dispose(); _meshShader?.Dispose();
_terrain?.Dispose(); _terrain?.Dispose();
_shader?.Dispose(); _shader?.Dispose();
@ -8821,16 +8709,4 @@ public sealed class GameWindow : IDisposable
_ => $"Room 0x{roomId:X8}", _ => $"Room 0x{roomId:X8}",
}; };
} }
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
/// factory when neither <c>_dats</c> 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).
/// </summary>
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
} }

View file

@ -20,7 +20,6 @@
// needs to update the shader and uniform setup at the call sites. // needs to update the shader and uniform setup at the call sites.
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.Core.World; using AcDream.Core.World;
@ -34,17 +33,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
private readonly Shader _shader; private readonly Shader _shader;
private readonly TextureCache _textures; private readonly TextureCache _textures;
/// <summary>
/// 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.
/// </summary>
private readonly WbMeshAdapter? _wbMeshAdapter;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes. // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new(); private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
@ -79,13 +67,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature); 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; _gl = gl;
_shader = shader; _shader = shader;
_textures = textures; _textures = textures;
_wbMeshAdapter = wbMeshAdapter;
_instanceVbo = _gl.GenBuffer(); _instanceVbo = _gl.GenBuffer();
} }
@ -97,11 +83,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
if (_gpuByGfxObj.ContainsKey(gfxObjId)) if (_gpuByGfxObj.ContainsKey(gfxObjId))
return; 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<SubMeshGpu>(subMeshes.Count); var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes) foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm)); list.Add(UploadSubMesh(sm));
@ -438,7 +419,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
foreach (var meshRef in entity.MeshRefs) foreach (var meshRef in entity.MeshRefs)
{ {
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes)) if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId))
continue; continue;
var model = meshRef.PartTransform * entityRoot; var model = meshRef.PartTransform * entityRoot;

View file

@ -316,10 +316,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
return false; return false;
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha // Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
// format) or the more generic PFID_A8; terrain blending alpha masks // format) or the more generic PFID_A8; SurfaceDecoder routes both
// MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader // through the same "replicate single byte to RGBA" path. Palette is
// reads .r for the blend weight. Palette is not used. // not used.
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true); var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
if (ReferenceEquals(d, DecodedTexture.Magenta)) if (ReferenceEquals(d, DecodedTexture.Magenta))
return false; return false;

View file

@ -8,7 +8,7 @@ using SurfaceType = DatReaderWriter.Enums.SurfaceType;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable public sealed unsafe class TextureCache : IDisposable
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly DatCollection _dats; private readonly DatCollection _dats;
@ -123,23 +123,10 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
uint surfaceId, uint surfaceId,
uint? overrideOrigTextureId, uint? overrideOrigTextureId,
PaletteOverride paletteOverride) PaletteOverride paletteOverride)
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
HashPaletteOverride(paletteOverride));
/// <summary>
/// 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 <see cref="PaletteOverride.SubPalettes"/>.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{ {
ulong hash = HashPaletteOverride(paletteOverride);
uint origTexKey = overrideOrigTextureId ?? 0; uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, precomputedPaletteHash); var key = (surfaceId, origTexKey, hash);
if (_handlesByPalette.TryGetValue(key, out var h)) if (_handlesByPalette.TryGetValue(key, out var h))
return h; return h;
@ -151,10 +138,9 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
/// <summary> /// <summary>
/// Cheap 64-bit hash over a palette override's identity so two /// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode. Internal so /// entities with the same palette setup share a decode.
/// the WB dispatcher can compute it once per entity.
/// </summary> /// </summary>
internal static ulong HashPaletteOverride(PaletteOverride p) private static ulong HashPaletteOverride(PaletteOverride p)
{ {
// Not cryptographic — just needs to distinguish override setups // Not cryptographic — just needs to distinguish override setups
// for caching. Start with base palette id, fold in each entry. // for caching. Start with base palette id, fold in each entry.
@ -213,9 +199,8 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
// Clipmap surfaces use palette indices 0..7 as transparent sentinels. // Clipmap surfaces use palette indices 0..7 as transparent sentinels.
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive); return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
} }
/// <summary> /// <summary>

View file

@ -1,21 +0,0 @@
using AcDream.Core.Meshing;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// AC-specific surface render metadata that WB's <c>MeshBatchData</c>
/// doesn't carry. Computed at mesh-extraction time and looked up by the
/// draw dispatcher to drive translucency / sky-pass / fog behavior.
///
/// <para>
/// All fields mirror those on today's <see cref="GfxObjSubMesh"/> so
/// behavior is preserved bit-for-bit through the migration.
/// </para>
/// </summary>
public sealed record AcSurfaceMetadata(
TranslucencyKind Translucency,
float Luminosity,
float Diffuse,
float SurfOpacity,
bool NeedsUvRepeat,
bool DisableFog);

View file

@ -1,27 +0,0 @@
using System.Collections.Concurrent;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Thread-safe side-table mapping <c>(gfxObjId, surfaceIdx)</c> to
/// <see cref="AcSurfaceMetadata"/>. Populated when a GfxObj's mesh data
/// is extracted; queried at draw time.
///
/// <para>
/// Keyed by <c>(gfxObjId, surfaceIdx)</c> not by WB's runtime batch
/// identity because batch objects can be evicted and re-loaded by WB's
/// LRU; the (gfxObj, surface) pair is stable across cycles.
/// </para>
/// </summary>
public sealed class AcSurfaceMetadataTable
{
private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
=> _table[(gfxObjId, surfaceIdx)] = meta;
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
public void Clear() => _table.Clear();
}

View file

@ -1,67 +0,0 @@
using System.Collections.Generic;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-entity render state for animated entities (characters, creatures,
/// equipped items). Holds AC-specific per-instance customizations the WB
/// atlas cache doesn't carry: <c>AnimPartChange</c> override map +
/// <c>HiddenParts</c> bitmask. Also holds a reference to acdream's existing
/// <see cref="AnimationSequencer"/> — Phase N.4 explicitly does not touch
/// the sequencer; we just route through it at draw time.
///
/// <para>
/// Lifecycle: created by <c>EntitySpawnAdapter.OnCreate</c> (Task 17) when
/// a server <c>CreateObject</c> is processed; destroyed by
/// <c>EntitySpawnAdapter.OnRemove</c> on <c>RemoveObject</c>. The mesh
/// data backing each part is cached in WB's <c>ObjectMeshManager</c>;
/// per-instance customizations don't go through the atlas — they overlay
/// at draw time.
/// </para>
/// </summary>
public sealed class AnimatedEntityState
{
private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
private ulong _hiddenMask = 0;
/// <summary>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.</summary>
public AnimationSequencer Sequencer { get; }
public AnimatedEntityState(AnimationSequencer sequencer)
{
System.ArgumentNullException.ThrowIfNull(sequencer);
Sequencer = sequencer;
}
/// <summary>Set the <c>HiddenParts</c> bitmask for this entity. Bit
/// <c>i</c> set hides part <c>i</c> at draw time.</summary>
public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
/// <summary>True if part <c>partIdx</c> should be skipped at draw
/// time. Returns false for part indices outside [0, 63].</summary>
public bool IsPartHidden(int partIdx)
{
if (partIdx < 0 || partIdx >= 64) return false;
return (_hiddenMask & (1ul << partIdx)) != 0;
}
/// <summary>Override the GfxObj id for a Setup part. Used for
/// AnimPartChange — e.g. wielding a weapon swaps the hand-part's
/// GfxObj.</summary>
public void SetPartOverride(int partIdx, ulong gfxObjId)
=> _partGfxObjOverrides[partIdx] = gfxObjId;
/// <summary>Look up the GfxObj override for a part. Returns false if
/// no override is set (caller should fall back to Setup default).</summary>
public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
=> _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
/// <summary>Resolve the GfxObj id for <paramref name="partIdx"/>:
/// override if set, else <paramref name="setupDefault"/>. Used by the
/// draw dispatcher to pick the right cached mesh data per part.</summary>
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
=> TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
}

View file

@ -1,188 +0,0 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) 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
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
/// atlas-tier (procedural) entities; this one handles per-instance-tier
/// (server-spawned). The boundary is <c>ServerGuid != 0</c> on
/// <see cref="WorldEntity"/>.
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). 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
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
/// Production callers supply a factory that fetches MotionTable from dats.
/// </para>
///
/// <para>
/// <b>Adjustment 6</b> (resolved Adjustment 4): <see cref="WorldEntity"/> now
/// carries <see cref="WorldEntity.PartOverrides"/> and
/// <see cref="WorldEntity.HiddenPartsMask"/>. <see cref="OnCreate"/> applies
/// both to the created <see cref="AnimatedEntityState"/>.
/// </para>
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly Func<WorldEntity, AnimationSequencer> _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<uint, AnimatedEntityState> _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<uint, HashSet<ulong>> _meshIdsByGuid = new();
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
/// entity. Receives the full <see cref="WorldEntity"/> so it can look up
/// the Setup + MotionTable from the entity's <c>SourceGfxObjOrSetupId</c>
/// and server-supplied motion table override. Tests pass a lambda that
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
/// <summary>
/// Process a server-spawned entity. Returns the created
/// <see cref="AnimatedEntityState"/> for the entity, or <c>null</c> if
/// <paramref name="entity"/> is atlas-tier (<c>ServerGuid == 0</c>).
/// </summary>
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<ulong>();
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;
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// </summary>
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);
}
}
/// <summary>
/// Look up the <see cref="AnimatedEntityState"/> for a server guid.
/// Returns <c>null</c> if the entity was never spawned or has already
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
}

View file

@ -1,22 +0,0 @@
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Seam interface over the per-instance palette-override decode path in
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
/// can be tested without a live GL context.
/// </summary>
public interface ITextureCachePerInstance
{
/// <summary>
/// Decode (or return cached) the palette-overridden texture for
/// <paramref name="surfaceId"/>. Delegates to
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
/// production.
/// </summary>
uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride);
}

View file

@ -1,12 +0,0 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
/// can be unit-tested without a real WB pipeline behind them.
/// </summary>
public interface IWbMeshAdapter
{
void IncrementRefCount(ulong id);
void DecrementRefCount(ulong id);
}

View file

@ -1,94 +0,0 @@
using System.Collections.Generic;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Bridges landblock streaming events to <see cref="IWbMeshAdapter"/>'s
/// reference-count lifecycle. <b>Tier-aware by design</b>: only atlas-tier
/// entities (procedural / dat-hydrated, identified by
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
/// (per-instance tier) are skipped — those go through
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
///
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
/// unload can match the load 1:1.
/// </para>
///
/// <para>
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
/// uses <c>ConcurrentDictionary</c>, 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).
/// </para>
/// </summary>
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<uint, HashSet<ulong>> _idsByLandblock = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
System.ArgumentNullException.ThrowIfNull(adapter);
_adapter = adapter;
}
/// <summary>
/// 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.
/// </summary>
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<ulong>();
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);
}
/// <summary>
/// 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.
/// </summary>
public void OnLandblockUnloaded(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
}
}

View file

@ -1,521 +0,0 @@
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;
/// <summary>
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
/// for texture resolution and <see cref="AcSurfaceMetadataTable"/> for
/// translucency classification.
///
/// <para>
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
/// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>.
/// Textures resolve through <see cref="TextureCache.GetOrUpload"/> using the batch's
/// <c>SurfaceId</c>.
/// </para>
///
/// <para>
/// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from
/// WB, but textures resolve through <see cref="TextureCache"/> with palette and
/// surface overrides applied. <see cref="AnimatedEntityState"/> is currently
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>.
/// </para>
///
/// <para>
/// <b>GL strategy:</b> GROUPED instanced drawing. All visible (entity, batch)
/// pairs are bucketed by <see cref="GroupKey"/>; within a group a single
/// <c>glDrawElementsInstancedBaseVertexBaseInstance</c> renders all instances.
/// All matrices for the frame land in one shared instance VBO via a single
/// <c>BufferData</c> upload. This drops draw calls from O(entities×batches)
/// to O(unique GfxObj×batch×texture) — typically two orders of magnitude fewer.
/// </para>
///
/// <para>
/// <b>Shader:</b> reuses <c>mesh_instanced</c> (vert locations 0-2 = Position/
/// Normal/UV from WB's <c>VertexPositionNormalTexture</c>; locations 3-6 = instance
/// matrix from our VBO). WB's 32-byte vertex stride is compatible.
/// </para>
///
/// <para>
/// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
/// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those
/// offsets via <c>DrawElementsInstancedBaseVertex(BaseInstance)</c>. The legacy
/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there.
/// </para>
/// </summary>
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<uint> _patchedVaos = new();
// Per-frame scratch — reused across frames to avoid per-frame allocation.
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _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<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
HashSet<uint>? 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<Matrix4x4> Matrices = new();
}
}

View file

@ -1,39 +0,0 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Process-lifetime cache of <c>ACDREAM_USE_WB_FOUNDATION</c> env var.
/// Read once at static-init time; all consumers import this rather than
/// re-reading the env var per call (env-var lookups on Windows are not
/// free at hot-path cadence).
///
/// <para>
/// <b>Default-on as of Phase N.4 ship (2026-05-08).</b> The WB foundation
/// (<c>WbMeshAdapter</c> + <c>WbDrawDispatcher</c>) is the production
/// rendering path. Set <c>ACDREAM_USE_WB_FOUNDATION=0</c> to fall back
/// to the legacy <c>InstancedMeshRenderer</c> path — kept as an escape
/// hatch until N.6 fully replaces it.
/// </para>
///
/// <para>
/// Per-instance customized content (server <c>CreateObject</c> entities
/// with palette / texture overrides) routes through
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> regardless
/// of the flag — the flag controls which DRAW path consumes those
/// textures.
/// </para>
/// </summary>
public static class WbFoundationFlag
{
private static bool _isEnabled =
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
public static bool IsEnabled => _isEnabled;
/// <summary>
/// FOR TESTS ONLY. Forces <see cref="IsEnabled"/> to <c>true</c> so
/// integration tests can exercise the WB adapter path without having to
/// set the env var before static initialisation. Never call from
/// production code.
/// </summary>
internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
}

View file

@ -1,203 +0,0 @@
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;
/// <summary>
/// Single seam between acdream and WB's render pipeline. Owns the
/// <c>ObjectMeshManager</c> instance and exposes a stable acdream-shaped API
/// so the rest of the renderer doesn't need to know about WB's types directly.
///
/// <para>
/// The adapter constructs its own <c>DefaultDatReaderWriter</c> internally; it
/// does NOT share file handles with our <c>DatCollection</c>. This duplicates
/// index-cache memory (~50100 MB) but keeps the two subsystems fully decoupled.
/// Acceptable for Phase N.4 foundation work (plan Adjustment 1).
/// </para>
/// </summary>
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<ulong> _metadataPopulated = new();
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
/// all public methods no-op when uninitialized.
/// </summary>
private readonly bool _isUninitialized;
private bool _disposed;
/// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter
/// → ObjectMeshManager.
/// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="datDir">Path to the dat directory (same as the one supplied
/// to our DatCollection). DefaultDatReaderWriter opens its own file handles.</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
public WbMeshAdapter(GL gl, string datDir, DatCollection dats, ILogger<WbMeshAdapter> 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<ObjectMeshManager>.Instance);
}
private WbMeshAdapter()
{
// Uninitialized constructor — only for tests / flag-off cases where
// the caller wants a Dispose-safe no-op instance.
_isUninitialized = true;
}
/// <summary>Test/init helper — produces a Dispose-safe instance with no
/// underlying mesh manager. Public methods are all no-ops.</summary>
public static WbMeshAdapter CreateUninitialized() => new();
/// <summary>
/// The surface metadata side-table populated on each first
/// <see cref="IncrementRefCount"/>. Queried by the draw dispatcher
/// to determine translucency, luminosity, and fog behavior per batch.
/// </summary>
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
/// <summary>
/// Returns the WB render data for <paramref name="id"/>, or null if not
/// yet uploaded or if this adapter is uninitialized. Increments WB's
/// internal usage counter — use <see cref="TryGetRenderData"/> for
/// render-loop lookups that should not affect lifecycle.
/// </summary>
public ObjectRenderData? GetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.GetRenderData(id);
}
/// <summary>
/// Returns the WB render data for <paramref name="id"/> without
/// modifying reference counts. Returns null if the mesh is not yet
/// uploaded. Safe for render-loop lookups.
/// </summary>
public ObjectRenderData? TryGetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.TryGetRenderData(id);
}
/// <inheritdoc/>
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);
}
}
/// <inheritdoc/>
public void DecrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.DecrementRefCount(id);
}
/// <summary>
/// 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.
///
/// <para>
/// Order matters: <c>ProcessGLQueue</c> 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
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
/// previously passed to <c>IncrementRefCount</c> may return non-null.
/// </para>
///
/// <para>
/// No-op when the adapter is uninitialized (e.g., flag is off and the
/// adapter was constructed via <c>CreateUninitialized</c>).
/// </para>
/// </summary>
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<GfxObj>((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));
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_meshManager?.Dispose();
_wbDats?.Dispose();
_graphicsDevice?.Dispose();
}
}

View file

@ -1,7 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Streaming; namespace AcDream.App.Streaming;
@ -39,17 +38,6 @@ namespace AcDream.App.Streaming;
/// </summary> /// </summary>
public sealed class GpuWorldState 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<uint, LoadedLandblock> _loaded = new(); private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new(); private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
@ -144,8 +132,6 @@ public sealed class GpuWorldState
} }
_loaded[landblock.LandblockId] = landblock; _loaded[landblock.LandblockId] = landblock;
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
RebuildFlatView(); RebuildFlatView();
} }
@ -195,9 +181,6 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId) public void RemoveLandblock(uint landblockId)
{ {
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
// Rescue persistent entities before removal. These get appended // Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for // to the _persistentRescued list; the caller is responsible for
// re-injecting them (via AppendLiveEntity) into whatever landblock // re-injecting them (via AppendLiveEntity) into whatever landblock
@ -250,10 +233,6 @@ public sealed class GpuWorldState
{ {
if (serverGuid == 0) return; 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; bool rebuiltLoaded = false;
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside. // Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
@ -309,11 +288,6 @@ public sealed class GpuWorldState
/// </summary> /// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity) 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; uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_loaded.TryGetValue(canonicalLandblockId, out var lb)) if (_loaded.TryGetValue(canonicalLandblockId, out var lb))

View file

@ -1,6 +1,5 @@
using BCnEncoder.Decoder; using BCnEncoder.Decoder;
using BCnEncoder.Shared; using BCnEncoder.Shared;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
@ -17,7 +16,7 @@ public static class SurfaceDecoder
/// when a palette is available. /// when a palette is available.
/// </summary> /// </summary>
public static DecodedTexture DecodeRenderSurface(RenderSurface rs) public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
=> DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false); => DecodeRenderSurface(rs, palette: null);
/// <summary> /// <summary>
/// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support. /// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support.
@ -25,11 +24,8 @@ public static class SurfaceDecoder
/// 16-bit value in SourceData is treated as an index into <see cref="Palette.Colors"/>. /// 16-bit value in SourceData is treated as an index into <see cref="Palette.Colors"/>.
/// When <paramref name="isClipMap"/> is true on an indexed surface, palette indices /// When <paramref name="isClipMap"/> is true on an indexed surface, palette indices
/// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention). /// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention).
/// When <paramref name="isAdditive"/> 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).
/// </summary> /// </summary>
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false)
{ {
if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0)
return DecodedTexture.Magenta; return DecodedTexture.Magenta;
@ -44,11 +40,9 @@ public static class SurfaceDecoder
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap), PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap),
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap), PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap),
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap),
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive), PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), 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_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap),
PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),
_ => DecodedTexture.Magenta, _ => DecodedTexture.Magenta,
}; };
} }
@ -65,7 +59,33 @@ public static class SurfaceDecoder
return DecodedTexture.Magenta; return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4]; var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); 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;
}
}
return new DecodedTexture(rgba, rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height);
} }
@ -89,22 +109,30 @@ public static class SurfaceDecoder
} }
/// <summary> /// <summary>
/// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8. /// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA)
/// When <paramref name="isAdditive"/> is true: R=G=B=A=val (terrain alpha masks and /// into RGBA8 by replicating each alpha byte into all four channels. AC's
/// additive entity textures — the shader reads .r for the blend weight). When false: /// terrain blending alpha masks are stored as PFID_CUSTOM_LSCAPE_ALPHA and
/// R=G=B=255, A=val (WB FillA8 semantics for non-additive entity textures). /// 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.
/// </summary> /// </summary>
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive) private static DecodedTexture DecodeA8(RenderSurface rs)
{ {
int expected = rs.Width * rs.Height; int expected = rs.Width * rs.Height;
if (rs.SourceData.Length < expected) if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta; return DecodedTexture.Magenta;
var rgba = new byte[expected * 4]; var rgba = new byte[expected * 4];
if (isAdditive) for (int i = 0; i < expected; i++)
TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); {
else byte a = rs.SourceData[i];
TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); int d = i * 4;
rgba[d + 0] = a;
rgba[d + 1] = a;
rgba[d + 2] = a;
rgba[d + 3] = a;
}
return new DecodedTexture(rgba, rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height);
} }
@ -115,7 +143,15 @@ public static class SurfaceDecoder
return DecodedTexture.Magenta; return DecodedTexture.Magenta;
var rgba = new byte[expected]; var rgba = new byte[expected];
TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); // 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
}
return new DecodedTexture(rgba, rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height);
} }
@ -132,7 +168,29 @@ public static class SurfaceDecoder
return DecodedTexture.Magenta; return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4]; var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); 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;
}
}
return new DecodedTexture(rgba, rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height);
} }
@ -149,7 +207,16 @@ public static class SurfaceDecoder
return DecodedTexture.Magenta; return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4]; var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); 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
}
return new DecodedTexture(rgba, rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height);
} }
@ -178,28 +245,6 @@ public static class SurfaceDecoder
return new DecodedTexture(rgba, rs.Width, rs.Height); 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) private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap)
{ {
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);

View file

@ -55,27 +55,4 @@ public sealed class WorldEntity
/// visible trunk, producing "partial passthrough" bugs. /// visible trunk, producing "partial passthrough" bugs.
/// </summary> /// </summary>
public float Scale { get; init; } = 1.0f; public float Scale { get; init; } = 1.0f;
/// <summary>
/// Server-sent part-swap overrides from <c>AnimPartChange</c>. Each entry
/// replaces a Setup part's GfxObj with an alternate model (clothing, weapons,
/// helmets). Carried on the entity so <c>EntitySpawnAdapter</c> can populate
/// <c>AnimatedEntityState</c>'s override map at spawn time. Empty for atlas-
/// tier entities.
/// </summary>
public IReadOnlyList<PartOverride> PartOverrides { get; init; } = Array.Empty<PartOverride>();
/// <summary>
/// Bitmask of hidden Setup parts. Bit <c>i</c> set hides part <c>i</c> at
/// draw time. Sourced from the server's <c>CreateObject</c> record when
/// present. Zero (no parts hidden) is the default.
/// </summary>
public ulong HiddenPartsMask { get; init; }
} }
/// <summary>
/// Lightweight value type for a server-sent <c>AnimPartChange</c> (part index
/// → replacement GfxObj id). Decouples <c>WorldEntity</c> (Core) from the
/// network-layer <c>CreateObject.AnimPartChange</c> type.
/// </summary>
public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);

View file

@ -1,72 +0,0 @@
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 _));
}
}

View file

@ -1,62 +0,0 @@
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;
}
}

View file

@ -1,45 +0,0 @@
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<System.ArgumentNullException>(
() => 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;
}
}

View file

@ -1,256 +0,0 @@
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<uint, uint>
{
{ 0x08000100u, 0u }, // surfaceId → origTex (0 = none)
},
},
new MeshRef(0x01000020u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
{ 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<PaletteOverride.SubPaletteRange>()),
// 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 ─────────────────────────────────────────────────────
/// <summary>
/// Capture every call to GetOrUploadWithPaletteOverride so tests can
/// assert without a live GL context.
/// </summary>
private sealed class CapturingTextureCache : ITextureCachePerInstance
{
public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette);
public List<Call> 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;
}
}

View file

@ -1,56 +0,0 @@
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;
}
}

View file

@ -1,158 +0,0 @@
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<ulong> IncrementCalls { get; } = new();
public List<ulong> DecrementCalls { get; } = new();
public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
}
private static LoadedLandblock MakeLandblock(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,
};
}
}

View file

@ -1,64 +0,0 @@
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);
}
}

View file

@ -1,136 +0,0 @@
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;
/// <summary>
/// Conformance: our <see cref="GfxObjMesh.Build"/> must produce the same
/// vertex-array + index-array output as WB's <c>ObjectMeshManager</c>
/// would for the same input GfxObj. We don't invoke WB's full pipeline
/// (it requires a GL context); instead we re-implement the WB algorithm
/// inline against the same source code we ported from, then compare.
///
/// <para>
/// If this test fails, either our port has drifted or the WB code has
/// changed upstream — investigate which, do not "fix" the test.
/// </para>
/// </summary>
public sealed class MeshExtractionConformanceTests
{
[Fact]
public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
{
var gfxObj = MakeUnitQuadGfxObj();
var ours = GfxObjMesh.Build(gfxObj, dats: null);
Assert.Single(ours);
var sub = ours[0];
// Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
Assert.Equal(4, sub.Vertices.Length);
Assert.Equal(6, sub.Indices.Length);
// Fan from vertex 0: (0,1,2) and (0,2,3).
Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
}
[Fact]
public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
{
var gfxObj = MakeUnitQuadGfxObj();
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -1,149 +0,0 @@
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;
/// <summary>
/// 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.
/// </summary>
public sealed class PendingSpawnIntegrationTests
{
/// <summary>
/// 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.
/// </summary>
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<ulong> IncrementCalls { get; } = new();
public List<ulong> DecrementCalls { get; } = new();
public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
}
private static 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) },
};
}

View file

@ -1,105 +0,0 @@
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Rendering.Wb;
/// <summary>
/// Conformance: our <see cref="SetupMesh.Flatten"/> must produce the same
/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride →
/// Resting → Default → first available) before substitution.
/// </summary>
public sealed class SetupFlattenConformanceTests
{
[Fact]
public void Flatten_NoFrames_FallsBackToIdentity()
{
var setup = new Setup { Parts = { 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);
}
}

View file

@ -1,65 +0,0 @@
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<ArgumentNullException>(() =>
new WbMeshAdapter(gl: null!, datDir: "some/path", dats: null!, logger: NullLogger<WbMeshAdapter>.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
}
}

View file

@ -56,10 +56,12 @@ public class SurfaceDecoderTests
} }
[Fact] [Fact]
public void Decode_A8_NonAdditive_ProducesWhitePlusAlpha() public void Decode_A8_ExpandsSingleByteToRgbaWithAlphaInAllChannels()
{ {
// Default (isAdditive: false) = WB FillA8 semantics: R=G=B=255, A=val. // PFID_A8 is single-byte-per-pixel alpha. AC terrain blending alpha maps
// Used for non-additive entity surfaces where A8 is a pure alpha channel. // 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).
var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image
var rs = new RenderSurface var rs = new RenderSurface
{ {
@ -74,34 +76,7 @@ public class SurfaceDecoderTests
Assert.Equal(2, decoded.Width); Assert.Equal(2, decoded.Width);
Assert.Equal(2, decoded.Height); Assert.Equal(2, decoded.Height);
Assert.Equal(16, decoded.Rgba8.Length); Assert.Equal(16, decoded.Rgba8.Length);
// Each input byte expands to (255, 255, 255, val) — white with varying alpha // Each input byte expands to (b, b, b, b) in RGBA output
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[] Assert.Equal(new byte[]
{ {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@ -117,7 +92,7 @@ public class SurfaceDecoderTests
// PFID_CUSTOM_LSCAPE_ALPHA (0xF4) is AC's custom format for terrain // PFID_CUSTOM_LSCAPE_ALPHA (0xF4) is AC's custom format for terrain
// blending alpha maps. Pixel layout is identical to PFID_A8 — one // blending alpha maps. Pixel layout is identical to PFID_A8 — one
// byte of alpha per pixel — so the decoder routes both through the // byte of alpha per pixel — so the decoder routes both through the
// same DecodeA8 implementation. Default (isAdditive:false) → R=G=B=255, A=val. // same DecodeA8 implementation.
var src = new byte[] { 0x10, 0x20, 0x30, 0x40 }; // 2x2 var src = new byte[] { 0x10, 0x20, 0x30, 0x40 }; // 2x2
var rs = new RenderSurface var rs = new RenderSurface
{ {
@ -132,10 +107,10 @@ public class SurfaceDecoderTests
Assert.Equal(16, decoded.Rgba8.Length); Assert.Equal(16, decoded.Rgba8.Length);
Assert.Equal(new byte[] Assert.Equal(new byte[]
{ {
255, 255, 255, 0x10, 0x10, 0x10, 0x10, 0x10,
255, 255, 255, 0x20, 0x20, 0x20, 0x20, 0x20,
255, 255, 255, 0x30, 0x30, 0x30, 0x30, 0x30,
255, 255, 255, 0x40, 0x40, 0x40, 0x40, 0x40,
}, decoded.Rgba8); }, decoded.Rgba8);
} }

View file

@ -1,369 +0,0 @@
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Textures;
/// <summary>
/// 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.
/// </summary>
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 -----------------------------------------------------------------
/// <summary>
/// Test 1: INDEX16 normal mode — 2×2 image with two palette entries.
/// WB's FillIndex16 and our DecodeIndex16 must produce identical RGBA bytes.
/// </summary>
[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);
}
/// <summary>
/// Test 2: INDEX16 clipmap mode — indices below 8 must produce transparent pixels.
/// Both implementations share the same clipmap alpha-key convention from retail ACViewer.
/// </summary>
[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 08 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);
}
/// <summary>
/// Test 3: P8 (8-bit palette index) — 2×2 image.
/// WB FillP8 and our DecodeP8 must produce identical RGBA output.
/// </summary>
[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);
}
/// <summary>
/// Test 4: A8R8G8B8 (BGRA on-disk → RGBA) — 2×1 image.
/// WB FillA8R8G8B8 and our DecodeA8R8G8B8 both swap B↔R.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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]);
}
/// <summary>
/// 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.
/// </summary>
[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
}
/// <summary>
/// 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.
/// </summary>
[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
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}