Commit graph

590 commits

Author SHA1 Message Date
Erik
f9a644a366 feat(render): Phase A8 Wave 4 — RenderInsideOutAcdream byte-for-byte WB port
Six surgical edits in GameWindow.cs (+275 LOC):

1. _indoorStencilPipeline field + ctor init (line 172 + 1788). Uses
   the portal_stencil.{vert,frag} shaders. Disposed at line 10595.

2. Strict cameraInsideBuilding gate (line 7079-7097): visibility.CameraCell
   PointInCell + BuildingId != null. camBuildings + otherBuildings lists
   populated from _buildingRegistries.GetBuildingsContainingCell / .All().

3. envCellViewProj compute + _envCellFrustum.Update + _envCellRenderer
   .PrepareRenderBatches (line 7192) — once per frame, before sky.

4. Frame clear now includes StencilBufferBit (line 6947) so stencil starts
   at 0 each frame. RR7 missed this.

5. Old "depth clear when inside" workaround (was lines 7210-7215) DELETED.
   Replaced with one-line marker pointing at RenderInsideOutAcdream.

6. Indoor-vs-outdoor branch (line 7284-7298): on cameraInsideBuilding,
   call RenderInsideOutAcdream. Otherwise, existing Dispatcher.Draw(set: All).
   The outdoor path retains pre-A8 behavior exactly.

7. RenderInsideOutAcdream method (line 10587-10761): byte-for-byte port of
   WB VisibilityManager.RenderInsideOut at
   references/WorldBuilder/.../VisibilityManager.cs:73-239. Substitutions:
     portalManager.RenderBuildingStencilMask -> _indoorStencilPipeline.RenderBuildingStencilMask
     envCellManager.Render(pass, filter)     -> _envCellRenderer.Render(pass, filter)
     terrainManager.Render(...)              -> _terrain?.Draw(camera, frustum, neverCullLb)
     sceneryManager + staticObjectManager    -> _wbDrawDispatcher.Draw(set: OutdoorScenery)
     sceneryShader.Bind()                    -> _meshShader.Use()
   Step 1 + 2 (camera-building portals stencil mark + far-depth punch).
   Step 3 (cells of camera-buildings, opaque + transparent).
   Step 4 (stencil-gated terrain + scenery).
   Step 5 (cross-building visibility via 3-bit stencil + occlusion query).

8. Four EmitXxxProbe stub methods (Task 9 fills them with real output).

LiveDynamic (player + NPCs + dropped items) is NOT YET drawn separately;
Task 9 follow-up may add the LiveDynamic dispatch call after stencil
disable. Pre-A8 behavior had no separate LiveDynamic pass either —
dynamic entities flow through Dispatcher.Draw(All) on the outdoor path.

