Adds the GPU mechanism to clip drawing to a per-cell screen-space convex
region via gl_ClipDistance, consumed by the mesh + terrain vertex shaders.
This is the MECHANISM only — every instance defaults to slot 0 (no-clip /
pass-all) and terrain to count 0, so the running game renders IDENTICALLY to
pre-U.3 (verified: offline launch compiles both shaders and reaches steady
state; no GL errors). U.4 populates real clip data from portal visibility.
Binding contract (define once, both sides obey):
- mesh_modern.vert: SSBO binding=2 CellClip[] (shared per-frame regions, slot 0
reserved no-clip) + SSBO binding=3 uint[] per-instance slot, indexed by the
IDENTICAL gl_BaseInstanceARB+gl_InstanceID used for binding=0. binding=0/1
untouched.
- terrain_modern.vert: UBO binding=2 TerrainClip { int count; vec4 planes[8]; }
for the single OutsideView region (UBO namespace; SceneLighting is UBO
binding=1, so binding=2 is free and does not collide with the mesh SSBO
binding=2). count 0 = ungated.
- Both redeclare out gl_PerVertex { vec4 gl_Position; float gl_ClipDistance[8]; }
and set unused planes (i >= count) to +1.0 so they pass everything.
CellClip std430 layout (144 bytes/slot): count@0, 3 pad uints@4/8/12,
planes[8]@16 (vec4 stride 16). Terrain UBO std140: count@0 (padded to 16),
planes[8]@16 → 144 bytes. Verified by ClipFrameLayoutTests (8 new tests).
Pieces:
- ClipFrame: per-frame container + uploader for the SHARED clip data (binding=2
SSBO + terrain UBO). NoClip() = slot 0 + terrain count 0. AppendSlot /
SetTerrainClip pack std430/std140 bytes for U.4. UploadShared binds both.
- WbDrawDispatcher + EnvCellRenderer: each owns its binding=3 zero buffer
(all-zeros sized to its instance count → slot 0), re-binds binding=2 from the
shared ClipFrame id (or an internal no-clip fallback if unwired) before MDI.
gl_ClipDistance is per-vertex, so the single glMultiDrawElementsIndirect per
group is preserved — no draw splitting.
- TerrainModernRenderer: binds the terrain clip UBO (shared or no-clip fallback)
before its draw.
- GameWindow: glEnable(GL_CLIP_DISTANCE0..7) once at init (unused planes pass-all
so always-on avoids per-draw thrash); per frame builds ClipFrame.NoClip(),
UploadShared, and hands the buffer ids to the three renderers (tiny diff; U.4
swaps NoClip() for the real portal-visibility frame).
Gate: dotnet build green; App suite 134/134; offline launch confirms both
shaders compile + link with no GL errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CellView convex polygon edges → clip-space planes (nx,ny,0,d) for gl_ClipDistance,
≤8 with collinear-edge merge. Multi-polygon or >8-edge regions degrade to the union
AABB scissor (over-include, never hide); empty regions are distinct (draw nothing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review caught a CRITICAL under-inclusion: ApplyReciprocalClip scanned for the
first OtherCellId match, so a cell with two portals to the same neighbour clipped both
near-side openings against the FIRST reciprocal polygon — hiding geometry through the
second opening (real on Holtburg cellar cells 0x148<->0x149). Plumb the dat's
OtherPortalId back-link through CellPortalInfo + BuildLoadedCell and index the reciprocal
directly (retail arg2->other_portal_id, 433557). Skip (degrade to over-include) when the
index is unresolvable — never clip against a guessed polygon. Adds a disjoint two-back-
portal regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clip the portal opening against the neighbour's matching back-portal polygon
before propagating, so a cell's clip region is the intersection of the opening
seen from both sides. Closes the M-4 stub in ISSUES #102. Can only tighten,
never under-include; degrades to prior behavior when no back-portal is found.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review minor follow-ups: correct the CellTodoList comments (ties are LIFO,
not FIFO — an equal-distance newcomer lands at the tail and pops first, matching
retail's break-on-first-not-greater + pop-from-tail). Update ISSUES #102 to record
that U.2a closes I-1/I-2 (under-count + duplicate accumulation) via the enqueue-once
gate, narrowing the residual to diamond-topology clip-completeness (AddToCell onward
re-propagation, tracked under U.6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PortalVisibilityFrame gains OrderedVisibleCells (closest-first). Replace the FIFO +
MaxReprocessPerCell cap with a distance-priority queue and a grow-watermark fixpoint
(retail InsCellTodoList 433183 / AddViewToPortals 433446) so cyclic dungeon graphs
converge without duplicate-cell blow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (U.4a replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.
Also deleted with the partition: the two test-only walk helpers
(WbDrawDispatcher.WalkEntitiesForTest / WalkEntitiesForTestByCellIds) and their
test files (WbDrawDispatcherEntitySetTests, WbDrawDispatcherCellIdsOverloadTests),
which existed solely to exercise the removed IndoorPass/OutdoorScenery/
BuildingShells/LiveDynamic partition. EntityMatchesSet / IsShellScopedSet collapse
to the All-path constants; the set: parameter is retained as a seam for the
unified pass.
Note: the depth-clear-if-inside default-path workaround was removed per the
U.1 task list — any current indoor-wall degradation persists until a later
Phase U task lands the unified pass (expected, not a regression introduced here).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visual verification showed the camera vibrating/bouncing when pressed against a
wall. Cause: the sweep wrote its clamped result back into _dampedEye, so the
next frame's damping lerped from the wall toward the target and the sweep
re-clamped it — a per-frame feedback loop. Retail keeps viewer_sought_position
(damped, uncollided) separate from viewer (the published collided eye). Fix:
collide into a separate publishedEye for Position/View/fade and leave _dampedEye
as the clean sought position. New regression test
Update_CollisionDoesNotCorruptDampedState (clamp-then-release → full recovery).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a checkable "Collide Camera (spring arm)" item to the Camera submenu.
Clicking it flips CameraDiagnostics.CollideCamera, matching the live A/B
toggle pattern used for UseRetailChaseCamera. The checkmark reflects the
current flag value so state is always visible in the menu.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both RetailChaseCamera construction sites now supply CollisionProbe with a
fresh PhysicsCameraCollisionProbe(_physicsEngine). The per-frame Update
call gains cellId: _playerController.CellId and selfEntityId:
_playerController.LocalEntityId so the probe has the correct spatial
context for sphere-sweep queries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add ICameraCollisionProbe? CollisionProbe { get; init; } to RetailChaseCamera.
Extend Update() with optional cellId/selfEntityId params (default 0) so all
existing callers compile unchanged. After the exponential-damping block (step 5)
and before publishing Position/View (step 6), sweep _dampedEye through the
probe when CameraDiagnostics.CollideCamera is true and a probe is wired in
(step 5b). The fade computation in step 7 then naturally uses the collided eye.
Null probe and cellId=0 both short-circuit cleanly. Three new xUnit tests
cover: probe-wired+flag-on publishes collided eye, flag-off skips probe,
null probe doesn't throw. All 30 RetailChaseCameraTests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code review found the probe passed ObjectInfoState.None; retail's
SmartBox::update_viewer calls init_object(player, 0x5c) =
IsViewer|PathClipped|FreeRotate|PerfectClip (pseudo-C :92864). PathClipped makes
the sweep hard-stop at first contact (TransitionTypes.cs:811) instead of
edge-sliding around corners (which would re-trigger the A8.F camera-cell
instability); IsViewer lets the eye pass through creatures, colliding only with
world geometry. Resolves the spec's slide-vs-stop open question. Also reset
CollideCamera in the Defaults_AreRetailValues baseline test (review: maintenance
trap). Spec §5.1/§11.1 synced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First-fix from the visual-gate-failure handoff: an empty OutsideView means
"no outdoors visible from here," not "all outdoors." When inside a building
with an empty clipped mask, Step 4 now draws NO terrain/scenery instead of
disabling the stencil and flooding ungated terrain over the cell interior
(the Step-3 walls already occupy the framebuffer). Visual-confirmed: Holtburg
cottage cellar walls are solid now, no terrain bleed-through.
Also adds portal diagnostics that root-caused so-called "Bug B":
- PortalVisibilityBuilder: per-camera-cell CAMPORTAL census (polyLen +
side-test result) emitted BEFORE the BFS guards, so an empty OUTSIDEVIEW
can be traced to the exact gate.
- A8CellAudit `portals`: replicate BuildLoadedCell's polygon-vertex
resolution so PortalPolygons[i] validity is checkable offline.
Finding: the builder is largely CORRECT — it produces narrowed clipped
OutsideView regions for most cells (0172/0173/0162/015E/0165/016F). The
empty cases are mostly legitimate (windowless cellar can't see out; the
3rd-person camera eye on the outdoor side of a front-door plane culls that
exit). The handoff's Finding 2 ("under-produces, never narrows") is
substantially not real. Remaining wall-missing regressions in OTHER
buildings live in the cross-building Step-5 enforcement, escalated separately.
All gated behind ACDREAM_A8_INDOOR_BRANCH=1; default play unaffected.
App tests 108/108.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Env-gated diagnostics (off by default; do not affect the default game):
- ACDREAM_A8_DUMP_PV=1: PortalVisibilityBuilder dumps local→NDC→clipped portal
geometry + OutsideView poly count for the first 2 Build calls per camera cell.
- ACDREAM_PROBE_ENVCELL=1: [opaque] line dumps the opaque cell-render stats
(cells/tris) BEFORE the per-cell transparent loop overwrites _envCellRenderer.Stats.
Used to diagnose the A8.F visual-gate failure (see handoff doc). Gated behind
ACDREAM_A8_INDOOR_BRANCH=1 like the rest of the indoor branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DrawRegionBit2 set DepthFunc.Always and never restored it; EnvCellRenderer
and WbDrawDispatcher rely on ambient DepthFunc, so the leak made clipped
translucent cells, the camera cell + cells iterated after a clipped one, and
the IndoorPass building shells all render with Always instead of Less (walls
drawing through each other). DrawRegionBit2 now restores DepthFunc.Less on
exit; EnableBit2CellPass sets the per-cell render state (Less + depth-write
off) explicitly so the bug class can't silently recur. ColorMask matched to
the indoor pass (alpha-write off).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The clip predicate (w+z>=0) is convention-agnostic, not GL-specific:
Matrix4x4.CreatePerspectiveFieldOfView (which all acdream cameras use) is
NDC z in [0,1], not [-1,1]. Comment said "GL near plane / z_ndc>=-1" which
is misleading though the code is correct (eye w=0 always excluded; divide
safe under both conventions). Also soften the ProjectToNdc CCW claim: it
preserves projected winding; the caller must feed camera-facing portals.
No behavior change. (Opus code-review I-1/M-1.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Task 0 baseline cleanup. Removes the temporary A8 step-disable diag
toggles (A8Diag* properties + ACDREAM_A8_DIAG_* env reads) that the A8
batch left behind in RuntimeOptions, and unwraps their guards in
GameWindow.RenderInsideOutAcdream so every guarded draw (Step 2 punch,
Step 3 EnvCell-opaque + IndoorPass, Step 4 terrain + outdoor scenery,
portal depth-clamp) now runs unconditionally. RuntimeOptionsTests drops
the matching assertions. The ACDREAM_PROBE_VIS apparatus
(EmitDrawOrderProbe / EmitStencilProbe / EmitBuildingsProbe /
EmitEnvCellProbe) is preserved untouched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
After 5 visual-gate failures with speculative fixes that each addressed
plausible-looking symptoms without resolving the chaos (texture flicker,
missing walls, GPU 100%, ~10 FPS), this commit stops the speculation and
ships a kill-switch that reverts default behavior to pre-A8.
The user's verbatim authorization at session start said "no quickfixes
or fixes that might cause issues down the line ... no band-aids." The
post-Wave-5 fix stream WAS band-aids — each fix was pattern-matched
against possible RR7-era causes without confirming the actual root
cause from evidence. Five failures in a row is the signal to stop.
ACDREAM_A8_INDOOR_BRANCH gate:
- Unset or != "1" (DEFAULT): cameraInsideBuilding forced false. Outdoor
Draw(All) path runs for indoor cells too. Pre-A8 depth-clear-if-inside
workaround at line ~7314 is restored. Visual behavior = pre-A8.
- Set to "1": indoor branch (RenderInsideOutAcdream) runs. All A8 code
exercises. Probes ([envcells]/[stencil]/[draworder]/[buildings]) emit.
All Phase A8 scaffolding (Waves 1-5 + post-Wave-5 fix commits) remains
in tree, accessible for the next-session apparatus to test against.
~1,830 LOC of WB-extracted infrastructure preserved.
Handoff doc at docs/research/2026-05-28-a8-wb-port-shipped-but-broken-handoff.md
captures the full chronicle: which fixes were applied, what each
visual-gate launch reported, the root-cause hypotheses tested and
falsified, the remaining unknowns, and the recommended apparatus
approaches (frame-replay harness / per-step GL state probe / WB-renderer
side-by-side / mesh-data audit).
Next session's mission: NO MORE LIVE LAUNCHES until apparatus is built.
This is the same trap the issue #98 saga fell into before the
trajectory-replay harness shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report after Step 5 disable + ColorMask cleanup + cull-cache fix +
terrain-skip fix: "Still chaos, GPU 100%. House missing lots of walls."
Root cause finally found: Pre-A8 indoor walls came from IsBuildingShell
entities (landblock-baked GfxObj slabs that represent cottage exterior
walls — NOT part of any cell's CellStruct). They were drawn via
Dispatcher.Draw(set: IndoorPass) in the pre-A8 outdoor path.
WB's algorithm assumes its StaticObjectManager.Render in Step 4 handles
these (its partition lumps shells with outdoor statics). Our EntitySet
partition (RR2) puts IsBuildingShell into IndoorPass (alongside cell
stabs), NOT OutdoorScenery — because logically shells ARE indoor walls.
A8's RenderInsideOutAcdream Step 4 calls Draw(set: OutdoorScenery)
which EXCLUDES IsBuildingShell. So cottage exterior wall slabs never
render in A8. EnvCellRenderer provides the floor + interior CellStruct
walls, but the shell slabs (exterior walls visible from inside) are
gone. Symptom: "missing walls" because half the cottage walls are
landblock-baked shells, not cell mesh.
FIX: insert a Draw(set: IndoorPass) call between Step 3 (cells) and
Step 4 (stencil-gated outdoor) when cameraInsideBuilding. Uses
currentEnvCellIds as the cell filter — narrows cell stabs to camera-
building cells; building shells (no ParentCellId) pass through and
all render. Depth-tested (DepthFunc.Less) so cottage-A's near walls
occlude cottage-B's far walls; no stencil so shells render
unconditionally inside the camera building.
Build green.
This was the root cause behind the 4 days of RR7 failures. The
handoff doc even noted "Building shells render (they ARE GfxObj
entities with proper mesh refs after hydration)" — but the new
RenderInsideOutAcdream code DIDN'T call IndoorPass, only
OutdoorScenery. Hence shells never rendered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report after Step 5 disable + cull-cache fix: "Cant see anything,
flickering colors, sometimes textures sometimes inside, house missing
lots of walls. 10 FPS. GPU 100%."
Root cause: terrain was being drawn TWICE per indoor frame:
1. Line 7283 _terrain.Draw — UNCONDITIONAL pre-A8 pass; writes full-
screen terrain color + depth at terrain Z.
2. Step 4 inside RenderInsideOutAcdream — stencil-gated terrain at
portal silhouettes only (matching WB VisibilityManager:143).
Pre-A8 papered over the Z conflict with a depth-clear-if-inside
workaround (cleared depth between terrain and cottage) which we
DELETED as part of Wave 4. Without it, when Step 3 writes the cell
geometry with DepthFunc.Less, the cottage walls at Z=92-94 (where
they meet the ground) Z-FIGHT against the terrain already in the
depth buffer from pass 1. Symptom: flickering walls + missing
sections + GPU saturated drawing terrain twice.
Fix: gate the line-7200 terrain draw on `!cameraInsideBuilding`.
When indoor, Step 4 is the SOLE terrain pass — stencil-gated to
portal silhouettes only. No double-draw, no Z-fighting, no need
for the deleted depth-clear workaround.
Outdoor mode unchanged (pass 1 still fires, Step 4 isn't taken).
Build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
First user-driven visual gate (1,595 indoor frames, 109 other-buildings)
reported textures flickering / can barely move / client crashed
indoors. Root causes:
1. Step 5 (cross-building visibility) iterates EVERY loaded other-
building per frame with NO frustum culling. At Holtburg that's 109
other-buildings × 5 GL draws each = ~545 extra draws/frame on top
of the 4 setup steps. Each Render() within Step 5c also re-uploads
the instance SSBO via glBufferData(orphan) + glBufferSubData and
a glMemoryBarrier. Combined with rapid 109-iteration back-to-back
state churn, the driver hits TDR and crashes.
GATE: Step 5 is now OFF BY DEFAULT. Set ACDREAM_A8_STEP5=1 to opt
in once we add per-building frustum culling on otherBuildings.
Cross-building visibility is a polish feature; M1.5 indoor walking
doesn't require it.
2. WB's RenderInsideOut cleanup at line 234-238 exits with
ColorMask(t,t,t,FALSE) — alpha-bit OFF — matching WB's editor
pipeline expectations. acdream's subsequent rendering (particles,
anything writing alpha) needs alpha-bit ON. The flicker symptom
is consistent with subsequent passes mis-writing alpha.
FIX: cleanup now restores ColorMask(t,t,t,t), DepthMask(true),
DepthFunc.Less, Enable(CullFace) — all to acdream defaults so the
outer render frame sees a clean slate. Step 5's loop also leaves
DepthMask/CullFace in non-default states; defensive restore makes
this safe whether Step 5 ran or not.
Build green. Tests unchanged.
Expectation for next relaunch: indoor frames hit only Steps 1+2+3+4
(camera-building stencil + cell render + stencil-gated outdoor scenery).
Cross-building visibility (Step 5) is intentionally inert. Flicker
should be resolved by the ColorMask alpha restore. Perf should be
closer to pre-A8 outdoor (one extra full-screen pass + a small
stencil mask).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second visual-gate probe data: [envcells]/[buildings] firing 3711 times
each (indoor branch FIRED), but [stencil]=0 and [draworder]=2x (only
Steps 3+4, no Steps 1+2+5). [buildings] sample:
camCell=0xA9B40143 camBldgs=[] otherBldgs=109 totalKnown=110
The registry HAS 110 buildings loaded but lookup returns empty. Root
cause: storage key mismatch. lb.LandblockId encodes 0xXXYY_FFFF (low 16
bits = 0xFFFF for the landblock's own LandBlockInfo dat id), while the
runtime lookup at the gate derives 0xXXYY_0000 via cellId & 0xFFFF0000u.
Same bug RR7.2 (`efe3520`, reverted by `9aaae02`) tried to fix — landed
here properly:
- Storage key now `lb.LandblockId & 0xFFFF0000u` (was lb.LandblockId).
- Both RemoveLandblock callbacks use `id & 0xFFFF0000u` to match.
Build green.
After this fix, [buildings] should show camBldgs=[0x1] (or similar)
when the player is inside a cottage, [envcells] cells/tris should be
non-zero, and the [stencil] / [draworder] step 1 + 2 + 5 should fire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First visual-gate launch showed 8,737 [vis] lines (player at Holtburg
cottage cell 0xA9B40143, inside=True really=True) but ZERO [buildings] /
[envcells] / [stencil] / [draworder] probe emissions. Root cause: same as
the original RR7.1 saga — BuildingLoader.Build was passed only the
per-frame drainedCells dict, missing cells loaded on PRIOR frames. Those
cells stayed with BuildingId=null, the strict cameraInsideBuilding gate
returned false, the indoor branch never fired.
Fix: in ApplyLoadedTerrainLocked, merge drainedCells with the cells
already registered in _cellVisibility for the same landblock prefix
before passing to BuildingLoader. The richer dict ensures the stamping
loop in BuildingLoader.Build covers EVERY cell in this landblock.
Added IReadOnlyList<LoadedCell> GetCellsForLandblock(uint lbId) on
CellVisibility — minimal API expose; existing _cellsByLandblock dict
was already the right shape (lbId = upper 16 bits).
Build green. Tests unchanged.
Next: relaunch the client. With the fix, [buildings] probe should fire
with camBldgs=[0x1,...] when the player is inside a Holtburg cottage,
[envcells] should report cells>=1 tris>=1 per indoor frame, and the
indoor branch should be exercising the WB-faithful Steps 1-5 pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Probe emitters wired (replaces the Task 8 stubs). All gated on
ACDREAM_PROBE_VIS=1 (everything) or ACDREAM_PROBE_ENVCELL=1
([envcells] only):
- [envcells] frame=N cells=N tris=N ourBldgs=N otherBldgs=N filterCnt=N
Fires once per Render call inside RenderInsideOutAcdream Step 3.
Reads CellsRendered + TrianglesDrawn from EnvCellRenderer.Stats.
- [stencil] op={mark|punch} bld=0xHHHHHHHH verts=N
Fires after every IndoorCellStencilPipeline.RenderBuildingStencilMask
call (Steps 1, 2, 5a, 5b, 5d) — surfaces LastStencil* probe fields
added in Wave 1's Task 7 extension.
- [draworder] frame=N step=Xy stencil={on|off} depthFn=0xHHH depthMask={true|false}
Fires at each step boundary (entry to Step 1/2/3/4/5{a,b,c,d}).
Reads live GL state via glGetInteger so divergence between assumed
vs actual state is immediately visible.
- [buildings] camCell=0xHHHHHHHH camBldgs=[0x1,0x2,...] otherBldgs=N totalKnown=N
Fires once per indoor frame at the top of RenderInsideOutAcdream.
totalKnown sums BuildingRegistry.Count across all loaded landblocks.
Per-frame counter _phaseA8DrawOrderFrame incremented once per render
tick after the existing [vis] probe block (line 7104).
New env-var flag ACDREAM_PROBE_ENVCELL in RenderingDiagnostics +
ProbeEnvCellEnabled property (true OR ProbeVisibilityEnabled).
Mandatory acceptance criteria (process rule "no visual-gate launch
without probe data first") to check FROM the log BEFORE asking the
user for visual verification:
- [buildings] camBldgs=[0x...] non-empty when inside a cottage
- [envcells] cells>=1 tris>=1 filterCnt>=1 for at least one indoor frame
- [stencil] op=mark verts>0 fires per camera-building
- [draworder] shows the full Step 1 → 2 → 3 → 4 → 5{a,b,c,d} cycle
Build green. 82/82 App.Tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>