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>
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>
RR7.1 fixed cell-timing but the indoor branch STILL fired 0 times in
the v2 visual gate (125,476 inside=True frames, all routed outdoor).
Real root cause: a key-form mismatch between storage and lookup.
Storage at line ~5886 used `_buildingRegistries[lb.LandblockId]`. But
lb.LandblockId is the LandBlock dat-file id (e.g. 0xA9B4FFFF — the
0xFFFF low word identifies the file as terrain). Lookups at the gate
(line ~7090) and the drain late-stamp (line ~5708) used
`cell.CellId & 0xFFFF0000u` (e.g. 0xA9B40000). 0xA9B4FFFF ≠ 0xA9B40000
so TryGetValue always missed; camBuildings stayed empty; the gate
fell to the outdoor branch unconditionally.
Fix: normalize all four sites to the masked form
(`& 0xFFFF0000u`) — storage at the build call, both Remove callbacks
in the streaming-controller setup, and the lookups (already correct).
User-visible symptom that surfaced the v2 launch:
- sky + ground missing through windows
- buildings + objects still visible
This pattern (stencil-gated outdoor passes failing while ungated
indoor pass works) was actually the OUTDOOR branch running with the
indoor visibility set — `visibleCellIds` filtered out terrain cells
and the sky pre-scene was gated off too because cameraInsideBuilding
was True (correctly) but camBuildings was empty (incorrectly).
Wait — re-reading the indoor branch's gate: it requires
camBuildings.Count > 0 too, so with the key mismatch it took the
outdoor branch. The sky+terrain visibility pattern user reported is
the outdoor branch where sky-pre-scene was correctly gated off by
!cameraInsideBuilding (cameraInsideBuilding is what computes the
ROUTING; it doesn't have to match the actual branch taken when the
extra `camBuildings.Count > 0` filter trips). So initial-sky was
skipped (cameraInsideBuilding=true) but indoor branch didn't fire
either — outdoor branch with no initial sky = the dark window
visual. RR7.2 closes both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RR7 visual gate (2026-05-27) revealed the indoor branch NEVER fired even
when the strict gate's PointInCell + non-null CameraCell hit: 17,748
inside=True frames, 0 branch=indoor decisions. Root cause: RR4 wired
BuildingLoader.Build with the per-frame drainedCells dict — cells that
streamed in on earlier frames (the common case, since cells arrive
asynchronously over many frames after the landblock-info completion)
were not in drainedCells, so the BFS short-circuited and the registry's
EnvCellIds set was systematically incomplete. Cells loaded ahead of
lbInfo arrival never got their BuildingId stamped.
Fix has two parts:
1. CellVisibility.AllLoadedCells — new public IReadOnlyDictionary
exposing the existing private _cellLookup. BuildingLoader.Build at
landblock-info-arrival now walks the full cell set, not just this
frame's drain.
2. _pendingCells drain loop — late-stamps BuildingId on each arriving
cell if its landblock's BuildingRegistry already exists. Covers cells
that arrive AFTER the registry-build pass.
Together these handle all four timing cases:
- Cells loaded before lbInfo arrives → stamped in BuildingLoader.Build
- Cells loaded with lbInfo (same frame) → stamped in BuildingLoader.Build
- Cells loaded after lbInfo arrives → stamped in drain loop
- lbInfo never arrives (LB has no info) → registry never built, cells
stay at BuildingId == null
(intended — flow through outdoor
render path)
Probe data from the failed gate launch confirmed cell 0xA9B40150
(cottage idx=6 cellar from the #98 saga) was reached as the camera cell
with visN=16 visible neighbours, but BuildingId stayed null. This fix
gets the indoor branch fired in that scenario.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the post-revert pre-A8 render frame with WB's RenderInsideOut
Steps 1-4 (Step 5 = RR9, RenderOutsideIn = RR11):
Indoor (cameraInsideBuilding == true):
1+2. MarkAndPunch on camera-buildings' exit portals
3. IndoorPass — cell scope = camBuildings.SelectMany(EnvCellIds)
(no BFS-wide cell render → fixes Issues A + C)
4a. Stencil-gated sky (DepthMask off; acdream enhancement)
4b. Stencil-gated terrain re-draw
4c. Stencil-gated OutdoorScenery
5. (RR9 — placeholder)
6. DisableStencil
7. LiveDynamic
Outdoor (cameraInsideBuilding == false):
Single Draw(All) — unchanged pre-A8 shape. (RR11 adds RenderOutsideIn.)
New cameraInsideBuilding gate is STRICT (PointInCell + BuildingId not
null). No grace mechanism for the render path; the cell-side grace in
CellVisibility.FindCameraCell stays alive for non-render consumers.
Frame-start glClear now includes StencilBufferBit (was Color+Depth only)
— necessary now that stencil is consumed each indoor frame.
Sky pre-scene + initial terrain + weather post-scene gates all switched
to !cameraInsideBuilding from !cameraInsideCell. The legacy
cameraInsideCell stays only for the [vis] probe's side-by-side logging
and UpdateSkyPes path.
IndoorCellStencilPipeline constructed in OnLoad (portal_stencil.vert/frag,
shader-compile exception caught + logged; indoor branch falls back to
outdoor on null). Added to Dispose chain.
camBuildings looked up via _buildingRegistries dict (NOT
LandblockEntry.BuildingRegistry — per Code Structure Rule #2, the registry
lives on GameWindow keyed by landblock id).
Visual verification at RR8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the dormant single-bit stencil pipeline with WB Step 5 primitives:
MarkBuildingBit2 — mark stencil bit 2 where bit 1 set
PunchDepthAtStencil3 — depth=1.0 at intersection (stencil==3)
EnableOtherBuildingPass — render state for stencil==3 EnvCell pass
ResetBit2 — clear bit 2 between iterations
UploadBuildingPortalMesh — upload a Building.ExitPortalPolygons (vs
cell-based UploadPortalMesh)
Plus occlusion-query helpers:
EnsureOcclusionQueryId — lazy GenQuery
TryReadOcclusionResult — asynchronous read-back (no CPU stall)
BeginOcclusionQuery — BeginQuery wrapper
EndOcclusionQuery — EndQuery wrapper
All GL state sequences mirror WB VisibilityManager.cs:73-239 line-by-line.
Comments reference the corresponding WB line numbers for verification.
Consumed by RR7's Steps 1-4 + RR9's Step 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
Wires the dormant RenderingDiagnostics.ProbeVisibilityEnabled flag (added
2026-05-25 by Task 6 of the original A8 plan, no probe code) to per-frame
[vis] log lines around the render-frame branch decision. Captures camera
position, cameraInsideCell (lenient grace-aware), the strict PointInCell
result, the visibility CameraCell id, and VisibleCellIds count/list.
Enable via ACDREAM_PROBE_VIS=1.
Used during A8 RR0 falsification spike (2026-05-26) — see
docs/research/2026-05-26-a8-rr0-falsification-findings.md. Kept as long-
term diagnostic for the upcoming RR8/RR10 visual verification gates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R3.5 v1 only gated the stencil branch on `cameraReallyInside`; the
depth-clear-if-inside at line ~7129 stayed on `cameraInsideCell`. During
grace frames after exit:
cameraInsideCell = true (grace, holds previous cell for 3 frames)
cameraReallyInside = false (PointInCell on camera pos returns false)
So depth-clear FIRED (writing depth = 1.0 globally) but the OUTDOOR branch
ran (single Draw(All) on every entity). With depth cleared, terrain's
depth = 1.0 — every entity below terrain (cellar geometry, basement
GfxObjs, anything at world Z < terrain Z) won the depth test and rendered
THROUGH the ground. User reported: "stand outside or pass outside → flicker
where objects are visible through ground and walls of other buildings are
missing."
v2 fix: unify depth-related gates on `cameraReallyInside`. During grace
frames depth-clear is now ALSO skipped; terrain depth survives; the
outdoor pass renders normally with proper terrain occlusion. Sky /
lighting / particles continue to use `cameraInsideCell` for smooth
grace-aware transitions.
The two-flag split is now explicit:
cameraInsideCell → sky, lighting (smooth, grace-aware)
cameraReallyInside → depth-clear, stencil branch (strict, no grace)
Closes the persistent transition flicker observed in R4 visual
verification after v1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the transition-flicker symptom observed during R4 visual verification:
brief 1-3 frames after exiting a building where outdoor scenery rendered
with wrong stencil mask, "walls disappear and buildings show under ground"
shimmer, and sky stayed suppressed.
Root cause: CellVisibility.FindCameraCell holds the previous CameraCell
for ~3 grace frames after the camera physically exits the cell volume
(see _cellSwitchGraceFrames). The grace mechanism prevents flicker at
the doorway threshold for sky/lighting/depth-clear, but the new R3
stencil branch was using `cameraInsideCell` directly — so during grace
frames it ran MarkAndPunch with the previous cell's portals (now behind/
beside the camera) and the IndoorPass + stencil-gated outdoor produced
the garbage frame.
Fix: compute `cameraReallyInside` via the stricter
CellVisibility.PointInCell containment check and use it (instead of
`cameraInsideCell`) as the gate for the stencil branch. Sky, depth-clear,
lighting, and particles continue to use `cameraInsideCell` so their
smooth grace-aware behavior is unchanged.
Handoff item #10 (docs/research/2026-05-26-a8-revert-handoff.md) flagged
this exact concern: "Likely the CellSwitchGraceFrameCount = 3 interacting
with stencil setup timing." Confirmed and closed.
Visual-verification of the fix is part of R4 (re-run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the pre-A8 single dispatcher call with the WB RenderInsideOut
order when cameraInsideCell:
1. Terrain draws normally (color + depth)
2. depth-clear-if-inside (depth = 1.0 globally)
3. MarkAndPunch — stencil bit 1 at camera's-own-cell exit portals
4. IndoorPass — cell mesh + cell statics + building shells, stencil OFF
5. EnableOutdoorPass + re-draw terrain + OutdoorScenery, stencil-gated
6. DisableStencil + LiveDynamic, depth-test only
Outdoor (cameraInsideCell == false) path unchanged: single Draw(set: All).
Step 5 (WB's 3-stencil-bit cross-cell-portal pipeline) is DEFERRED — we
mark only the camera's own cell's exit portals via [visibility.CameraCell],
not the BFS-extended VisibleCellIds. Trade-off documented in
docs/research/2026-05-26-a8-entity-taxonomy.md §"open questions".
Adds IndoorCellStencilPipeline field + ctor wiring + Dispose. Field types
the partition consumers from R2; the ParentCellId / IsBuildingShell /
ServerGuid distinctions are now consumed at runtime.
Visual verification at cottage interior / cottage cellar / inn interior /
dungeon is R4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Adds a bool flag at the WorldEntity data layer set by LandblockLoader from
the source dat array: LandBlockInfo.Buildings → true (cottage walls, inn
walls, smithy walls); LandBlockInfo.Objects → false (trees, lampposts,
rocks, hitching posts).
Retail anchor: CLandBlock::init_buildings reads a separate BuildInfo**
array from objects (acclient.h:31893 num_buildings / buildings field;
acclient_2013_pseudo_c.txt:313854 init_buildings entry). WorldBuilder
preserves the same distinction via SceneryInstance.IsBuilding
(StaticObjectRenderManager.cs:334). Today acdream's loader reads both
arrays into the same WorldEntity pool with no tag, destroying the
distinction (the comment at GameWindow.cs:5175 already acknowledges this
gap for scenery suppression). This commit closes the gap.
Render-time consumption arrives in R2 (EntitySet partition refactor).
Two new LandblockLoader tests lock the tagging behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second visual verification surfaced three depth-ordering bugs all from
one cause: the IndoorOnly dispatcher Draw ran BEFORE MarkAndPunch, so
the far-depth punch (gl_FragDepth = 1.0 at stencil=1 portal silhouettes)
overwrote any indoor depth that had been written there. Result:
• Closed doors leaked outside terrain — door mesh wrote depth 0.6 at
the portal silhouette, then the punch overwrote it to 1.0, then
terrain at 0.99 won the depth test.
• Walls between rooms leaked the far-side door/window opening —
same mechanism: wall depth at the far-portal silhouette destroyed
by the punch.
• Animated character body bled to terrain where it overlapped a
portal silhouette on screen — same mechanism: character depth
destroyed by the punch.
Re-reading WB's RenderInsideOut (VisibilityManager.cs:73-239) confirms
the correct order is mark-and-punch FIRST, then indoor cells. Indoor
geometry drawn AFTER the punch wins the depth test against 1.0 and
correctly occludes the subsequent stencil-gated outdoor pass.
The swap is a single block move; MarkAndPunch was already correctly
leaving the GL state stencil-disabled for the indoor pass to follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Three small improvements from Task 5 code review:
- MarkAndPunch now enables DepthTest explicitly (was relying on
GameWindow's startup enable; this makes the method self-contained).
- Uniform location fields marked readonly (set once in ctor).
- AllocateVbo gets a comment noting that mid-session reallocation is
safe because the VAO bakes the VBO association at ConfigureVao time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pipeline class owns the portal_stencil shader + a dynamic VBO/VAO
for per-frame portal triangle uploads. MarkAndPunch runs WB's two-step
stencil setup (mark portals = 1, then write gl_FragDepth=1.0 into
stencil=1 regions). EnableOutdoorPass switches to read-only stencil
for the subsequent terrain + outdoor-entity passes.
PortalMeshBuilder.BuildTriangles is the pure-math triangle-fan
extractor — unit-testable without a GL context. Only exit portals
(OtherCellId == 0xFFFF) are emitted; inner portals are skipped to
prevent outdoor geometry from bleeding into adjacent rooms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WB's PortalStencil.vert has a pos.w clamp for the camera-coplanar-with-
portal degenerate. We exclude it per spec (matches retail intent), but
the file should note the omission so future readers don't wonder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the pos.w clamp in portal_stencil.vert and the FragColor
declaration in portal_stencil.frag added in 2d31d49. Both were
speculative defensive code not in the spec or the WB reference. The
shaders now match the spec verbatim (except the locally-conventional
`core` profile qualifier which is correct).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal pair for the indoor-cell stencil pipeline (#78). Vert transforms
world-space portal polygon vertices through uViewProjection; includes a
near-zero pos.w guard for coplanar-camera robustness (matches WB pattern).
Frag either passes through gl_FragCoord.z or writes gl_FragDepth=1.0
based on uWriteFarDepth; FragColor declared but suppressed via ColorMask
on the CPU side.
Matches WorldBuilder's PortalStencil.vert/.frag at
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/.
Uses #version 430 core consistent with acdream's mesh_modern shaders.
Deployed to bin/ via existing Rendering\Shaders\*.* .csproj glob.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the ACDREAM_PROBE_VIS=1 env-var-toggleable flag for the indoor-cell
visibility culling pipeline (#78). Mirrors the existing ProbeIndoor*
pattern. DebugVM checkbox follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BuildLoadedCell now reads the full portal polygon vertices from
cellStruct.Polygons[portal.PolygonId].VertexIds and stores them in
local-space on the LoadedCell. Empty arrays for unresolved polygons.
Same source as the ClipPlane block; no new dat read.
Unit test covers the data-class invariant (parallel indexing) since
the full integration is exercised only at runtime with live dat data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First slice of the indoor-cell visibility culling pipeline (#78). Adds
PortalPolygons: List<Vector3[]> to LoadedCell, parallel-indexed to the
existing Portals + ClipPlanes lists. Empty arrays for portals whose
polygon could not be resolved. Field is populated in Task 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retired in favour of Task 1's retail-faithful terrain shader Z nudge.
Pure removal — ~50 LOC of dead surface area across:
- src/AcDream.Core/Terrain/LandblockMesh.cs (drop parameter +
cell-collapse block)
- src/AcDream.Core/World/LoadedLandblock.cs (drop field)
- src/AcDream.Core/World/LandblockLoader.cs (drop method + call)
- src/AcDream.App/Rendering/GameWindow.cs (3 sites)
- src/AcDream.App/Streaming/GpuWorldState.cs (6 ctor sites)
- src/AcDream.App/Streaming/LandblockStreamer.cs (1 ctor site)
- tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs (drop test)
- tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs (drop test)
No retail anchor — the deleted mechanism never had one; this commit
rolls our code back to the actual retail behaviour established in
the prior commit's shader nudge.
ISSUES.md #100 moved to Recently closed.
Cross-ref:
docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subtract 0.01 from every terrain vertex Z in the modern terrain vertex
shader, matching retail's per-draw nudge applied inside
ACRender::landPolysDraw(arg2=2). Coplanar building floors now always win
the depth test against the rendered terrain, so the visual "ground at
the building floor" reads as the building's floor, not as Z-fighting.
Constant 0.01f bit-equals retail's float literal 0.00999999978 when
rounded to single precision.
Render-only — physics reads the un-nudged heightmap via
TerrainSurface.SampleZ / SampleZFromHeightmap. The same render-vs-
physics split is already established for EnvCell render lift
(+0.02m at GameWindow.cs around the cell-mesh draw).
Retail anchors:
docs/research/named-retail/acclient_2013_pseudo_c.txt:1120769
docs/research/named-retail/acclient_2013_pseudo_c.txt:702254
Cross-ref:
docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md
Followed by Task 2 (delete the hiddenTerrainCells / BuildingTerrainCells
plumbing). Visible result of this commit alone: building floors stop
Z-fighting, but the 24m x 24m transparent rectangles persist until the
plumbing is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 10 stair-step cyls (entities 0x40B5008A..0x40B50095 in Holtburg
cells 0xA9B40159/A) are synthesized by the mesh-aabb-fallback path
from the visual mesh AABB of GfxObj 0x0100081A — which has
HasPhysics=False and no PhysicsBSP. Retail's CPartArray::InitParts
emits no collision in this case; acdream now matches that by
consulting PhysicsDataCache.IsPhantomGfxObjSource (added in the
previous commit) and skipping synthesis when the predicate fires.
The actual staircase collision is on entity 0x40B50089 (GfxObj
0x01000C16, hasPhys=True, BSP radius 2.645m) — same staircase BSP
that retail uses. After this fix, only that BSP fires; the
phantoms are gone.
Visual verification pending (next step in plan); the BSP dump
from ACDREAM_DUMP_GFXOBJS=0x01000C16 will confirm whether
0x01000C16 has walkable inclined polys for the climb to actually
land. If not, a follow-up issue is needed; the cyl phantom is
closed either way.
Also updates PhysicsDataCache.cs XML doc line reference from
6116 to 6127 (drifted by the 11-line isPhantomGfxObj block
inserted above the guarded if).
Refs docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's CPartArray::InitParts emits collision shapes only from
Setup-level CylSpheres/Spheres or per-Part PhysicsBSP — never
from visual mesh AABBs. The predicate captures the retail rule:
a stab whose source is a GfxObj (high byte 0x01) with no cached
GfxObjPhysics is phantom (no collision). Wired into GameWindow's
mesh-aabb-fallback synthesis in the next commit.
Refs docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the door-cyl phantom slide where a sphere approaching a closed
cottage door at NE/SE headings could be blocked by the cyl's radial
normal contaminating the slide tangent into the slab face (live
evidence in door-a6p6-v2.utf8.log: 12 resolves with
cn=(0.86,0.51,0) attributed to door entity 0x000F4245).
Retail anchor: CPhysicsObj::FindObjCollisions at
acclient_2013_pseudo_c.txt:276861 dispatches BINARILY between
BSP-only and cyl+sphere based on HAS_PHYSICS_BSP_PS (0x10000 in
acclient.h:2833). For non-PvP, non-missile movers — every M1.5
scope walking-vs-static scenario — an entity with the flag set
tests its BSP exclusively; the foot cyl is never tested. ACE
confirms the truth table at PhysicsObj.cs:412-450 (HasPhysicsBSP,
missileIgnore, exemption).
Our dispatcher iterated every ShadowEntry independently and tested
both the cyl AND the BSP for a closed door. Cyl was registered
first (FromSetup walk order), and its diagonal radial slide normal
"won" attribution at the early-return on first non-OK. Result was
out=in for tangential motion along the door face.
Changes (~15 LOC + 7 unit tests):
- PhysicsStateFlags.HasPhysicsBsp = 0x00010000 (PhysicsBody.cs)
- Transition.BspOnlyDispatch(uint state) static predicate
(TransitionTypes.cs) — mirrors retail's branch with M1.5 scope
defaults (ebp_1 and eax_12 treated as false; wire PvP / missile
refinements when those scopes ship)
- Per-entry guard in FindObjCollisions cyl/sphere branch
(TransitionTypes.cs:2433) — continue when BspOnlyDispatch fires,
with [cyl-skip-bsp] diagnostic line gated on ProbeBuildingEnabled
- A6P7DispatchRulesTests (7 tests, all GREEN): flag value + 6
parameterized predicate cases
Verification: 14-test keep-green list from the 2026-05-25 handoff
passes (5 BSPQueryTests.FindCollisions_Path5_*, 2 CellTransitTests.A6P5_*,
2 DoorCollisionApparatusTests.Apparatus_DeadCenter_*,
5 DoorBugTrajectoryReplayTests, 1
CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap).
Total: 20/20 pass including the new 7-test predicate suite.
The DocumentsBug test (Apparatus_Grounded_50cmOffCenter) fails
post-fix BUT was already failing pre-fix in the worktree baseline
(verified by stashing the fix and re-running — same failure mode:
sphere blocks at start with floor normal (0,0,1)). Not in the
keep-green list, so this is a known pre-existing condition; the
test's own header comment instructs flipping the assertion when
the fix lands.
Investigation:
docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md
Needs visual verification at Holtburg cottage door (NE/SE approach
should now slide smoothly along the door face — zero [cyl-test]
log lines attributed to door entity, replaced by [cyl-skip-bsp]).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's CCylSphere::intersects_sphere at acclient_2013_pseudo_c.txt
:324626-324641 routes the Contact-state branch through step_sphere_up
(line 324516), not slide. The step-up check at line 324519-324524:
cyl_clearance = sphere.radius + cyl.height - offset.z
if (step_up_height < cyl_clearance) → slide (cyl too tall)
else → DoStepUp, on failure → step_up_slide
For the cottage door's foot cyl (h=0.20m, r=0.10m) at standing height,
cyl_clearance = 0.30m and player step_up_height = 0.60m, so the sphere
steps over the cyl easily — no radial push-out.
Pre-fix bug (live trace door-phantom.utf8.log 2026-05-25 PM):
when the player slid along the closed cottage door's slab face, the
foot cyl fired Slid with radial outward push at the door's middle X
(cn=(0.64,0.77,0) etc.) — a "phantom collision" that broke the slide.
Cause: A6.P5's cellSet expansion made the door reliably visible from
all approach angles, exposing this pre-existing behavior. Pre-A6.P5
the cyl wasn't visible from many approach angles so the phantom rarely
fired; the underlying mismatch with retail was always there.
Fix: in CylinderCollision, when oi.Contact && !sp.StepUp && !sp.StepDown
and engine is non-null, compute cyl_clearance, and if step_up_height
allows it, call DoStepUp with the cyl's radial collision normal. On
success the sphere is repositioned past the cyl (returns OK). On
failure (no walkable surface beyond — e.g., a wall behind the cyl),
fall back to StepUpSlide which uses SlideSphereInternal's crease
projection — smoother tangent slide than the radial push.
Conformance:
- All A6P5 unit tests + Path 5 tests + Apparatus_50cmOffCenter_* +
Apparatus_DeadCenter_* + Directional_OutsideIn/InsideOut + issue #98
LiveCompare_FirstCap_FixClosesCottageFloorCap pass in isolation.
- Full Core suite failure count unchanged (17 baseline → 17 with-fix);
diff is documented static-leak flakiness, no real regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>