Subagent deviation from spec: `camera` parameter typed as
AcDream.App.Rendering.ICamera (the actual type GameWindow uses) rather
than AcDream.Core.Rendering.Camera (which doesn't exist).

Build green. 82/82 App.Tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:13:16 +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
4fa3390592 Revert "feat(render): Phase A8 RR7 — WB RenderInsideOut Steps 1-4 + outdoor branch"
This reverts commit 3d28d701a2.
2026-05-27 14:07:13 +02:00
Erik
21dc72b010 Revert "fix(render): Phase A8 RR7.1 — stamp BuildingId on cells loaded across multiple frames"
This reverts commit a1a3e0ee3e.
2026-05-27 14:07:13 +02:00
Erik
9aaae02610 Revert "fix(render): Phase A8 RR7.2 — _buildingRegistries key mismatch"
This reverts commit efe35201fc.
2026-05-27 14:07:13 +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
efe35201fc fix(render): Phase A8 RR7.2 — _buildingRegistries key mismatch
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>
2026-05-27 12:00:28 +02:00
Erik
a1a3e0ee3e fix(render): Phase A8 RR7.1 — stamp BuildingId on cells loaded across multiple frames
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>
2026-05-27 11:45:45 +02:00
Erik
3d28d701a2 feat(render): Phase A8 RR7 — WB RenderInsideOut Steps 1-4 + outdoor branch
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>
2026-05-27 11:33:26 +02:00
Erik
6a7894ac35 feat(render): Phase A8 RR6 — IndoorCellStencilPipeline 3-bit + occlusion-query
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>
2026-05-27 11:22:15 +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
fd721afdf9 Revert "feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order)"
This reverts commit 60f07bc21b.
2026-05-27 10:08:10 +02:00
Erik
b93103885a Revert "fix(render): Phase A8 R3.5 — gate stencil branch on PointInCell containment"
This reverts commit 38d537491f.
2026-05-27 10:07:15 +02:00
Erik
664ca9cb16 Revert "fix(render): Phase A8 R3.5 v2 — gate depth-clear on cameraReallyInside too"
This reverts commit 2bfeafd358.
2026-05-27 10:07:15 +02:00
Erik
84c4a70296 diag(render): Phase A8 [vis] probe — light up dormant ProbeVisibilityEnabled
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>
2026-05-27 10:07:10 +02:00
Erik
2bfeafd358 fix(render): Phase A8 R3.5 v2 — gate depth-clear on cameraReallyInside too
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>
2026-05-26 12:40:35 +02:00
Erik
38d537491f fix(render): Phase A8 R3.5 — gate stencil branch on PointInCell containment
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>
2026-05-26 12:07:36 +02:00
Erik
60f07bc21b feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order)
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>
2026-05-26 11:46:45 +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
ed72704f7b feat(world): Phase A8 R1 — tag WorldEntity.IsBuildingShell at LandblockLoader
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>
2026-05-26 11:31:11 +02:00
Erik
fef6c619a9 Revert "feat(render): Phase A8 — wire stencil pipeline into render frame"
This reverts commit 41c2e67cd8.
2026-05-26 09:38:37 +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
c897a179fa Revert "fix(render): Phase A8 — mark-and-punch BEFORE indoor draw (correct WB order)"
This reverts commit b76f6d112e.
2026-05-26 09:38:36 +02:00
Erik
b76f6d112e fix(render): Phase A8 — mark-and-punch BEFORE indoor draw (correct WB order)
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>
2026-05-26 09:18:13 +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
41c2e67cd8 feat(render): Phase A8 — wire stencil pipeline into render frame
Replaces the pre-A8 "terrain-always + depth-clear-when-inside" pattern
with WB's stencil-aware ordering when cameraInsideCell:

  1. Upload portal triangle mesh from VisibleCellIds → LoadedCell.
  2. Draw indoor entities (EntitySet.IndoorOnly) — stencil OFF.
  3. Mark portal stencil + punch far depth (MarkAndPunch).
  4. Draw terrain — stencil-gated to portal silhouettes.
  5. Draw outdoor entities (EntitySet.OutdoorOnly) — stencil-gated.
  6. DisableStencil before particles/weather/UI.

Outdoor path unchanged (EntitySet.All, no stencil work).

Adds CellVisibility.TryGetCell(uint) for the VisibleCellIds → LoadedCell
materialization. Removes the now-redundant DepthBufferBit Clear that
was the old approximation.

