Commit graph

90 commits

Author SHA1 Message Date
Erik
5dc4140c11 feat(render): Phase A8 — indoor visibility + streaming fixes batch
Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 10:14:50 +02:00
Erik
d5deeb3314 fix(render): Phase A8 — remove cull-restore at EnvCell exit (lets IndoorPass inherit cull-off)
Visual-gate-#4 evidence revealed the prior commit's cull-restore-at-exit
addition was wrong. The Landblock→None CullMode override worked correctly
for cell-mesh polys, but the cull-back state I restored at Render exit
propagated to the subsequent `dispatcher.Draw(IndoorPass)` call. The
dispatcher's IndoorPass renders AC's cottage shell — landblock-baked
GfxObj parts (wooden floor planks, wall slabs) whose pos-side winding +
our FrontFace=CCW + cull-back = floor poly is back-facing and culled.
User saw light blue sky through the floor in gate-#4.

Reverting the cull-restore lets cull-disabled propagate from
EnvCellRenderer.Render through IndoorPass. Cottage shell renders
double-sided so the floor + wall slabs are visible from any angle.
Step 4's gl.Enable(EnableCap.CullFace) at the terrain pass (line
~10768) + the cleanup block's enable (line ~10870) re-establish
cull-back BEFORE the LiveDynamic dispatcher.Draw fires — so chars,
NPCs, doors still render solid (no see-through-head regression from
gate-#3's ACDREAM_A8_DISABLE_CULL=1 diagnostic).

The retail-faithful long-term fix is matching WB's `glFrontFace(GLEnum.CW)`
globally (per GameScene.cs:843) so cull-back selects the correct side
for AC's natural polygon winding without needing double-sided rendering.
That requires a wider audit of every consumer's FrontFace assumption
(translucent crystal renderer + others) and is deferred.

14/14 EnvCellRenderer tests pass. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:24:59 +02:00
Erik
0940d7961a fix(render): Phase A8 — cell-mesh Landblock CullMode → None + cull state restore
The cull A/B diagnostic (prior commit's ACDREAM_A8_DISABLE_CULL=1) in
visual-gate-#3 confirmed: cell-mesh polys are being culled by back-face
culling, which is why floors disappear when looking down from inside a
room. Per-cell audit data showed every cell-mesh batch has
CullMode.Landblock — assigned because AC's CellStruct polys carry
SidesType=Landblock in the dat. Our SetCullMode maps Landblock to
glCullFace(Back), matching WB.

Root cause:
WB sets `glFrontFace(GLEnum.CW)` globally at GameScene.cs:843. Our
WbDrawDispatcher.cs:1056 sets `glFrontFace(CCW)` — the GL default,
opposite of WB. With our flipped-from-natural fan triangulation in
BuildCellStructPolygonIndices (which emits (i, i-1, 0) for each fan
triangle, reversing the input vertex order), the resulting effective
winding from the camera's perspective is OPPOSITE WB's. Cull-back then
removes the OPPOSITE face from what WB does — hiding the floor side
that should be visible from inside the room.

Within a single cell-mesh batch, the polys face every direction (walls
outward, floor up, ceiling down) but all share CullMode.Landblock. No
single cull setting can be correct for all three orientations
simultaneously — the retail-faithful approach is to render cell polys
double-sided (cull off).

Two changes scoped to EnvCellRenderer.RenderModernMDIInternal so other
renderers aren't affected:
  1. Remap CullMode.Landblock → None when iterating per-cull-mode
     batch groups. Cell polys render with cull disabled, all faces
     visible. CullMode.Landblock is only assigned by
     PrepareCellStructMeshData (cell polys) in this codebase — terrain
     uses a different render path. Scope is exactly right.
  2. Explicitly Enable(CullFace) + CullFace(Back) at Render exit so the
     dispatcher's subsequent IndoorPass + LiveDynamic Draws don't
     inherit the cull-disabled state. The see-through-head symptom in
     visual-gate-#3 was caused by exactly this state leak from the
     ACDREAM_A8_DISABLE_CULL=1 diagnostic; the proper fix needs the
     explicit restore. Also updates the static `_currentCullMode` cache
     so the next Render call's first SetCullMode comparison is correct.

Removed the ACDREAM_A8_DISABLE_CULL diagnostic env var — its role as
A/B test is complete. 14/14 EnvCellRenderer tests pass. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:12:20 +02:00
Erik
b19f3c14a9 fix(render): Phase A8 — LiveDynamic in indoor branch + cull A/B gate
Two changes from visual-gate-#2 evidence.

LiveDynamic fix (real bug closure):
The user reported "can't see char ... door is missing" in visual gate #2.
Doors and the player char are LiveDynamic entities (ServerGuid != 0). The
outdoor branch's Draw(set: All) includes them; the indoor branch's
RenderInsideOutAcdream only renders IndoorPass + OutdoorScenery partitions,
implicitly excluding LiveDynamic. The method's own header comment promised
"LiveDynamic is drawn last in BOTH branches" but no call existed in the
indoor path — a documented behavior with no implementation. Wire the
LiveDynamic Draw after RenderInsideOutAcdream returns with stencil + state
restored to defaults at its cleanup block.

Cull A/B diagnostic (bisect floor-missing root cause):
ACDREAM_A8_DISABLE_CULL=1 forces every cell-mesh batch's effective CullMode
to None. The visual-gate-#2 audit confirmed cell meshes upload correctly
(every cell has multi-batch render data with non-zero indices, no null
data, no zero handles). Every batch uniformly reports CullMode.Landblock
which maps to glCullFace(Back) — identical to WB's mapping. So data is
fine and CullMode lookup is fine; only the BIND-TIME interaction (polygon
winding orientation in our coord system + cull-back) could still hide
specific polys. If floor appears with this gate set, cull/winding is the
remaining bug (need to either invert winding upstream or remap CullMode);
if not, the issue is elsewhere (lighting / depth / alpha) and we look
there. Tight bisect — one launch's evidence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:00:54 +02:00
Erik
772d69c7a6 fix(render)+feat(diag): Phase A8 — sky-when-inside-building + per-cell audit probe
Two changes for visual-gate-#1 follow-up. After the pool aliasing fix
(prior commit), walls + objects render cleanly but three residual symptoms
remain: missing floor, purple wall tint, no sky through windows. This
commit addresses one and adds the probe for the second.

Sky fix:
The blanket `!cameraInsideCell` skip of the sky pass was inherited from
when the indoor-cell concept was sealed dungeons. With Phase A8's
RenderInsideOutAcdream pipeline, cottages render through their portals
to outside — and the user expects sky visible through windows + doorways.
WB's VisibilityManager.RenderInsideOut assumes sky has already been
rendered as the far-depth backdrop before stencil setup. New gate:
`!cameraInsideCell || cameraInsideBuilding`. Sky renders inside cottages
(building → portals), skipped inside true dungeons (no portals). The
Step 4 stencil-gated outdoor pass composites terrain + scenery through
portal silhouettes on top of the sky.

Per-cell audit probe (ACDREAM_A8_AUDIT=1):
One-shot dump per (cellId, gfxObjId) pair in the active snapshot:
- renderData null/non-null status
- batches count + total IndexCount
- per-batch CullMode + IsTransparent + IsAdditive + bindless-handle-zero
The first visual gate showed tris=135 for 18 cells — way too low if cell
meshes were complete (expected ~20+ tris/cell). The audit dump will
identify whether (a) some cells aren't uploading, (b) some batches have
zero indices, or (c) batches' CullModes are getting them culled at
typical viewing angles. Without this probe, we'd be back to speculation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:54:45 +02:00
Erik
375f9a7b9b feat(render): Phase A8 — full GL state probe + pool diagnostics (apparatus)
Defense-in-depth apparatus per the 2026-05-27 handoff's option-1 recommendation.
The audit-found pool aliasing bug (prior commit) is the primary fix; this probe
is the safety net for any unidentified residual issue when the visual gate runs.

EmitDrawOrderProbe now logs the full GL state at each step boundary of
RenderInsideOutAcdream — stencil test/func/ref/mask/op, depth func/mask, cull
face/mode, blend src/dst, color writemask, current VAO, current program. An
operator can read the log offline and compare line-by-line against WB's
expected state at VisibilityManager.cs:73-239. Any divergence pinpoints the
bug's GL-state shape; matching state confirms the issue is elsewhere
(instance data, mesh upload, etc.).

EmitEnvCellProbe now logs pool diagnostics — total pool size + snapshot's
PostPreparePoolIndex high-water mark. A spike in poolTotal across stationary-
camera frames, or a divergence between poolHwm and cell-count, signals
pool-management regression. The fix-the-bug-first principle keeps this probe
dormant by default; enable via ACDREAM_PROBE_VIS=1 only when investigating.

Heavy (~10 GL queries per step × 5-10 steps per frame), but gated.

86/86 App tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:11:00 +02:00
Erik
9559726960 fix(render): Phase A8 — pool aliasing in EnvCellRenderer (visual chaos root cause)
The post-Wave-5 indoor branch chaos (flickering, missing walls, GPU 100%,
~10 FPS) is caused by two interconnected pool-management bugs in
EnvCellRenderer that line-by-line WB comparison surfaced in 30 minutes.
Neither was found by the five post-Wave-5 speculative fixes because none
of them inspected the pool path.

Bug #1 — GetPooledList missing list.Clear():
The reuse branch returned pool lists with prior-frame data still inside.
PrepareRenderBatches' merge phase pattern `gfxDict[k] = list; list.AddRange(...)`
assumes empty lists. Without Clear(), lists grow unbounded each frame, GPU
draws cumulative instance counts, and per-instance transforms become a stew
of past + present data. Mirrors WB ObjectRenderManagerBase.cs:1221-1233.

Bug #2 — Render uses snapshot.BatchedByCell.Count instead of PostPreparePoolIndex:
The snapshot author dropped WB's PostPreparePoolIndex field calling it
"scenery-only," then "compensated" in Render by setting _poolIndex to the
cell count. The cell count has no relation to the pool — Prepare may have
used 50+ pool lists for an 18-cell scene. Render's filter-path GetPooledList
then returns lists that ARE in snapshot.BatchedByCell, corrupting the snapshot
mid-Render. Restoring PostPreparePoolIndex (WB VisibilitySnapshot.cs:31)
correctly places Render's pool cursor past the snapshot's owned region.

Bug #3 (minor) — PopulateRecursive hardcoded isSetup:false for nested parts:
Setup IDs use high-byte 0x02 (per retail). WB ObjectRenderManagerBase.cs:813
checks `(partId >> 24) == 0x02` to detect nested Setups. Our port always
passed isSetup:false, silently dropping any nested Setup (its TryGetRenderData
returns IsSetup=true, Render's `!IsSetup` guard skips the draw). Probably
rare in EnvCells but fixed for completeness.

Regression coverage:
- GetPooledList_ReusedList_IsClearedBeforeReturn — would have failed pre-fix
- GetPooledList_FreshList_IsAlwaysEmpty — sanity check
- Snapshot_PostPreparePoolIndex_IsInitSettable — compile-time guarantee
- Snapshot_PostPreparePoolIndex_DefaultsToZero — defensive default

86/86 App tests pass. Build green. The fix is the audit's primary
deliverable; the GL state probe option-1 apparatus follows in a separate
commit as defense-in-depth for any unidentified residual issue.

Full audit + WB cross-reference in
docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:08:49 +02:00
Erik
9ee42d408a fix(render): Phase A8 — invalidate GL state caches at Render entry
User report from second visual gate (Step 5 disabled, ColorMask fixed):
"Cant see anything, flickering colors, sometimes I see textures and
sometimes I see inside, the house is missing lots of walls. 10 FPS."

Root cause: EnvCellRenderer._currentVao and _currentCullMode are STATIC
caches that let SetCullMode / BindVertexArray skip redundant GL state
changes when "already" in the right state. But other consumers
(WbDrawDispatcher, terrain renderer, the Step 1+2 stencil pipeline)
change the actual GL state without updating these caches. The cache
lies, the per-batch SetCullMode in RenderModernMDIInternal skips its
glCullFace call, and the cottage's mixed-cull-mode batches end up
rendering with whatever stale cull state was leaked from the prior
consumer. Walls with backface-only geometry vanish. The flicker is
the state alternating depending on which Render call set the cache
this frame.

WB invalidates these caches at line 404-410 of EnvCellRenderManager.cs:
  CurrentVAO = 0;
  CurrentIBO = 0;
  CurrentAtlas = 0;
  CurrentInstanceBuffer = 0;
  CurrentCullMode = null;

Our port missed this. Adding _currentVao = 0; _currentCullMode = null;
at Render entry forces each Render call to re-establish the GL state
it expects. (We only track Vao + CullMode in our minimal port; IBO/
Atlas/InstanceBuffer aren't cached in our class.)

Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:10:52 +02:00
Erik
4b4f687070 feat(render): Phase A8 Wave 3 — wire EnvCellRenderer into landblock streaming
Six surgical edits to GameWindow.cs (+1 MeshManager accessor on WbMeshAdapter):

1. Field declarations (line 166-167): _envCellRenderer + _envCellFrustum.
2. Ctor init (line 1775-1778): construct WbFrustum + EnvCellRenderer,
   Initialize with the existing _meshShader (loaded from mesh_modern.vert/frag).
3. BuildInteriorEntitiesForStreaming (line 5444): _envCellRenderer.RegisterCell(...)
   replaces the cell-as-WorldEntity creation block. staticObjects is empty —
   cell stabs continue as WorldEntity records via the dispatcher's IndoorPass.
4. ApplyLoadedTerrainLocked (line 5885): _envCellRenderer.FinalizeLandblock(...)
   immediately after _buildingRegistries[lb.LandblockId] = ... — atomically
   commits the landblock's per-cell instance store.
5. RemoveLandblock callbacks (lines 1861 + 8955): mirror the existing
   _buildingRegistries.Remove(id) sites so EnvCellRenderer's storage clears
   in lockstep.
6. Dispose (line 10595): _envCellRenderer?.Dispose() after _wbDrawDispatcher.

Plan revision (vs original plan.md Task 6): we keep the static-object stab
WorldEntity hydration (lines 5440-5489) instead of deleting it — stabs need
WorldEntity records for interaction (clicking) and physics. EnvCellRenderer
receives empty staticObjects so it only renders cell geometry; stab rendering
continues unchanged through the dispatcher.

Build green. 23/23 EnvCellRenderer + WbFrustum + EnvCellSceneryInstance
tests pass. App.Tests baseline holds (82/82). Pre-existing Core.Tests
static-leak flakiness (8-19 failures, documented baseline) unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:03:17 +02:00
Erik
aad9ed4cdb fix(render): Phase A8 — EnvCellRenderer uses acdream Shader (not GLSLShader)
Task 5's subagent took GLSLShader (WB's abstract shader). Our existing
GameWindow wire-in uses the legacy AcDream.App.Rendering.Shader class
loaded once at startup for mesh_modern.{vert,frag} and shared with
WbDrawDispatcher. Matching that convention keeps the wire-in trivial
and avoids a second shader compile.

API mapping (acdream Shader is the surface here):
  Bind()                      -> Use()
  SetUniform(name, int)       -> SetInt(name, int)
  SetUniform(name, Vector4)   -> SetVec4(name, Vector4)
  SetUniform(name, Matrix4x4) -> SetMatrix4(name, Matrix4x4)  (unused)

Build green. 23/23 Wave 1+2 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:57:29 +02:00
Erik
f16b8e9812 feat(render): Phase A8 Wave 2 — EnvCellRenderer (WB EnvCellRenderManager port)
The core port. 1013 LOC of WB-faithful rendering algorithm:

- GetEnvCellGeomId        : WB EnvCellRenderManager.cs:94-103 verbatim
- PrepareRenderBatches    : WB EnvCellRenderManager.cs:247-373 verbatim
                            (parallel frustum-cull, per-cell slow path,
                            ThreadLocal merge, atomic snapshot swap)
- Render(filter:)         : WB EnvCellRenderManager.cs:395-511 verbatim
                            (filter-driven gfxObj group + draw call build)
- RenderModernMDIInternal : WB BaseObjectRenderManager.cs:709-848
                            (single-slot variant; resize buffers,
                            group by cull mode + additive, MDI draw)
- PopulatePartGroups      : WB EnvCellRenderManager.cs:572-580 verbatim
                            (Setup part recursion via PopulateRecursive)
- RegisterCell / FinalizeLandblock / RemoveLandblock — streaming seam
  (no WB analog; bridges acdream's existing StreamingController +
  LandblockStreamer to the renderer's per-cell instance store)

Documented deviations from WB:
- Drop _useModernRendering branch (Phase N.5 mandatory modern path)
- Drop SelectedInstance/HoveredInstance highlights (no editor state)
- _activeSnapshotGlobalGroups/GfxObjIds as sibling fields on the class
  rather than on the snapshot (EnvCellVisibilitySnapshot per Task 4 spec
  only carries BatchedByCell + VisibleLandblocks; global groups only
  used in the unfiltered Render(pass) path which we don't take)
- ConcurrentDictionary<uint, EnvCellLandblock> keyed by full 32-bit
  landblock id (WB uses ushort packed key; acdream uses full id throughout)

10 unit tests (GetEnvCellGeomId determinism + bit-33 dedup flag +
NeedsPrepare + dispose semantics + RemoveLandblock idempotence). Build
green; 23/23 Wave 1+2 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:55:15 +02:00
Erik
fc68d6d01f feat(render): Phase A8 Wave 1 — WB scaffolding extraction + stencil low-level method
Five tasks shipped together (interdependent at build time):

Task 1: WbRenderPass enum — verbatim port of WB RenderPass.cs:1-22
Task 2: WbFrustum + WbBoundingBox + FrustumTestResult — verbatim port
  of WB Frustum.cs (98 LOC) with namespace + BoundingBox-type adaptations.
  +7 unit tests.
Task 3: EnvCellSceneryInstance + EnvCellLandblock — verbatim port of WB
  SceneryInstance.cs:1-161, renamed scope-narrow. Dropped editor-only
  fields (DisqualificationReason, ParticleEmitters, IsQueuedForUpload,
  InstanceBufferOffset, InstanceCount, MdiCommands, IsTransformOnlyUpdate)
  + InstanceId narrowed uint (we don't use ObjectId's editor methods).
  +5 unit tests.
Task 4: EnvCellVisibilitySnapshot — direct port of WB VisibilitySnapshot
  narrowed to BatchedByCell + VisibleLandblocks only.
Task 7: IndoorCellStencilPipeline.RenderBuildingStencilMask — new
  low-level WB-faithful entry mirroring PortalRenderManager:471-484.
  No surrounding GL state setup (caller's responsibility). Probe fields
  LastStencilVertexCount / LastStencilWasFarPunch / LastStencilBuildingId
  for the [stencil] probe emitter in Task 9.

Build green, 18 tests pass (7 new Frustum + 5 new SceneryInstance + 6
existing stencil pipeline). Ready for Wave 2 (EnvCellRenderer port).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:46:07 +02:00
Erik
07c5981824 Revert "fix(render): Phase A8 RR7.3 — dat-driven BFS in BuildingLoader"
This reverts commit 56673e1b1e.
2026-05-27 14:07:13 +02:00
Erik
56673e1b1e fix(render): Phase A8 RR7.3 — dat-driven BFS in BuildingLoader
RR7.2 fix made the indoor branch fire (119K frames vs 0), but visual
verification showed missing interior textures — the inn's floor + lower
wall sections rendered as fog-color clear instead of cell-mesh polygons.
Root cause: BFS short-circuited at registry-build time on intermediate
cells that hadn't yet streamed in. The Holtburg Inn has 2 entry portals
+ 209 interior leaves; if any intermediate cell wasn't loaded when lbInfo
arrived, BFS stopped, EnvCellIds was a tiny subset of the building's true
cells, camCellIds at the gate excluded most inn cells, and IndoorPass
skipped their mesh entities → flat fog-color floor.

Fix: walk the dat directly in BFS via `dats.Get<EnvCell>(cellId)
  .CellPortals` (matches WB PortalService.cs:67-79). BFS now completes
deterministically at registry-build time regardless of cell load
ordering. Exit-portal polygon collection (Step C) also gets a dat
fallback so the stencil mask is complete on first indoor frame.

BuildingLoader.Build signature gains two optional params:
  - dats: DatCollection? — null in unit tests preserves old behavior
  - landblockOrigin: Vector3 — translation for dat-side polygons

Tests: 11/11 pass (unit-test path unchanged via dats == null).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:18:57 +02:00
Erik
3361933ce6 feat(render): Phase A8 RR5 — WbDrawDispatcher Draw(cellIds:) overload
Adds a new public overload accepting an explicit IReadOnlyCollection<uint>
cellIds (the camera-buildings' EnvCellIds) instead of a BFS-derived
visibility set. Used by RR7's IndoorPass to scope indoor rendering to the
camera-buildings' cells, not the full portal BFS (which causes Issues A+C).

Pure-data test helper WalkEntitiesForTestByCellIds added alongside the
production overload, mirroring the WalkEntitiesForTest pattern.

The overload internally delegates to the existing visibleCellIds path —
the dispatcher's semantic stays the same; only the caller's intent differs
(explicit cell list vs visibility-derived).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:18:21 +02:00
Erik
f8d0499d8b feat(render): Phase A8 RR4 — wire BuildingRegistry into landblock load
LoadedCell.BuildingId (init + internal setter) — set exactly once at
    landblock load time by BuildingLoader; null when the cell isn't
    part of any building (outdoor surface cells; dungeon cells not
    enumerated in LandBlockInfo.Buildings).

  GameWindow landblock-load path: builds BuildingRegistry from
    LandBlockInfo.Buildings; stamps each cell's BuildingId; stores the
    registry on _buildingRegistries[landblockId] (GameWindow-level dict)
    for render-frame lookups. Note: LoadedLandblock is AcDream.Core.World
    (a sealed record) — adding an App-type field there would violate
    Code Structure Rule #2, so the registry is stored in a new
    GameWindow-level dictionary instead. Cleanup wired in both
    removeTerrain lambdas (OnLoad + OnResize paths).

  drainedCells dict: the existing _pendingCells drain loop is extended
    to also build a local CellId→LoadedCell dict; BuildingLoader.Build
    uses this dict for the stamping pass so no second iteration is needed.

  New BuildingLoaderTest verifies the stamping path. 5 BuildingLoader
  tests total (4 from RR3 + 1 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:13:48 +02:00
Erik
f125fdb220 feat(render): Phase A8 RR3 — Building + BuildingRegistry + BuildingLoader
New per-landblock data model for WB-style per-building cell scoping:

  Building            — BuildingId, EnvCellIds, ExitPortalPolygons,
                        occlusion-query state (Step 5 lifecycle)
  BuildingRegistry    — two-way indexed (by cellId + by buildingId);
                        single source of truth per landblock
  BuildingLoader      — static factory from LandBlockInfo.Buildings;
                        walks interior portals to expand cell sets;
                        collects exit portal polygons in world space

10 new unit tests cover data invariants + registry indexing + loader
mapping per the algorithm resolved in RR2 findings.

LoadedCell.BuildingId stamping wired in RR4. Render-time consumption
arrives in RR7 (Steps 1-4) + RR9 (Step 5) + RR11 (RenderOutsideIn).

Design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md
Spike: docs/research/2026-05-26-a8-buildings-data-shape.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:08:43 +02:00
Erik
55f26f2a9c feat(render): Phase A8 R2 — WbDrawDispatcher.EntitySet taxonomy partition
Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to
a three-way taxonomy-aware partition:

  IndoorPass     — cell mesh + cell statics + building shells
                   (ParentCellId.HasValue OR IsBuildingShell), live-dynamic
                   excluded
  OutdoorScenery — outdoor scenery only (ParentCellId == null AND
                   !IsBuildingShell), live-dynamic excluded
  LiveDynamic    — ServerGuid != 0 (player, NPCs, dropped items)

Centralizes the membership predicate in EntityMatchesSet to keep the three
call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY.

R1's IsBuildingShell flag is now consumed at render time. Integration into
the render frame ships in R3.

Tests rebuilt from scratch — 7 cases cover the new partition truth table.
Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the
default EntitySet.All.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:42:09 +02:00
Erik
96f8bd2bd7 Revert "fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass"
This reverts commit a2ad5c1ac4.
2026-05-26 09:38:37 +02:00
Erik
a2ad5c1ac4 fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass
Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.

Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
  it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
  OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
  mirror the new partition logic

Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities

Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:55:45 +02:00
Erik
dcf69a1feb feat(render): Phase A8 — WbDrawDispatcher.EntitySet partition
Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.

Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:20:45 +02:00
Erik
3e6f6ec858 chore(O-T7): code-review housekeeping after WB extraction
Five small post-cleanup items from T7 code review:

I1: Removed dead `datDir` parameter from WbMeshAdapter ctor (parameter
    was unused after _wbDats removal; ArgumentNullException.ThrowIfNull
    was misleading). Updated call sites in GameWindow.cs and
    WbMeshAdapterTests.cs.

I2: Updated stale GameWindow.cs comment that still described
    WbMeshAdapter as opening its own dat handles. Now reflects Phase O
    state: shared DatCollection via DatCollectionAdapter.

I3: Documented thread-safety contract on RenderStateCache (render-thread
    only — required for the mutable-static GL sentinel pattern).

M1: Added comment on IDatReaderWriter's write-path methods noting they
    are preserved for verbatim compatibility but unused in acdream.

M3: Added comment on Chorizite.Core PackageReference in Core.csproj
    explaining the previously-transitive dependency.

Also excluded SplitFormulaDivergenceTest.cs from the test build via
<Compile Remove>: this N.5b one-time data-collection test referenced
WorldBuilder.Shared types directly; after Phase O-T7 dropped that
project reference it no longer compiles. The sweep data it produced
already informed the N.5b Path-C decision and the file is retained
in the tree for historical reference.

Build green; tests green (1146 + 8 pre-existing failures baseline
maintained).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:29:06 +02:00
Erik
dc722e70bd feat(O-T7): drop WB project references; complete extraction
End of Phase O extraction. Final cleanup:

- Dropped <ProjectReference> entries to WorldBuilder.Shared and
  Chorizite.OpenGLSDLBackend from both AcDream.App.csproj and
  AcDream.Core.csproj.
- Added Chorizite.Core NuGet PackageReference to AcDream.Core.csproj
  (needed by Core.Rendering.Wb.TextureHelpers for TextureFormat enum;
  previously transitive through the WB project ref).
- Added BCnEncoder.Net.ImageSharp (1.1.2) + SixLabors.ImageSharp (3.1.12)
  as direct PackageReferences to AcDream.App.csproj — previously transitive
  via Chorizite.OpenGLSDLBackend project; used directly by ObjectMeshManager.

Item A (BaseObjectRenderManager static fields):
- Inlined CurrentAtlas/CurrentVAO/CurrentIBO into a new RenderStateCache.cs
  static class (AcDream.App.Rendering.Wb namespace) — the 4 consumers
  (ManagedGLIndexBuffer, ManagedGLTexture, ManagedGLTextureArray, ParticleBatcher)
  all reference RenderStateCache.* instead of BaseObjectRenderManager.*.
- Dropped using Chorizite.OpenGLSDLBackend.Lib from all 4 consumers and from
  WbDrawDispatcher (which had it only as a dead import).

Item B (ActiveParticleEmitter.ObjectLandblock):
- ObjectLandblock? erased to object?; WorldBuilder.Shared.Models.ObjectId? erased
  to ulong? — both fields are stored but never read by any consumer in our codebase.
- Dropped both WB using directives from ActiveParticleEmitter.cs.

Item C (IDatReaderWriter / IDatDatabase):
- Verbatim copy of both interfaces into IDatReaderWriter.cs in
  AcDream.App.Rendering.Wb namespace — DatCollectionAdapter and ObjectMeshManager
  already live in that namespace, so no using changes needed.
- Dropped using WorldBuilder.Shared.Services from DatCollectionAdapter.cs and
  ObjectMeshManager.cs.

Additional extractions required by the reference drop:
- GeometryUtils.cs: verbatim copy of WorldBuilder.Shared.Lib.GeometryUtils
  (float-precision overloads only; Vector3d double-precision overloads omitted —
  ObjectMeshManager uses only the float versions).
- Dropped using WorldBuilder.Shared.Lib from ObjectMeshManager.cs.

WbMeshAdapter.cs cleanup (spec O-D12):
- Deleted _wbDats (DefaultDatReaderWriter) field + ctor init + Dispose call.
- Deleted the [indoor-upload] NULL_RESULT diagnostic block (lines ~205-262) —
  its Phase 2 cell-resolution investigation is complete; its _wbDats.ResolveId
  dependency goes with this commit.
- Deleted _pendingEnvCellRequests field + isPendingEnvCell tracking in Tick().
- Simplified Tick() to a clean drain loop.

Deleted SplitFormulaDivergenceTest.cs — one-time N.5b data-collection sweep;
job done.

Verified acceptance criteria:
- Zero <ProjectReference> to WorldBuilder.* / Chorizite.OpenGLSDLBackend.* in any csproj.
- Zero 'using WorldBuilder.*' / 'using Chorizite.OpenGLSDLBackend.*' in src/.
- DefaultDatReaderWriter referenced in zero places in src/ (comments only).

Build green (0 warnings, 0 errors).
Tests: 1154 total (-1 from deleted SplitFormulaDivergenceTest), 1146 pass,
8 pre-existing failures (unchanged from baseline — physics/input tests
unrelated to this change).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:17:33 +02:00
Erik
a9ccc5acf5 fix(O-T4): thread-safety lock in DatDatabaseWrapper + drop unused using
Code-review findings on T4:

1. Added lock(_lock) around _db.TryGet and TryGetFileBytes in
   DatDatabaseWrapper, matching WB's DefaultDatDatabase pattern.
   ObjectMeshManager.PrepareMeshDataAsync runs on the thread pool, so
   concurrent dat access through the adapter must be serialized — our
   underlying DatCollection is not documented as thread-safe.

2. Removed unused `using WorldBuilder.Shared.Models;` from WbMeshAdapter.cs
   (its only purpose was TerrainEntry, which moved to AcDream.Core in T2).

Build green; tests green (1147 passing, 8 pre-existing failures baseline).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:43 +02:00
Erik
c0326523ac fix(O-T4): address spec-review findings — InstanceData + using cleanups
Four fixes from T4 spec review:

1. Extracted InstanceData.cs (14-line struct) verbatim to
   src/AcDream.App/Rendering/Wb/InstanceData.cs (per O-D1).

2. ObjectMeshManager.cs: replaced `using Chorizite.OpenGLSDLBackend.Lib;`
   with `using AcDream.Core.Rendering.Wb;` (TextureHelpers comes from
   our T2 Core extraction; InstanceData comes from new T4 cleanup).

3. EmbeddedResourceReader.GetEmbeddedResource promoted from `internal`
   to `public` per O-D9 intent (the type promotion only changed the
   class signature in T3; this finishes the spec).

4. OpenGLGraphicsDevice.cs: removed stale T3 interim comment at
   lines 142-145 — T4 resolved the ParticleBatcher construction
   via post-ctor assignment in WbMeshAdapter.cs:78.

Build green; tests green (1147 passing, 8 pre-existing failures
baseline maintained).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:50:05 +02:00
Erik
d16d8cd4e5 feat(O-T4): extract ObjectMeshManager + mesh pipeline closure into AcDream.App.Rendering.Wb
Phase O Task 4: extract the WB mesh pipeline (ObjectMeshManager + 7 support files)
from references/WorldBuilder into src/AcDream.App/Rendering/Wb/ and bridge dat I/O
through our DatCollection via a thin DatCollectionAdapter.

O-D7 adapter path taken: ObjectMeshManager has 26 _dats.X call sites (threshold 20),
so a DatCollectionAdapter : IDatReaderWriter is introduced rather than refactoring
ObjectMeshManager's internal dat access directly.

Files added (verbatim copies, namespace-only changes):
- ObjectMeshManager.cs — mesh pipeline hub; IDatReaderWriter field satisfied by adapter
- GlobalMeshBuffer.cs — single global VAO/VBO/IBO manager
- EdgeLineBuilder.cs — wireframe edge geometry from CellStruct polygons
- ModernRenderData.cs — ModernBatchData + LandblockMdiCommand structs
- TextureAtlasManager.cs — texture array grouping by (Width, Height, Format)
- ParticleBatcher.cs — GPU particle batching; T4 interim uses BaseObjectRenderManager
  static fields from Chorizite.OpenGLSDLBackend.Lib (stays until T7)
- ParticleEmitterRenderer.cs — per-emitter particle lifecycle + rendering
- ActiveParticleEmitter.cs — wrapper holding renderer + part index + local offset
- DatCollectionAdapter.cs — NEW: bridges DatCollection → IDatReaderWriter; implements
  ResolveId() via DatDatabase.TypeFromId + Tree.TryGetFile in HighRes→Portal→Language→Cell
  order matching DefaultDatReaderWriter; DatDatabaseWrapper wraps DatDatabase as IDatDatabase

WbMeshAdapter.cs changes (T4 Step 6):
- _graphicsDevice switched from Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice to
  extracted AcDream.App.Rendering.Wb.OpenGLGraphicsDevice
- ParticleBatcher = new ParticleBatcher(_graphicsDevice) restored (T3 had null! placeholder)
- ObjectMeshManager now constructed with new DatCollectionAdapter(dats) instead of _wbDats
- _wbDats field + its construction + disposal + [indoor-upload] NULL_RESULT diagnostic block
  left intact — T7 cleanup removes these once WorldBuilder project ref is dropped

EmbeddedResourceReader.cs: replaced assembly manifest lookup (wrong prefix for our assembly)
with disk-based lookup mapping "Shaders.Particle.vert" → Rendering/Shaders/wb_particle.vert;
consistent with all other acdream shaders.

wb_particle.vert / wb_particle.frag: WB particle shaders copied verbatim with wb_ prefix
to distinguish from acdream's own particle.vert.

OpenGLGraphicsDevice.cs: ParticleBatcher property type updated to extracted ParticleBatcher;
setter changed from private to internal so WbMeshAdapter (same assembly) can assign post-ctor.

Build: green (0 errors, 0 warnings in AcDream.App).
Tests: 1147+8 baseline maintained (8 pre-existing failures unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:37:55 +02:00
Erik
4cc38805b5 feat(O-T3): extract GL infrastructure to AcDream.App
Phase O Task 3 — verbatim-copy GL infra from Chorizite.OpenGLSDLBackend
into src/AcDream.App/Rendering/Wb/ (namespace AcDream.App.Rendering.Wb).

18 files extracted (all namespace-changed; no algorithm changes):
  OpenGLGraphicsDevice, ManagedGLTexture, ManagedGLTextureArray,
  ManagedGLVertexBuffer, ManagedGLIndexBuffer, ManagedGLVertexArray,
  ManagedGLFrameBuffer, ManagedGLUniformBuffer, GLSLShader, GLHelpers,
  GLStateScope, GpuMemoryTracker, SceneData, DebugRenderSettings,
  TextureParameters, TextureFormatExtensions, BufferUsageExtensions,
  EmbeddedResourceReader.

3 internals promoted to public (O-D9):
  EmbeddedResourceReader, TextureFormatExtensions, BufferUsageExtensions.

SixLabors.ImageSharp not reachable: TextureHelpers was placed in
AcDream.Core (no GL/ImageSharp dep); only the GL types went to App.

TextureHelpers.GetCompressedLayerSize added to AcDream.Core.Rendering.Wb
(was in Chorizite.OpenGLSDLBackend.Lib.TextureHelpers; uses
Chorizite.Core.Render.Enums.TextureFormat which Core gets transitively
via the still-present WB project refs).

T3/T4 boundary interims:
  - WbMeshAdapter._graphicsDevice stays Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice
    (T4 will swap it when ObjectMeshManager is extracted).
  - OpenGLGraphicsDevice.ParticleBatcher deferred to null! (T4 extracts
    ParticleBatcher alongside ObjectMeshManager; can't pass `this` of our
    new type to the WB-original ctor before T4).
  - ManagedGLTextureArray uses our TextureHelpers via explicit alias.
  - IUniformBuffer is in Chorizite.Core.dll under Chorizite.OpenGLSDLBackend
    namespace (unusual packaging); resolved via type alias.
  - AcDream.App.csproj gets explicit Chorizite.Core 0.0.18 PackageReference
    (IUniformBuffer + other Chorizite.Core types now used directly in App).

Build green. Test baseline 1147+8 maintained (1902 passing, 8 pre-existing
MotionInterpreterTests failures unrelated to T3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:00:31 +02:00
Erik
b838eccb38 feat(wb): ConsoleErrorLogger + cause report — H1 swallowed-exception confirmed
Phase 2 diagnostic chain identified the EXACT cause of 26/123 Holtburg
cells silently failing in WB's PrepareEnvCellMeshData:
ArgumentOutOfRangeException thrown from Setup.Unpack inside
DatReaderWriter when WB calls TryGet<Setup>(stab.Id, ...) on a stab id
whose prefix is GfxObj (0x01xxxxxx), not Setup (0x02xxxxxx).
DatReaderWriter finds the file in Portal's tree (GfxObjs and Setups
share tree-lookups), attempts to parse GfxObj bytes as Setup format,
throws OOR. Exception bubbles to PrepareMeshData's outer try/catch
which silently swallows + returns null. Entire cell fails to upload.

This commit lands the diagnostic infrastructure that surfaced the bug:

- WbMeshAdapter: replaced NullLogger<ObjectMeshManager> with a small
  Console-backed ConsoleErrorLogger<T> private class. Filters to
  LogLevel.Error+. WB's existing _logger.LogError(ex, ...) at the
  swallow site now writes [wb-error] lines with type + message + top 5
  stack frames. Bridges WB's intentional log point to acdream's console.
- WbMeshAdapter: extended [indoor-upload] NULL_RESULT probe with
  reader-divergence diagnostic (ourCellDb.TryGet, wbResolveId.Count,
  wbSelectedType, wbDbIsPortal, wbDbTryGet<EnvCell>, hadRenderData).
  Made it possible to rule out cache-hits and reader-divergence as
  causes before identifying the real one.
- Cause report at docs/research/2026-05-19-indoor-cell-rendering-cause.md
  documents the full chain: 55 ArgumentOutOfRangeException stack traces
  captured in one launch, all from PrepareEnvCellMeshData line 1223.

The fix itself (1-line guard at WB's TryGet<Setup> call site) is applied
to references/WorldBuilder/.../ObjectMeshManager.cs — which is a git
submodule. Will be committed separately to the WB submodule after
visual verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:00:18 +02:00
Erik
914638819d feat(wb): extend NULL_RESULT probe with reader-divergence diagnostic
Phase 2 Task 1's continuation logged [indoor-upload] NULL_RESULT
when WB's PrepareMeshDataAsync returned null. Extend the line to
include two cross-checks:

  ourCellDb.TryGet=<bool>    — acdream's DatCollection.Cell.TryGet<EnvCell>
  wbResolveId.Count=<int>    — WB's DefaultDatReaderWriter.ResolveId().Count

This narrows the cause among WB's null-return paths (ResolveId empty
vs TryGet<EnvCell> failure vs wrong type). Best-effort: both calls
wrapped in try/catch so diagnostic failures don't propagate.

Capture: 55 NULL_RESULTs across multiple landblocks ALL show
ourCellDb.TryGet=True + wbResolveId.Count=1. Both readers find the
cells in their indices, but WB's downstream PrepareMeshData logic
still returns null. Divergence is downstream of ResolveId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:36:37 +02:00
Erik
011a5e43f4 feat(wb): surface WB-swallowed exceptions for EnvCell upload failures
Phase 1 confirmed 26/123 Holtburg cells silently fail in WB's
PrepareEnvCellMeshData / PrepareMeshData. WB's catch block at
ObjectMeshManager.cs:589 calls _logger.LogError(ex, ...) — but we
construct ObjectMeshManager with NullLogger, so the log is dropped.

Capture the Task from PrepareMeshDataAsync (previously fire-and-forget)
and attach a ContinueWith that, for EnvCell ids only when the probe
is on, logs:

  [indoor-upload] FAILED cellId=0x... exception=<Type>: <Message>
                          stack=[<top 3 frames>]
  [indoor-upload] NULL_RESULT cellId=0x...

Runs on ThreadPool — non-blocking. Zero cost when ProbeIndoorUploadEnabled
is off. AggregateException is unwrapped to InnerException for readability.
Stack truncated to top 3 frames.

Next: capture procedure, identify cause, target the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:25:31 +02:00
Erik
9b948b6ad5 feat(dispatcher): [indoor-lookup] + [indoor-xform] probes
Instruments the per-MeshRef draw loop in WbDrawDispatcher:

- [indoor-lookup]: per cell entity, dumps render-data hit/miss,
  IsSetup, parts count, and a partsHit/partsMiss tally over the
  SetupParts. Disambiguates hypothesis H2 (WB produces empty
  ObjectRenderData with zero parts) and H6 (dispatcher fails to
  traverse Setup).

- [indoor-xform]: only fires for the cell's synthetic geometry part
  (the SetupPart whose GfxObjId has bit 32 set, per WB's
  PrepareEnvCellMeshData cellGeomId convention). Logs the three
  composed transform translations: entityWorld, meshRef.PartTransform,
  partTransform, and the final composed matrix translation. Disambiguates
  hypothesis H5 (transform double-apply — composedT lands at 2 ×
  cellOrigin).

Rate-limited via the ShouldEmitIndoorProbe instance helper added in
Task 6 (now consumed — no longer dead code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:54:45 +02:00
Erik
36a29ceff5 feat(dispatcher): [indoor-walk] + [indoor-cull] probes
Instruments WalkVisibleEntities to identify whether cell entities (first
MeshRef.GfxObjId low-16-bits >= 0x0100) pass all visibility filters or
get culled. Three emission paths:

- [indoor-cull] reason=visibleCellIds-miss -- when the ParentCellId
  filter rejects the entity.
- [indoor-cull] reason=frustum -- when AABB frustum cull rejects.
- [indoor-walk] -- when the entity passes all filters and reaches the
  draw list.

Rate-limited to once per cellId per ~1 sec (30 frames at 30 Hz) via
IndoorProbeState, a nested class wrapping _lastIndoorProbeFrame dictionary
and _indoorProbeFrameCounter (bumped at top of Draw()). WalkEntitiesInto
accepts a new optional IndoorProbeState? parameter (null = probes off,
default) so the test-friendly WalkEntities overload is unaffected. The
ShouldEmitIndoorProbe instance helper is also retained for Task 7 use.

Disambiguates hypothesis H3 (cull bug -- cell entity dropped before draw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:48:27 +02:00
Erik
1dd20ddd40 feat(wb): [indoor-upload] probe for EnvCell mesh requests + completions
Instruments WbMeshAdapter at two sites:
- IncrementRefCount: on first call for an EnvCell id (low 16 bits >=
  0x0100), tag the id in _pendingEnvCellRequests and log
  [indoor-upload] requested.
- Tick: when WB's StagedMeshData drains an ObjectMeshData whose
  ObjectId matches a pending EnvCell, log [indoor-upload] completed
  with parts count, EnvCellGeometry vertex count, and upload result.

Missing "completed" lines after "requested" identify hypothesis H1
(WB silently returns null from PrepareEnvCellMeshData).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:37:58 +02:00
Erik
25cb147d97 fix(perf #N6.1): gate gpu_us read on diag for symmetric toggle behavior
Code-quality review on Task 1 (commit a7c9800) flagged an asymmetric
diag gate: the read-before-overwrite block at the top of the dispatcher
was not gated on diag, but the frame-counter increment and BeginQuery
calls were. If a maintainer toggled ACDREAM_WB_DIAG from "1" to "" mid-
session, _gpuQueryFrameIndex would freeze (gated inside if(diag)) while
the read kept firing every frame at the same slot — producing duplicate
stale samples.

Add diag to the read block's outer condition so the read/issue/increment
trio is symmetric. One-line change; behavior under the normal usage
pattern (env var set at launch, never toggled) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:28:22 +02:00
Erik
a7c98004bb feat(perf): Phase N.6 slice 1 — fix gpu_us double-buffering in WbDrawDispatcher
The dispatcher's GPU TimeElapsed queries were polled in the same frame
as the indirect draw, so glGetQueryObject(ResultAvailable) always
returned 0 and gpu_us in [WB-DIAG] was stuck at 0m/0p95.

Replace the 2 single-handle queries with ring-of-3 arrays and move the
result read to BEFORE issuing the next frame's queries into the same
slot — at frame N we read slot N%3 which holds frame N-3's queries
(oldest in the ring, ~50ms old at 60fps and definitely done across all
desktop GL drivers). Vendor-neutral: AMD/NVIDIA/Intel desktop GL all
work without driver-specific code.

The gpuQuerySlot variable is hoisted to function scope (just before
Phase 7 opaque pass) so both the opaque and transparent passes
reference the same slot — the plan placed it inside the opaque-pass
if-block, which would have been out of scope for the transparent
BeginQuery; corrected in the implementation.

No new tests — the change is purely a diagnostic readout fix, no
observable behavior in the rendering path. Build green; tests at
baseline (1711 passing, 8 pre-existing physics/MotionInterpreter
failures unchanged). Manual gpu_us verification still pending in-world.

Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md (§4).
Plan: docs/superpowers/plans/2026-05-11-phase-n6-slice1.md (Task 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:24:26 +02:00
Erik
f928e66119 fix(render #53): incomplete-entity flag must persist across same-entity tuples
User reported (cache enabled, post-c55acdc): drudge statue renders fully
but many trees are missing branches. Cache-disabled A/B run rendered trees
correctly. So the bug is in the cache wiring.

Root cause: c55acdc's `currentEntityIncomplete = false;` reset fired
UNCONDITIONALLY at the top of every iteration. For a tree with MeshRefs
[trunk valid, branches null, leaves valid], the tuple sequence is:

  - tuple 0 (trunk): no flag set
  - tuple 1 (branches): TryGetRenderData null → set flag, continue
  - tuple 2 (leaves): unconditional reset → flag = false (WRONG)
  - end-of-entity: flag is false, scratch has trunk+leaves batches but NOT
    branches → MaybeFlushOnEntityChange populates a PARTIAL cache entry
  - cache hits forever serve trunk+leaves with no branches

Drudge happened to render correctly because its missing MeshRef was at the
END of its MeshRefs list — no later tuple reset the flag.

Adds a per-tuple `prevTupleEntityId` tracker for entity-change detection,
updated UNCONDITIONALLY at end of each tuple (including tuples that skip
via null renderData). The flag-reset block now fires ONLY on actual entity
change. Within the same entity, the flag accumulates across tuples.

Also includes ACDREAM_DISABLE_TIER1_CACHE=1 diagnostic env-var added
inline (was stashed previously) for future A/B testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:56:58 +02:00
Erik
c55acdc3d5 fix(render #53): skip cache populate when classification is incomplete
User reported: the drudge statue on top of the Foundry (a multi-part
live-spawned entity with AnimPartChange + texChanges) renders only
PARTIALLY — some parts visible, some missing.

Root cause: the dispatcher's slow path skips a MeshRef when
_meshAdapter.TryGetRenderData returns null (mesh still async-decoding
via ObjectMeshManager.PrepareMeshDataAsync). The classified-batches
collector accumulates only the MeshRefs that DID resolve. At entity
boundary, the cache populates with the PARTIAL set. Frame-2 cache hits
serve that partial entry forever — even after the missing mesh loads,
the cache continues to skip those parts because classification never
reruns for cached entities.

Fix: track currentEntityIncomplete during the foreach. Set it true on
any null renderData. At entity boundary (and at end-of-loop), if the
flag is set, DROP the accumulated populate scratch instead of writing
it to the cache. The slow path retries on the next frame; once all
meshes have loaded, the populate fires correctly with the complete
classification.

Adds a regression test pinning the contract — incomplete entities
produce zero cache entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:42:46 +02:00
Erik
95ebbf3004 fix(render #53): key cache by (entityId, landblockHint) to defeat ID collision
User confirmed via A/B test (ACDREAM_DISABLE_TIER1_CACHE=1) that the
visual bug — buildings rendering up in the air outside Holtburg — is in
the cache wiring, not elsewhere. The matrix math (restPose * entityWorld
== model) was provably correct, so the bug had to be cache key collision.

Stabs were namespaced in commit 71d0edc, but scenery (0x80LLBB00 +
localIndex) and interior (0x40LLBB00 + localCounter) still have the
same 256-overflow risk. Dense LBs outside Holtburg (forest, urban) push
localIndex past 255, wrapping into the lbY byte and creating cross-LB
collisions.

Fix: change the cache key from uint entityId to (uint, uint) tuple of
(EntityId, LandblockHint). The cache is now correct-by-construction
regardless of any hydration path's Id-generation strategy. Defensive
against future regressions in any ID namespace.

InvalidateEntity becomes a sweep (was O(1)), but it's called rarely
(only on live-entity despawn). InvalidateLandblock was already a sweep.

Updated 14 existing cache tests + 1 dispatcher integration test to thread
landblockHint through TryGet / DebugCrossCheck calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:02:14 +02:00
Erik
4df19146ff docs(render #53): clarify DebugCrossCheck's wiring status
Code review of f16604b flagged that DebugCrossCheck's XML doc claimed
"called once per static-entity cache hit per frame" — overstated. The
method is currently exercised by unit tests only; the dispatcher's
cache-hit branch fires a simpler predicate assert (!isAnimated) at
production hit time, not the full live-state cross-check. Wiring the
full cross-check is the spec section 6.5 stretch goal, kept open as a
follow-up.

Doc-only change. No behavior change. 1708 / 8 baseline preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:49:13 +02:00
Erik
f16604b60b feat(render #53): DEBUG cross-check guards against the prior Tier 1 bug class
Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that
asserts cached state matches a live re-classification. Wires a simpler
predicate assert into WbDrawDispatcher's cache-hit branch (asserts
isAnimated == false on cache hit). Tests #13a and #13b cover the
batch-count mismatch and clean-match cases via a custom TraceListener
that captures Debug.Assert calls.

Zero cost in Release. In DEBUG, the assert fires immediately if a future
regression mutates static-entity state outside the audit's known write
sites — the same failure mode that bit the prior Tier 1 attempt.

Phase 4 complete. Cache + invalidation + safety net all in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:43:24 +02:00
Erik
f7e38c214d fix(render #53): cache-hit fast path must fire per-entity, not per-tuple
Task 10 (commit 0cbef3c) called ApplyCacheHit inside the per-(entity, partIdx)
foreach loop, but cachedEntry.Batches is flat across all MeshRefs of the
entity. For a 3-MeshRef static building on frame 2: 3 tuples times 6 cached
batches per call = 18 instances drawn instead of 6. Severe Z-fighting and
3x perf hit on every multi-part static entity (buildings, statues, multi-
MeshRef NPCs).

This is the symmetric mirror of the Task 9 bug fixed at 00fa8ae. Both
spec section 5.2 and the plan describe the foreach as per-entity, but
_walkScratch has been per-tuple since Task 6. The implementation
faithfully ported the buggy spec.

Fix: track lastHitEntityId; the cache-hit fast path fires only on the
first tuple of each entity, and subsequent tuples skip the iteration
body via continue. Adds a regression test pinning the per-entity
amplification invariant.

Caught by code review (subagent-driven-development) before Phase 3
dispatched. The bug was invisible in the no-multi-frame-test 1702/8
baseline; would have manifested as visible Z-fighting on every multi-
part building on second-and-subsequent frames once Task 13 perf gate
captured live runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:15:20 +02:00
Erik
0cbef3c8b3 feat(render #53): cache-hit fast path + dispatcher integration tests
WbDrawDispatcher.Draw now branches on cache hit before running classification:
on hit, walks the cached flat batch list and appends RestPose times entityWorld
to the matching groups; on miss, runs today's classification and populates
the cache (Task 9). Animated entities skip the cache entirely.

Adds dispatcher integration tests #11 (static entity populates + reuses)
and #12 (animated bypasses) per spec test plan section 7.2, plus the
multi-MeshRef regression test that would have caught the bug fixed in
commit 00fa8ae (cache populate must flush at entity boundary, not per-tuple).

Phase 2 (dispatcher integration) complete. End-to-end caching now live.
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:56:33 +02:00
Erik
00fa8ae839 fix(render #53): cache Populate must flush at entity boundary, not per-MeshRef tuple
Task 9 (commit 2f489a8) called _cache.Populate inside the per-tuple
foreach loop, but _walkScratch contains one tuple per (entity, MeshRefIndex)
and the cache is keyed by entity.Id. For multi-MeshRef entities (multi-part
Setup buildings, statues, multi-MeshRef NPCs), each iteration's Populate
OVERWROTE the previous one — only the last MeshRef's batches survived.

The bug was invisible at commit time because Task 10 had not landed
(cache populates but isn't read). It would have manifested the moment
Task 10 wired the cache-hit fast path: every multi-part static building
in Holtburg would render as N stacked copies of its last part.

Fix: restructure the per-entity loop with a flush-on-entity-change pattern.
Track the previous entity's Id; when the iteration moves to a different
entity, flush the previous entity's accumulated _populateScratch via one
Populate call. After the loop, flush the final entity. _populateScratch
is now cleared at flush time, not per-iteration.

Caught by code review (subagent-driven-development) before Task 10 dispatched.
Verified: 1699/8 baseline preserved, sentinel 105/105 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:36:57 +02:00
Erik
2f489a83a7 feat(render #53): cache-miss populate on first frame for static entities
Restructures Draw's per-entity loop: animated entities still skip the
cache entirely, but static entities now collect their classification into
_populateScratch and call cache.Populate at the end of the iteration.

Cache fast-path (skip slow classification on cache hit) lands in Task 10.
This intermediate state is verifiable: behavior unchanged, but the cache
is being populated as entities render. Diagnostic-friendly split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:24:26 +02:00
Erik
28513eae88 feat(render #53): add optional CachedBatch collector to ClassifyBatches
ClassifyBatches now accepts a restPose parameter (the model-matrix
component without entityWorld baked in) and an optional collector. When
collector is non-null, each classified batch is appended as a CachedBatch
record. Defaults preserve today's behavior. Used in Task 9 to populate
the cache on a static-entity miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:35 +02:00
Erik
a65a241981 feat(render #53): inject EntityClassificationCache into WbDrawDispatcher
Adds the cache as a constructor parameter on WbDrawDispatcher and a
private field on GameWindow. The cache is passed through but not yet
consumed by Draw — that wires up in Task 9 (cache miss / populate) and
Task 10 (cache hit / fast path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:05:03 +02:00
Erik
60fbfce8bc refactor(render #53): plumb landblockId through WbDrawDispatcher walkScratch
Extends the walk scratch tuple from (entity, meshRefIndex) to
(entity, meshRefIndex, landblockId). The dispatcher's per-entity loop now
has the landblock id available for EntityClassificationCache.Populate's
landblockHint argument (consumed in Task 9). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:55:51 +02:00
Erik
a171e7007b feat(render #53): EntityClassificationCache.InvalidateLandblock + tests
Sweep-by-landblock removal for the streaming demote/unload path. Tests
#6, #7, #8 from spec section 7.1 lock in: (a) all matching entries removed,
(b) non-matching entries preserved, (c) idempotent on missing LB.

Phase 1 (cache foundation) complete. 11 cache tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:47:57 +02:00
Erik
aea4460eae feat(render #53): EntityClassificationCache.InvalidateEntity + tests
Idempotent removal of a cached entry by entity id. Tests #4 and #5 from
spec section 7.1 lock in the contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:42:09 +02:00
Erik
694815c499 feat(render #53): EntityClassificationCache.Populate + roundtrip tests
Implements Populate (insert-or-overwrite) and adds 5 tests covering the
populate->TryGet round-trip including the Setup pre-flatten shape. Per
spec test plan section 7.1 tests #2, #3, #9, #10, #14.

Tests use xUnit Assert.* (not FluentAssertions) to match the Task 2
implementer's choice and the existing 149 sibling assertions in the Wb
test directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:34:48 +02:00