Retail oracle: PView::DrawCells at
acclient_2013_pseudo_c.txt:432709 ("outside_view.view_count > 0" gate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:29: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
a1c393ee14 hardening(render): Phase A8 — IndoorCellStencilPipeline robustness
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>
2026-05-26 08:16:42 +02:00
Erik
3973596468 feat(render): Phase A8 — IndoorCellStencilPipeline + PortalMeshBuilder
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>
2026-05-26 08:02:14 +02:00
Erik
f3d7b13664 docs(render): Phase A8 — document portal_stencil.vert pos.w omission
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>
2026-05-26 07:58:26 +02:00
Erik
344034bcd3 fix(render): Phase A8 — remove over-engineered shader guards (Task 4)
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>
2026-05-26 07:55:15 +02:00
Erik
2d31d490d1 feat(render): Phase A8 — portal_stencil vert/frag shaders
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>
2026-05-26 07:52:55 +02:00
Erik
d834188a4e feat(render): Phase A8 — populate LoadedCell.PortalPolygons
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>
2026-05-26 07:43:24 +02:00
Erik
fee878f292 feat(render): Phase A8 — LoadedCell.PortalPolygons field
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>
2026-05-26 07:39:31 +02:00
Erik
a64e6f20da refactor: #100 — remove hiddenTerrainCells / BuildingTerrainCells plumbing
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>
2026-05-25 21:37:53 +02:00
Erik
f48c74aa8b fix(render): #100 — render terrain 1 cm below physical Z (retail zFightTerrainAdjust)
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>
2026-05-25 21:24:28 +02:00
Erik
5240d654df fix(physics): #101 — suppress mesh-aabb-fallback for phantom GfxObj stabs
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>
2026-05-25 19:51:45 +02:00
Erik
ca9341c2cb feat(phys): A6.P4 Task 7 — RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart
Closes the M1.5 "doors don't block in production" bug (alongside the
foundation fix at 3b7dc46). Server-spawned entities (doors, NPCs,
chests, items) now register one ShadowEntry per collision shape —
matching retail's CPhysicsObj-with-CPartArray model
(acclient_2013_pseudo_c.txt:286236) — instead of one Cylinder
approximation per entity.

Before:
  RegisterLiveEntityCollision picked ONE shape via a CylSphere → Radius
  → Sphere cascade, registered as a single Cylinder. Doors got a
  14 cm × 20 cm cylinder from setup.Radius — far too narrow to span
  the doorway gap. Players could walk through closed doors.

After:
  - ShadowShapeBuilder.FromSetup emits N shapes:
    • one Cylinder per CylSphere
    • one Cylinder per Sphere (only when no CylSpheres — retail
      convention)
    • one BSP shape per Part with a non-null PhysicsBSP
  - Caller substitutes the real BoundingSphere.Radius from
    PhysicsDataCache for BSP shapes (pure builder's 2.0 placeholder
    is tightened to the actual cached value).
  - setup.Radius fallback preserved: if the builder produces zero
    shapes but Radius > 0, register a Radius-based Cylinder so simple
    decorative props don't silently lose collision.
  - ShadowObjects.RegisterMultiPart adds N rows, all sharing
    entity.Id so the existing UpdatePhysicsState (ETHEREAL flip on
    door Use) propagates to every part without changes.

Door 0x020019FF (Holtburg cottage) now registers:
  - Cylinder r=0.10 h=0.20 (from the single Sphere)
  - BSP from Part 0 = GfxObj 0x010044B5, the 6-face 1.925 m × 0.261 m
    × 2.490 m two-sided slab confirmed by
    DoorSetupGfxObjInspectionTests
  Parts 1 + 2 (GfxObj 0x010044B6, the visual leaves) are visual-only
  in the dat by retail design and correctly skipped.

Test impact: 53/53 pass in the shape / registry / door /
cellar-replay scope. App-layer 41/41 pass.

Visual verification needed: launch the client, walk into a closed
Holtburg cottage door from outside (dead center AND ~50 cm
off-center), then walk into it from inside. Door should block all
three approaches. Use the door (click + Use) → door swings open →
walking through passes (ETHEREAL flip via existing SetState path).

Foundation fix dependency:
  3b7dc46 fix(phys): GetNearbyObjects dedup-by-entityId silently
                     drops multi-part shadows
Without 3b7dc46 in place, the BSP shape registered here would be
dropped by GetNearbyObjects's dedup. They land together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:52:36 +02:00
Erik
35b37dfb5f chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments
Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
2026-05-23 15:11:49 +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