Commit graph

1045 commits

Author SHA1 Message Date
Erik
2ec189c106 fix(render): R-A2 seam fix — flood null-BuildingId cells instead of dropping them
MergeNearbyBuildingFloods skipped cells whose BuildingId is null; the pre-R-A2 outdoor-node reverse-portal flood reached them, so dropping left holes at building/terrain seams. Key by (BuildingId ?? CellId) so unstamped/outdoor-adjacent exit-portal cells still seed a per-entrance flood; cells without an exit portal contribute nothing as before. App Rendering 207/207.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:08:14 +02:00
Erik
c62663d7cb feat(render): R-A2 — per-building floods (the flap fix)
Replace the outdoor root's single unified reverse-portal flood (whose root-level
portal-side test oscillated as the chase eye grazed a doorway — the measured
flood 2<->6) with retail's per-building floods.

- OutdoorCellNode.Build(uint): portal-less land root; floods only itself ->
  full-screen OutsideView -> terrain (PortalVisibilityBuilder IsOutdoorNode seed).
- PortalVisibilityBuilder.ConstructViewBuilding: per-building flood seeded at a
  building's own finite entrance (retail ConstructView(CBldPortal) 0x5a59a0 via
  DrawPortal 0x5a5ab0 / portal_draw_portals_only 0x53d870). Entrance-bounded ->
  consistent ~2-cell depth (measured retail cell_draw_num, handoff OPTION-A 3.4).
- RetailPViewRenderer.DrawInside: when the root is the outdoor node, group nearby
  cells by BuildingId and merge each per-building flood into the frame before
  assembly; existing shells/object-list draw path unchanged. 48 m seed cutoff.
- GameWindow: pass flat NearbyBuildingCells only on outdoor-node frames.

Tests: +3 PortalVisibilityRobustnessTests (per-building touches ~2 cells, membership
stable under the measured 36 um eye jitter). UnifiedFloodTests retired (its subject,
the unified flood from the outdoor node, is removed); surviving full-screen-OutsideView
coverage moved to OutdoorCellNodeTests. App Rendering 207/207, Core movement 14/14.

Conformance-verified sound; the grazing-doorway flap is the visual acceptance test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:44:43 +02:00
Erik
7fe98098f5 refactor(render): R-A1 — canonicalize outdoor-root detection on IsOutdoorNode
Replace ReferenceEquals(clipRoot, _outdoorNode) object-identity checks with the
documented LoadedCell.IsOutdoorNode flag (4 sites) so they survive R-A2 changing
the outdoor root's portals. Behavior-preserving (build + targeted suites green:
App PortalVisibilityBuilderTests 24/24, Core PlayerMovementControllerTests 14/14).

Right-sized from the planned 'collapse to one root': reading the live dispatch,
the viewerRoot ?? outdoorRoot split is already correct (viewerRoot feeds
cameraInsideCell/lighting via the older CellVisibility BFS; clipRoot is the render
root), and the 2026-06-07 cutover flip already made in-world frames single-path
DrawInside. The real flap fix is R-A2 (per-building floods). Dead exterior
DrawPortal look-in deletion deferred to R-A3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:25:58 +02:00
Erik
9b1857ac52 Revert "fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker"
This reverts commit cd974b29bc.
2026-06-08 15:08:25 +02:00
Erik
cd974b29bc fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker
Root cause (pinned live, flap-churn.log at the Holtburg cottage doorway): the physics
body is byte-stable at rest (rawPlayer = 1 distinct value), but
PlayerMovementController.ComputeRenderPosition's Lerp(prev, curr, alpha) dithers the
render position by microns — the two physics-tick snapshots lag the settled body
(per-frame resolve edge-settles the resting sphere against the doorframe after the last
tick wrote curr) while the leftover-accumulator alpha varies every frame. The grazing-
doorframe camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that
~1000x into a ~1.3 mm eye jitter (eye 17 distinct, RenderPosition 15 distinct) that trips
the PortalVisibilityBuilder clip -> the standing-still flicker (blue void / grass over the
cellar entrance) the user reported.

Fix: at rest (body velocity below RestVelocityEpsilonSq) render AT the authoritative
byte-stable body position instead of interpolating between two stale tick snapshots, so the
camera's pivot input is byte-stable and the sweep output stops jittering. Mirrors retail (a
resting object renders bit-stable) + the boom convergence snap
(RetailChaseCamera.ApplyConvergenceSnap, d2212cf), one layer earlier. Sub-tick interpolation
is preserved during motion (velocity above epsilon).

This SUPERSEDES the committed bounded-propagation plan: the live pin proved ZERO portal
re-enqueue churn during the flap (maxPop=1 across 13k oscillating frames; 0/63k reciprocals
ever clipped empty), so the flap was never the churn the spec hypothesized. The
ACDREAM_PROBE_PORTAL_CHURN apparatus did its job (refuted the hypothesis before the wrong
fix was built); plan/spec/memory updates to follow.

TDD: extracted the rest-snap into an internal-static pure ComputeRenderPosition; RED rest-
snap test (stale prev!=curr + varying alpha dithers) -> GREEN after the gate; motion test
guards interpolation; precondition test confirms a settled body's velocity is below the
gate threshold. 29 controller+cellar + 62 camera+portal tests green, no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:59:59 +02:00
Erik
e6fe4c611a diag(render): [portal-churn] probe — per-Build re-enqueue + reciprocal pre/post
Step 4 summary-emit adapted from the plan: the plan's Invariant($"a" + $"b" + sb) form

passes a string to FormattableString.Invariant (which requires a FormattableString) and

does not compile; merged the two interpolated fragments into one literal and appended the

already-invariant-formatted reciprocal detail outside the Invariant call. Same output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:51:48 +02:00
Erik
687040ba52 feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:49:17 +02:00
Erik
6c3a96b26e diag(render): flap re-diagnosed as portal-flood re-clip DRIFT; physics + camera REFUTED
The 2026-06-08 AM "physics rest micro-jitter" diagnosis is refuted with primary
evidence (door-recheck 216K standstill records: 0 position re-snaps; player
byte-stable during the flap). Two adversarial verification sub-agents confirmed:

- Retail roots the render at the camera viewer_cell (swept from the player via
  SmartBox::update_viewer 0x453ce0; DrawInside(viewer_cell) 0x453aa0) and toggles
  DrawInside / LScape::draw -- so acdream's eye-cell rooting + inside/outside
  toggle are RETAIL-FAITHFUL. The locked-design "root at player cell" is wrong.
- The flap is render membership instability, eye-motion-driven: the visible-cell
  set oscillates (8<->3) as the eye sweeps monotonically. Root = the
  re-enqueue-on-growth DRIFT (PortalVisibilityBuilder.cs:322, MaxReprocessPerCell
  =16) re-clipping each grown cell every round -> sub-cm eye jitter flips membership.

Fix (spec, not yet implemented): verbatim port of retail's enqueue-once flood
(ConstructView + AddViewToPortals): enqueue once on first discovery, clip each
cell's portals once, union late growth in place (AddToCell) + draw-reorder
(FixCellList), never re-enqueue. Kills the drift; rooting/camera/seal untouched.

This commit lands VERIFIED GROUNDWORK + design only:
- spec: docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md
- findings: docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md
- [pv-input] probe gains rawPlayer + yaw (disambiguates the varying input)
- 4 GREEN physics rest-stability tests (prove rest is bit-stable -> flap not physics)
- apparatus: launch-flap-capture.ps1, analyze_flap_live.py, find_burst.py
- captured fixtures: tests/.../Fixtures/flap-doorway/0xA9B4017{0..5}.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:21:46 +02:00
Erik
d6aa526dd3 diag(render/physics): flap root-caused to physics rest µm-jitter; refute prior diagnoses
Apparatus + handoff for the indoor flap. Confirmed (primary evidence): the flap is the
portal-flood clip being µm-sensitive at the threshold, driven by a ~1-8µm jitter in the
player RenderPosition (physics resting position not bit-stable; Lerp surfaces it). REFUTES
the 2026-06-07 see-through/EnvCell/outdoor-node diagnosis (ModelId GfxObj 0x01000A2B IS the
solid exterior) AND an enqueue-once attempt (retail propagates late slices via AddToCell;
the existing PropagatesNewSlicesToExit test caught it; reverted). Adds: Build determinism
test, A8CellAudit gfxobj dump, [pv-input] 6dp probe + [render-sig] outRoot/bshell fields.
No functional fix shipped. Next: higher-precision physics rest trace -> port retail
kill_velocity/contact rest-stability. Canonical: docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:16:12 +02:00
Erik
774cb22713 Revert "fix(render): outdoor look-in draws interior cells only through real doorway apertures (no see-through walls)"
This reverts commit 0030dacaaa.
2026-06-07 21:16:11 +02:00
Erik
0030dacaaa fix(render): outdoor look-in draws interior cells only through real doorway apertures (no see-through walls)
Cutover-flip follow-up: see-through buildings from outside. When the outdoor-node flood reaches a building, each interior cell is meant to draw clipped to its doorway aperture. But DrawEnvCellShells falls back to the no-clip slot 0 (full-screen) when a cell's aperture degenerates — screen-covering when you get close, or edge-on. Indoors that fallback is load-bearing (it seals the room the camera stands in; near walls hide the over-draw). From OUTSIDE it paints the building interior across the whole screen, depth-tested, so it shows wherever the solid exterior does not cover — the see-through walls, appearing 'past a threshold' exactly where the aperture degenerates.

Fix: for the outdoor-node root only, skip a flooded interior cell with no real plane-clip slot (HasRealClipSlot). From outside, 'no real aperture' means 'do not paint this interior', not 'paint it everywhere'. Interior roots keep the seal-everything slot-0 fallback unchanged. Applied to DrawEnvCellShells AND DrawCellObjectLists so a skipped cell shows neither walls nor furniture; the dead DrawPortal exterior look-in gets the same gate.

Root cause traced over the WB EnvCell render path: CellMesh.cs is physics-only; ObjectMeshManager.PrepareCellStructMeshData builds double-sided walls, so this was never a culling bug. App 216/0, build green. Visual gate pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:08:43 +02:00
Erik
88caa0dc8b fix(render): outdoor-root skips the full-screen depth clear (cellar/buildings no longer draw over the world)
Cutover-flip follow-up (issues 1+2 from the visual gate). After the flip an outdoor-node frame ran DrawInside with a full-screen OutsideView slice (Step A) whose ClearDepthSlice wiped the ENTIRE depth buffer AFTER terrain/exteriors/player drew — so the flooded building interior shells (cellars) drew over everything: the cellar painted in front of the player from outside, and distant building interiors showed through the ground in the open field.

The depth clear is a doorway look-in trick (clear a small door region so the cell seen through it draws over the terrain drawn through it). It is wrong for the full-screen base terrain of the outdoor root. Skip it there (ClearDepthSlice=null when clipRoot is the outdoor node); interiors now depth-test against terrain + exteriors and appear only through real door openings. Interior roots keep the doorway clear unchanged.

App 216/0, build green. Visual gate pending (player must be outdoors to exercise the outdoor root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:18:33 +02:00
Erik
445e861163 feat(render): Phase 3 (Step B) — single render path rooted at the viewer cell (cutover flip)
The CUTOVER FLIP that kills the indoor FLAP. Replace the two-branch gate (clipRoot = playerIndoorGate && viewerRoot ? viewerRoot : null) with clipRoot = viewerRoot ?? _outdoorNode, so EVERY frame routes through the one RetailPViewRenderer.DrawInside path rooted at the viewer cell — interior EnvCell when the eye is indoors, the synthetic outdoor node when outdoors. There is no inside/outside branch to toggle as the 3rd-person eye crosses the doorway, so the flap (textures battling at every transition) dies by construction. Matches retail SmartBox::RenderNormalMode -> DrawInside(viewer_cell) (0x453aa0 -> 0x5a5860).

clipRoot is null only pre-spawn/login (viewerCellId==0 -> _outdoorNode null), so the outdoor LScape block still runs as the safety path and login keeps its live sky. playerIndoorGate stays computed for the [render-sig] probe.

Preserve the LiveDynamic entity draw (server entities with no resolved ParentCellId — the transient unpositioned case) for the outdoor-node root: the old outdoor branch drew it; DrawInside does not, so re-issue it after DrawInside to keep the spec section 10 byte-identical-outdoor guarantee (no live entity blinks out).

The old outdoor else block + DrawPortal/BuildFromExterior are now dead when clipRoot is non-null but are LEFT IN PLACE for the user visual gate (handoff section 4 Step D deletes them only after the user confirms). App 216/0, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:06:27 +02:00
Erik
5379f6ecd3 feat(render): Phase 3 (Step A) — outdoor-root seeds full-screen OutsideView
Render unification cutover, Step A (additive, behavior-neutral until Step B). When PortalVisibilityBuilder.Build roots at the synthetic outdoor node, seed OutsideView with the full-screen NDC quad so ClipFrameAssembler yields a full-screen OutsideView slice and DrawInside's DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's shell — the same callback that already draws the doorway slice for an interior root looking out.

Keyed on a new explicit LoadedCell.IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior ids, so an id test misfired on 4 existing PortalVisibilityBuilderTests.

Nothing roots at the node until Step B, so this is behavior-neutral. Tests: App 216/0 (2 new UnifiedFloodTests incl. the spec section 10 pure-outdoor regression guard + 2 OutdoorCellNode flag assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:06:13 +02:00
Erik
d01fe30ac0 feat(render): Phase 3 (Task 2) — build the outdoor node each frame (additive, unconsumed)
Builds the synthetic outdoor cell node (OutdoorCellNode.Build) every outdoor frame
from the nearby building-entrance portals (Chebyshev <=1 landblocks), stored in
_outdoorNode. NOT yet rooted — clipRoot/viewerRoot unchanged, so behaviour is
identical this commit. [outdoor-node] probe (ACDREAM_PROBE_FLAP) reports the live
portal count so the next (cutover) step can confirm real building entrances were
found before flipping the render root. App.Tests 214/214, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:35:25 +02:00
Erik
2a2cc97d28 feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)
Purely additive: creates the synthetic outdoor cell node that will serve as the
flood-graph root for the unified render pipeline. Each nearby building's exit
portal (OtherCellId==0xFFFF) is reversed into a portal pointing back into the
building, with its polygon transformed to world space and InsideSide flipped so
the outdoor half-space is "inside" the node. WorldTransform=Identity (portals in
world space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell)
roots at (SmartBox::RenderNormalMode, decomp pc:92635). Nothing consumes this
yet — consumer wiring is Task 2.

2 new tests, 212 total passing, 0 regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:16:55 +02:00
Erik
1405dd8e90 feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS
Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:14:43 +02:00
Erik
bff1955066 feat(render): IndoorDrawPlan.ShellPass — every visible cell, no drawable filter (R1)
Pure port of retail DrawCells membership: reverse cell_draw_list, per-slice. Pins the
grey regression — a cell in OrderedVisibleCells is never dropped from the shell pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:26 +02:00
Erik
2b7f5a16c6 fix(render): branch inside/outside on is_player_outside, not the camera cell (PARTIAL)
Retail SmartBox::RenderNormalMode (0x453aa0:92665) branches DrawInside vs the
outdoor LScape::draw on is_player_outside (the PLAYER's cell, 0x451e80), then
roots DrawInside at the VIEWER cell. acdream keyed the whole branch off the
camera cell (clipRoot = visibility.CameraCell), so a 3rd-person chase camera
lagging in a doorway AFTER the player stepped outside took the DrawInside path
rooted at the threshold cell, where the exit-portal flood degenerates: grey
world + entities-through-walls. Now ShouldRenderIndoor(playerCellId,
viewerCellResolved) gates the branch on the player; the DrawInside root stays
the viewer cell (handoff invariant preserved).

SCOPE / HONESTY: this REDUCES the player-OUTSIDE doorway grey (visual-confirmed
reduced a lot) but does NOT fix the deeper symptom: when the player is in one
interior cell (cellar 0174) and the camera is in another (room 0171), the flood
roots at the camera cell and does NOT seal the player's cell, so the cellar
floor / interior walls drop to grey. That is the KNOWN R1-completion problem
(2026-06-05 Residual A handoff + 2026-06-02 design doc section 3: a
SHELL-SEALING / wrong-flood-root bug), not this branch.

Tests: Core 1331p / 4f (documented) / 1s, App 187p, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:02:09 +02:00
Erik
d2212cfaea fix(render): Part 1 — camera boom convergence snap (kills the at-rest viewer-cell flicker trigger)
Port retail CameraManager::UpdateCamera's convergence snap (0x00456fcd):
once the per-frame lerp step is below 0.0004 m AND the rotation within
0.000199999995, freeze the damped eye at an exact fixed point instead of
Vector3.Lerp's endless sub-mm asymptote. The drift was walking the 3rd-person
eye across the vestibule/room portal plane at rest, flipping the per-frame
viewer-cell resolve 0170<->0171 -> the indoor grey/texture flicker. The
collided-eye firewall (separate publishedEye local) is already present.

Adds ApplyConvergenceSnap static (TDD: 3 unit tests + 1 integration freeze
test) + SnapEpsilon/RotCloseEpsilon. App suite 183 -> 187, all green.

Plan: docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:56:04 +02:00
Erik
9f95252d20 fix(render): flood the neighbour when the eye stands in an interior portal
When the chase camera roots in a thin doorway cell and the eye stands in an interior portal opening (live capture: vestibule->room portal D=0.16m, proj=0), the 2D projection degenerates and the neighbour was culled (cells=1) -> only the thin cell drew -> bluish void / transparent ceiling. Retail's 3D clip imposes no constraint for a portal the eye is inside, so the neighbour is fully visible. When the clipped region is empty but the eye stands in the opening (EyeInsidePortalOpening: within 0.5m of the portal plane AND point-in-opening), flood the neighbour with the current view. Guarded so an off-screen degenerate portal stays culled (no #95 blowup; over-include is mesh-frustum-culled at draw). Visual-verified: cellar ceiling now solid.

Band-aid for thin-cell-root coverage; likely superseded by the boom-stability + viewer-cell dead-zone + w=0 near-plane clip fix next session (reassess / maybe revert). 2 RED->GREEN tests; cyclic/hub termination guards unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:27:39 +02:00
Erik
5f596f2d25 fix(render): clip portal projection against frustum side planes (clip-space)
ProjectToNdc clipped only the eye half-space (w>MinW, a 2026-06-03 workaround) and left the 4 frustum side planes to the 2D ScreenPolygonClip. When the eye is within a portal's near plane, small-w verts explode under the perspective divide (probe saw NDC (10.2,-67.4)); the 2D clip then collapses to empty -> OutsideView empty -> terrain Skip -> the bluish doorway void. Clip the eye plane + 4 side planes (homogeneous Sutherland-Hodgman) before the divide so NDC is bounded to the screen by construction, matching retail GetClip -> polyClipFinish (clip in clip-space before the divide; pc:432344).

Partial: NOT the full flicker fix. The dominant cause (camera boom drift + viewer-cell flip at boundaries + missing w=0 near-plane clip) is identified and deferred to the next session per the handoff. 2 RED->GREEN tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:27:24 +02:00
Erik
9e70031bc6 feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks)
Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye
now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end:

- Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT
  via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector,
  head up at floor level); outdoor keeps the player cell.
- Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags).
- Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell.
- Fallback 1 (pc:92878): AdjustPosition(sought_eye).
- Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This
  also makes cellId==0 faithful (was returning the desired eye; retail snaps to
  player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye.

Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail
find_valid_position != 0, pc:273898) so SweepEye knows when to fall back.

TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2,
SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto-
advance feet->room, so the pivot-seated start cell is genuinely decisive. Core
1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression.

Per the live-capture finding, the visible payoff is the cellar-corner (point 3);
the cottage-room bluish void stays for residual C. Spec:
docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:10:32 +02:00
Erik
5177b54bbe feat(A): port find_visible_child_cell + AdjustPosition (Render Residual A primitives)
The two Core physics primitives retail's SmartBox::update_viewer calls down into,
ported verbatim (TDD, 7 new tests):

- CellTransit.FindVisibleChildCell (CEnvCell::find_visible_child_cell, pc:311397):
  return the cell whose cell-BSP point_in_cell contains a world point — start cell
  first, then (stab-list mode) the start's VisibleCellIds or (portal mode) its
  direct portals. Sibling of FindCellList. Mirrors FindCellList's null-CellBSP skip
  (CellTransit.cs:518) so a cell lacking hydrated CellBSP doesn't spuriously claim
  every point via PointInsideCellBsp's null-node "inside" default.

- PhysicsEngine.AdjustPosition (CPhysicsObj::AdjustPosition, pc:280009): resolve a
  point's cell from a seed. Indoor (>=0x100) → FindVisibleChildCell(stab-list);
  outdoor → landcell snap (same grid lookup as ResolveCellId). The seen_outside
  sub-fallback is deferred (off the cottage/cellar path; spec §6).

Both are unwired into any production path — they land the machinery update_viewer's
start-cell + fallback 1 need (and that residual C also needs). The App SweepEye
orchestration that calls them lands next.

Decomp-faithful per the live-capture finding: A's V1 sweep already contains the eye
(eyeInRoot=Y 99.75%, never void); this completes A as a verbatim port. Spec:
docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:56:16 +02:00
Erik
9fdf6a5d01 chore(p2): strip cellar-lip dispatch-trace probes after visual confirmation
The stale-footCenter fix (cc4590f) is visually confirmed: cellar ascent is
smooth, inn door still blocks, generic step-up still climbs. The residual
9/29 (0,-1,0)-sliding-normal records did NOT manifest in live play —
confirming they were buggy-trajectory artifacts.

Remove the temporary investigation scaffolding added for this trace:
- [fc-dispatch] probe in BSPQuery.FindCollisions
- [step-sphere-down] probe in BSPQuery.StepSphereDown
- CellarLipWedgeTests.Diagnostic_TraceRecordByIndex [Theory]

Kept: the fix, the Fix_StaleFootCenter_* regression guards, and the
DocumentsResidualWedge_* documents-the-bug test. Core suite 1317 pass /
4 fail (documented baseline) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:24:20 +02:00
Erik
cc4590f9e5 fix(p2): cellar-lip wedge — check_other_cells must use the LIVE sphere position
Root cause of the "blocked at the last cellar step" wedge (the primary,
ramp-climb family — 20/29 captured records). The prior session's pinned
"find_walkable is never called during the step-down" was a probe artifact:
a fresh [fc-dispatch]/[step-sphere-down] trace proves Path-3 StepSphereDown
IS reached for both the carried cell and the iterated other-cell.

The real divergence is in Transition.CheckOtherCells. Retail's
check_other_cells (acclient_2013_pseudo_c.txt:272735 → (*cell+0x88)(this))
re-collides the OTHER cells against the LIVE sphere_path.global_sphere — the
position AFTER the primary insert_into_cell ran. The primary collide can MOVE
the sphere: a Path-5 full-hit dispatches step_sphere_up, and a successful
step-up climbs the foot onto the cottage floor yet still returns OK. acdream
instead reused a footCenter snapshot captured BEFORE the primary collide, so
once the lip-riser step-up climbed the foot onto the floor, check_other_cells
still queried 0171 at the pre-climb (sunk ~0.25 m below the floor) position →
the foot spuriously near-missed the very floor it had climbed onto →
neg_step_up → a doomed second step_up vs the floor normal (0,0,1) whose
step_up_slide unwound the climb → validate_transition reverted → 0% advance.

Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the top of
RunCheckOtherCellsAndAdvance (one line). Pre-fix 0/29 wedge records advanced;
post-fix 20/29 climb onto Z≈94.

No regression: full Core suite 1321 pass / 4 fail (the documented baseline:
Apparatus_Grounded_50cmOffCenter, 2× DoorBugTrajectoryReplay LiveCompare_*,
BSPStepUpTests.D4) / 1 skip. The 2 door LiveCompare divergences are
byte-identical with/without the fix (the door's step_up FAILS → sphere
restored → position unchanged → footCenter == live).

Tests: CellarLipWedgeTests.Fix_StaleFootCenter_RampRecordClimbsCottageFloor +
Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance (new, GREEN).
DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY documents the
remaining 9/29 (0,-1,0)-sliding-normal +Y-kill family (slide territory,
deferred to the visual gate).

Apparatus retained (gated on ACDREAM_PROBE_INDOOR_BSP): [fc-dispatch] in
BSPQuery.FindCollisions + [step-sphere-down] in BSPQuery.StepSphereDown +
CellarLipWedgeTests.Diagnostic_TraceRecordByIndex — strip once the residual
is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:15:19 +02:00
Erik
bc1be26907 test(p2): faithful cellar-lip wedge reproduction + investigation apparatus (no fix yet)
P2 / M1.5 "blocked at the last step" cellar-lip wedge. This session built a faithful
deterministic reproduction and peeled the cause through six evidence-disproven framings
to one bounded question. NO fix landed — the last layers were each disproven by evidence,
and guessing at the load-bearing collision code is the saga's failure mode.

Apparatus:
- CellarLipWedgeTests.cs + Fixtures/cellar-lip/ (3 real cell dumps + wedge-records.jsonl =
  29 captured ACDREAM_CAPTURE_RESOLVE wedge calls). Replays the exact calls + body-before
  through the lip-cell engine: all 29 reproduce at 0% advance in <200 ms. Tests are
  documents-the-bug / diagnostics (GREEN while the wedge exists).
- TEMP probes ([path5-wall]/[fw-enter]/[find-walkable] in BSPQuery; [neg-poly]/[stepsphereup]/
  [stepdown-decide]/CheckOtherCells cn/sn/negHit in TransitionTypes), gated on
  ACDREAM_PROBE_INDOOR_BSP, marked STRIP. TransitionTypes neg-poly shortcut has a reverted-fix
  comment (slide attempt didn't clear the wedge).
- tools/cdb/retail-*-trace.cdb (retail cdb traces).

Findings (handoff: docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, see the
"NEXT-SESSION KICKOFF" at top):
- Flat-floor contact plane is retail-faithful (v1 trace, full-file correlation). NOT the bug.
- PosHitsSphere cull sign is retail-faithful (cdb -z verified; the Binary Ninja `test ah,N; jp`
  parity-jump reads inverted — caught + reverted a wrong fix from that mis-read).
- Sphere radius correct (0.48 player / 0.30 camera probe).
- Retail connector cell 0xA9B40175 never blocks (CEnvCell::find_collisions trace: 0 Collided/Slid).
- PINNED: during the step-up's step-down, BSPQuery.FindWalkableInternal is never called for cell
  0171, so the cottage floor (poly 0x0023, Z=94) is never tested as walkable -> no contact plane
  -> step-up fails -> StepUpSlide=Collided -> wedge.

Next: trace FindEnvCollisions -> FindCollisions path dispatch for 0171 during StepDown=true (why
StepSphereDown/find_walkable is skipped), port retail, validate via CellarLipWedgeTests, regress
DoorBugTrajectoryReplayTests + visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:30:36 +02:00
Erik
0935a315bf fix(p2): port retail slide_sphere for head near-miss (cellar wall-slide)
Replace the A6.P4 Collided shortcut in transitional_insert's neg_step_up==0
branch with the faithful CSphere::slide_sphere. Retail
(acclient_2013_pseudo_c.txt:273350-273351, CTransition::transitional_insert):
neg_step_up==0 (HEAD-sphere near-miss) -> slide_sphere -> continue the insert
loop; neg_step_up==1 (foot) -> step_up_slide. The acdream foot branch already
did that; only the head branch took the shortcut (SetCollisionNormal + return
Collided = dead hard-stop). The slide itself is the existing
SlideSphereInternal (Sphere.SlideSphere port): it strips the into-wall
component and keeps the tangential crease (collisionNormal x contactPlane.N).

Surfaced by the B1 near-miss-gate fix (abbd761): once the grounded mover climbs
onto the cottage floor, its head sphere brushes the cellar stairwell walls and
the old hard-stop wedged it (2026-06-04 live capture: 274 (0,-1,0) + 78
(1,0,0) hits, out==current, dead oscillation). Post-fix capture shows 96
hit-and-advanced frames (the body slides along the walls).

Visual-verified 2026-06-04: closed cottage door still BLOCKS (no walkthrough --
drifts sideways along it, retail-faithful); cellar ascent now works (was always
stuck). An intermittent corner-wedge (slide into the -Y/+X wall corner) remains
-- separate finer issue, under investigation.

Core 1310 pass / 4 fail (pre-existing: 3 door documents-the-bug + D4 airborne).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:28:59 +02:00
Erik
abbd7615ee fix(p2): Path 5 near-miss = retail num_sphere>1 gate (fixes B1 step-up wedge)
The 2026-06-03 handoff localized the failing Core tests to the BSP Path 5
step-up CLIMB (find_walkable/step_sphere_down). An ITestOutputHelper capture
of B1 disproved that: the climb code is correct (matches ACE
Polygon.adjust_sphere_to_plane / BSPTree.step_sphere_down exactly). The real
bug is the A6.P4 near-miss dispatch in FindCollisions' Path 5 (Contact
branch), which diverged from retail three ways:

  1. Recorded a near-miss NegPolyHit UNCONDITIONALLY. Retail gates both
     set_neg_poly_hit calls behind `if (num_sphere > 1)`
     (acclient_2013_pseudo_c.txt:323852).
  2. Checked the foot sphere's near-miss before the head's. Retail checks
     the head (sphere1) first.
  3. Mapped foot->neg_step_up=false / head->true. Retail maps head(index 0)
     ->false (slide), foot(index 1)->true (step-up), per
     SPHEREPATH::set_neg_poly_hit (:323279, neg_step_up = arg2).

For B1's single foot sphere, the spurious near-miss -> outer loop
`!NegStepUp -> SetCollisionNormal + Collided` -> revert: the grounded mover
wedged at x=0.1 and never advanced to the wall to step up. With the verbatim
gate, a single-sphere near-miss records nothing, the sphere advances,
full-hits the wall, and step_sphere_up climbs the 0.25 m step (verified via
probe capture: foot ends at (0.6, 0, 0.25)).

The Holtburg cottage door still blocks faithfully (door slab (0,-1,0) normal,
stops in front of the door) when the scenario has a real floor — confirmed
this change does not regress the door.

The two BSPQueryTests Path5 near-miss tests used a single sphere (the very
non-retail assumption that caused this wedge); converted to the production
2-sphere shape where the head sphere records the near-miss, matching retail.

Core 1312 pass / 4 fail (the 4 pre-existing: 3 door documents-the-bug + D4
airborne, none regressed here); App 177 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:32:55 +02:00
Erik
0cc561c4d0 fix(render): doorway void — portal near-clip was near-dependent (eye-clip instead)
The cottage doorway 'void' (dark cell + floating entities while the chase camera looks
through the opening): PortalProjection.ProjectToNdc clipped portals on w+z>=0 — the GL
[-1,1] near-plane test — but acdream's camera builds its projection with D3D-convention
Matrix4x4.CreatePerspectiveFieldOfView and a 1.0 m near plane (RetailChaseCamera). Against
that matrix w+z>=0 discards everything within ~0.5 m of the eye, so when the camera orbits
to ~0.1 m from a doorway portal the near edge is clipped, the far edge projects off-screen
([flap] showed p->0171 D=0.10 proj=4 clip=0 ndc Y=-3.5..-6.6), the room behind is culled
(vis=1) and only the tiny vestibule shell draws -> dark void. Rotating away moved the eye
off the portal -> vis=5 -> room rendered.

Fix: clip against the EYE (w > MinW, MinW=0.05 m), near-INDEPENDENT — a portal you're
standing in still projects (covers the screen) so the cell behind stays visible. We only
use the projected x/y for the visibility clip region, so keeping vertices in front of the
near plane is correct. Matches retail PView::GetClip near-clipping the portal before project.
RED->GREEN regression test (doorway 0.1 m from a near=1.0 eye); 177 App tests green; the
existing straddling ±50 bound still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:42:46 +02:00
Erik
1e9a9cab8c feat(render): V1 — render keys on the viewer cell+eye; lighting stays on the player
Phase W single-viewpoint V1 (un-split). The render mode decision, indoor root, and portal
side-test now key on the collided-camera viewer cell + eye (RetailChaseCamera.ViewerCellId +
camPos) — retail RenderNormalMode -> DrawInside(viewer_cell) @92675; InitCell side-test vs
viewer.viewpoint @432991. Lighting / seen_outside / playerInsideCell stay on the PLAYER cell
(CurrCell), retail CellManager::ChangePosition @4559B0. The old per-render player-root +
eye-projection split (U.4c) is removed; the flap is avoided by the robust graph-tracked viewer
cell (no AABB, no grace). [flap-cam] probe extended with viewerCell vs playerCell. CurrCell
stays player-only (blue-hole fix intact). App 176 green; Core 1295/5 baseline (no new fails).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:40:10 +02:00
Erik
d03fe84845 feat(render): RetailChaseCamera.ViewerCellId — the swept viewer cell (retail viewer_cell)
Update() now always sets ViewerCellId: the camera-collision sweep's swept cell when collision
is on (retail viewer_cell = sphere_path.curr_cell), else the passed player cell. This is the
robust, per-frame, graph-tracked 'which cell is the camera in?' answer that V1 roots the render
on — no AABB, no grace frames (the U.4c flap source). 176 App tests green (2 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:34:07 +02:00
Erik
832001d289 refactor(render): SweepEye returns (Eye, ViewerCellId) — surface the swept viewer cell
The camera spring-arm sweep already resolves the collided eye's cell (ResolveResult.CellId
= sp.CurCellId = retail viewer_cell = sphere_path.curr_cell, update_viewer pc:92871).
Return it from SweepEye so the render can root on the viewer cell (Phase W single-viewpoint
V1, Task 1). Pure plumbing — behavior unchanged; callers extract .Eye. 174 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:32:45 +02:00
Erik
79fb6e7c23 fix(render): doorway blue-hole — render root clobbered by NPCs (CurrCell per-entity write)
THE doorway flap root cause, found via [flap-cam]/[shell]/[cell-transit] (2026-06-03):
the player spawned + stood still in the room (cell 0171, NO [cell-transit] after teleport),
yet the render rooted at the vestibule (0170) for all 77,951 frames — drawing only 0170's
~8-triangle shell, the rest = GL clear color = the bluish void.

CellGraph.CurrCell IS "the player's cell" (the render root), but it was written by
SetCurrAndReturn inside the PER-ENTITY ResolveWithTransition + ResolveCellId — so EVERY NPC
wrote it. A Holtburg NPC (0x000F4240) jump-looping near the doorway clobbered the player's
render root every tick. Standing still (player makes no resolve calls) the NPC's write wins
→ stuck blue void; moving, player/NPC writes fight → the flap. This is why the membership
pick fix (correct, kept) didn't change the visual — the render root was clobbered regardless.

Fix: CurrCell is now written ONLY by the player. New PhysicsEngine.UpdatePlayerCurrCell is
called from PlayerMovementController.UpdateCellId — the single player-only chokepoint for
CellId (teleport / server snap @ SetPosition + per-frame resolver). Removed the CurrCell
write from SetCurrAndReturn (inlined the 2 resolve call sites to sp.CurCellId) and the 4
ResolveCellId sites. NPCs no longer touch the render root. Teleport→UpdateCellId also covers
spawn/standing-still (CurrCell = the player's spawn cell immediately).

CellGraphMembershipTests rewritten to the new contract (3 tests): UpdatePlayerCurrCell writes
the render root; ResolveCellId does NOT (the blue-hole guard); stale-beats-null preserved.
Full Core suite: 1295 pass / 5 fail = the documented §10 baseline, zero new breakage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:12:38 +02:00
Erik
e5457f9552 fix(physics): Stage 1 — collide-then-pick (remove pre-pick fork) — the flap engine
The ordered pick alone did not stop the cottage doorway flap: the live [cell-transit]
log showed the cell faithfully following a position that cleanly oscillated between two
values at constant Z — the signature of a bistable membership<->collision feedback loop
(user's §4.4 #3, the forked collision).

Root cause: FindEnvCollisions RE-PICKED the cell from the TARGET position (old line 1958)
BEFORE running the primary collision, so the collision geometry (which cell BSP / terrain)
swapped the instant the pick flipped -> position shifts -> pick flips back.

Retail does NOT do this. CEnvCell::find_env_collisions (acclient_2013_pseudo_c.txt:309573)
collides against the cell it was called ON (sphere_path.check_cell, the carried seed) and
picks the NEW containing cell AFTER, in CTransition::check_other_cells (272717->272761:
check_cell = var_4c). Collide-then-pick.

This commit ports that order:
- remove the pre-pick (production); collide against the carried cell (indoor BSP block /
  terrain block unchanged);
- new shared RunCheckOtherCellsAndAdvance() runs the ordered FindCellSet pick +
  multi-valued CheckOtherCells + the carried-cell advance AFTER the primary collision, for
  BOTH indoor and outdoor seeds;
- the outdoor-seed post-step replaces the removed pre-pick's outdoor->indoor re-entry
  promotion (CheckBuildingTransit interior cell + its wall collision on the entry frame).

Cache-null unit-test fallback (ResolveCellId) kept. Full Core suite: 1293 pass / 5 fail =
the documented §10 baseline exactly (2 step-up + 3 door-collision), zero new breakage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:36:19 +02:00
Erik
22a184ca68 fix(physics): Stage 1 — verbatim ordered-CELLARRAY membership pick (the R1 flap)
Port CObjCell::find_cell_list (acclient_2013_pseudo_c.txt:308742) faithfully:
- build candidates into an ordered CellArray with the CURRENT cell at index 0
  (add_cell @308766);
- EXPAND via a single forward walk over the growing array, mirroring retail's
  for(i=0;i<num_cells;i++) cells[i].find_transit_cells loop (308775-308785),
  replacing the order-losing Queue/visited BFS;
- PICK in array order with interior-wins-break (308788-308825): current cell at
  index 0 wins a boundary straddle, so membership no longer ping-pongs.

Deletes the 5ca2f44 current-first pre-check (the ordered array subsumes it for every
seed). Keeps its guard test (TwoOverlappingCells_CurrentCellWinsTheStraddle) + adds
two conformance tests (current-cell-first ordering; interior-wins over outdoor
fallback). Membership net: 45 pass. Decomp finding: retail stability is emergent from
the ordered pick + carried seed, not a separate portal-crossing detector — see
docs/research/2026-06-03-cell-membership-ordered-cellarray-pseudocode.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:00:12 +02:00
Erik
bc56545634 refactor(physics): Stage 1 — widen cell-candidate helpers to ICollection<uint>
Non-behavioral: lets BuildCellSetAndPickContaining pass an ordered CellArray (next
commit) while existing HashSet-passing test callers compile unchanged. HashSet<uint>
and CellArray both implement ICollection<uint>. Core builds; 9 helper tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:56:07 +02:00
Erik
b44dd147bc feat(physics): Stage 1 — CellArray ordered/deduped cell collection (retail CELLARRAY)
Ports retail CELLARRAY::add_cell (acclient_2013_pseudo_c.txt:701036): ordered list,
dedup by cell_id, append at end. The order is load-bearing for the verbatim
find_cell_list current-cell-first interior-wins pick (next commits) that fixes the
R1 cottage membership flap. Implements ICollection<uint> (helper-facing) +
IReadOnlyCollection<uint> (consumer-facing). 5 unit tests.

Also lands the membership-port pseudocode (workflow step 3) + the Stage-1 plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:54:45 +02:00
Erik
5ca2f448d4 fix(physics): R1 membership — current-cell-first hysteresis in find_cell_list pick
The flap R1 exposed is a cell-membership ping-pong: the find_cell_list containing-
cell pick (CellTransit.BuildCellSetAndPickContaining) iterated an UNORDERED HashSet
and returned the first interior cell whose BSP contains the sphere center, with no
preference for the current cell. Retail CObjCell::find_cell_list adds the current
cell at index 0 (add_cell, pc:308766) and iterates current-first with interior-wins-
break (pc:308791-308819) — you STAY in your current cell until the center genuinely
leaves it. acdream's HashSet dropped that ordering; once the candidate set churns at
a boundary the enumeration can surface a neighbour before the current cell → the
ping-pong. Restore the explicit, deterministic current-cell-first test (retail's
index-0 hysteresis). + a two-direction regression guard (current cell wins the
straddle).

Diagnosed from the existing [cell-transit] walk log (no new probing): room flips are
the pick non-determinism; stairs flips additionally show the foot Z oscillating
~0.2m/tick (a separate stairs-physics residual, #98 family, to verify after this).

The 2 DoorBugTrajectoryReplay failures are PRE-EXISTING (verified: they fail without
this change too) — 2 of the handoff's '3 door-collision apparatus / A6.P5'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:51:25 +02:00
Erik
58822fed96 fix(render): R1 — repurpose the ParentCellId==null cell-gate bypass (#78)
EntityPassesVisibleCellGate no longer returns true unconditionally for outdoor
scenery under a cell filter (was the headline #78 bleed). Outdoor scenery now
draws only via the unfiltered bucket (visibleCellIds: null) + ResolveEntitySlot's
OutsideView routing. The outdoor-root global Draw passes visibleCellIds: null
(no portal-cell scoping outdoors; retires VisibleCellIds as a render gate — peering
into buildings is R5). Updated the EntityClipTests case that pinned the old bypass
(Included -> Excluded). 174/174 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:10:26 +02:00
Erik
c4fd71149a feat(render): R1 — binary render decision, indoor = per-cell DrawInside only
GameWindow.OnRender: when clipRoot != null, run only InteriorRenderer.DrawInside
(per-cell shells + per-cell objects + live-dynamics); the global entity pass +
global shell pass are no longer issued indoors. Outdoor scenery drawn clipped to
the doorway (after terrain, before the Z-clear). Outdoor root path unchanged.
pvFrame hoisted so the splice reads OrderedVisibleCells; per-frame 3-bucket
partition built on the indoor root. Retail RenderNormalMode @ 0x453aa0.

InteriorRenderer amended with a DrawableCells membership filter (an IsNothingVisible
cell can be in OrderedVisibleCells but absent from CellIdToSlot — iterate for ORDER,
filter for membership; matches the old envCellShellFilter set exactly).

Build green, 174/174 App tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:01:53 +02:00
Erik
4b75c68ea3 feat(render): R1 — InteriorRenderer per-cell DrawInside loop (retail PView::DrawCells)
Per-cell flood: closest-first over OrderedVisibleCells, per cell draws the closed
shell (EnvCellRenderer.Render(pass,{cellId})) + that cell's objects, then live-
dynamics unclipped, then transparent shells. Reuses the existing dispatcher Draw
per cell (safe to call N x/frame; only diagnostic GPU-timing miscounts). Caller
owns the landscape-through-door + Z-clear. Not yet wired (Task 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:54:42 +02:00
Erik
cf85ea4e17 feat(render): R1 — InteriorEntityPartition (3-bucket per-cell entity split)
Pure helper splitting a frame's entities into live-dynamic / per-cell statics /
outdoor scenery, by the same precedence as WbDrawDispatcher.ResolveEntitySlot
(serverGuid first — live entities have no ParentCellId). Feeds the per-cell
DrawInside loop. 3 unit tests, GL-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:53:42 +02:00
Erik
b595cfbb9f fix(render): Phase W Stage 4 — scissor sky/weather mesh in Scissor mode (adversarial-review fix)
Opus adversarial review caught a real gap: the sky/weather MESH bled full-screen indoors in TerrainClipMode.Scissor (a multi-exit interior, or an OutsideView with >8 edges). The assembler only sets the binding=2 clip-plane UBO in Planes mode; in Scissor mode it leaves count==0, so sky.vert's gl_ClipDistance writes all +1 (no clip) and the mesh draws — which had NO scissor wrapper, only the no-op planes — covered the whole screen. The terrain and particle passes were already scissored; the sky/weather mesh was the one unguarded path.

Fix: scissor the WHOLE sky pre-scene + weather post-scene blocks (mesh + particles) to the OutsideView AABB when indoors. In Planes mode the scissor is a harmless over-include (the per-vertex clip planes are tighter and do the exact doorway clip); in Scissor mode it is the sole confinement, mirroring the terrain Scissor path; outdoors it is skipped (full-screen, bit-identical). Also hoisted the scissor-disable out of the particle null-check (cleaner, leak-free on the no-particle path) and corrected a stale 'weather does not write gl_ClipDistance' comment at the world-bracket close.

The single-convex-doorway case (Holtburg cottage) was already correct (Planes mode); this seals the multi-opening case. Build 0/0; App tests 171/171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:57:11 +02:00
Erik
872dd34943 docs(render): Phase W Stage 4 — verify ceiling-sealed / terrain-viewpoint / GL-state + ParentCellId
T4.4: annotate EnvCellRenderer.RegisterCell to document that ceilings are present by
construction. PrepareCellStructMeshData iterates ALL CellStruct.Polygons (floor + walls +
ceiling) with no surface filter; retail PView::DrawCells draws the same closed-box
drawing_bsp. No ceiling filtering confirmed.

T4.2: annotate TerrainModernRenderer.Draw to document that terrain projects from the
passed-in ICamera (uView + uProjection derive from the same camera as all other
renderers). No separate landscape viewpoint exists that could desync from the eye.

T4.5, T5.1, T5.2: pure verification — no code changes (see report).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:24:23 +02:00
Erik
a8b831c23b test(render): Phase W Stage 4/5 — assembler OutsideView AABB + PView BFS + entity-clip tests
ClipFrameAssemblerTests (3 new):
- Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds
- Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid
- Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero

PortalVisibilityBuilderTests (3 new):
- Build_ExitPortalVisible_OutsideViewNonEmpty
- Build_NoExitPortal_OutsideViewEmpty
- Build_RootCellAlwaysFirstInOrderedVisibleCells

EntityClipTests (new file, 5 tests):
- EntityClip_ParentInVisibleSet_Included
- EntityClip_ParentNotInVisibleSet_Excluded
- EntityClip_NullVisibleSet_IncludesAll
- EntityClip_NullParentCell_NullVisibleSet_Included
- EntityClip_NullParentCell_NonNullVisibleSet_Included

WbDrawDispatcher.EntityPassesVisibleCellGate changed private → internal static
(AcDream.App already has InternalsVisibleTo AcDream.App.Tests; no new seam needed).

160 → 171 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:21:08 +02:00
Erik
ce2edad66a feat(render): Phase W Stage 4 — sky/weather portal-clip seal (LScape through the doorway)
The sky + weather (rain cylinder) are retail's LScape — 'the outside seen through the exit portal.' Retail PView::DrawCells (pseudo_c:432709) draws LScape clipped to the OutsideView when outside_view.view_count>0, then does a conditional Z-buffer-ONLY clear (432731) before the indoor cells. acdream now does the same:

- sky.vert writes gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads. The OutsideView planes are screen-space (NDC) half-spaces encoded as clip-space planes (nx,ny,0,dw); the test dot(plane,gl_Position)>=0 reduces after perspective divide to nx*ndcX+ny*ndcY+dw>=0 — projection-INDEPENDENT — so the same plane set clips the sky EXACTLY despite its separate dome projection. count==0 (outdoor) → all distances +1 → full-screen, bit-identical. Lighting/fog math untouched.

- GameWindow: relocated the sky pre-scene + weather post-scene draws to their retail LScape positions, each in a local 8-plane clip bracket so sky.vert confines them to the doorway indoors / full-screen outdoors. Added the conditional doorway depth-ONLY Z-clear (no color → no blue hole), scissored to the OutsideView AABB. drawSkyThisFrame = seen_outside policy AND (outdoor OR exit-portal-in-view) — a sealed interior with no exit portal in view draws no sky (kills the full-screen-sky interim regression). Sky pre/post particle passes (particle.vert has no gl_ClipDistance) scissored to the doorway bbox.

- ClipFrameAssembly gains HasOutsideView + OutsideViewNdcAabb (the doorway NDC AABB, computed for BOTH Planes and Scissor terrain modes — unlike TerrainScissorNdcAabb which is Scissor-only).

- The pre-login goto SkipWorldGeometry moved BELOW the sky draw so the live sky still renders during the EnterWorld handshake (clipAssembly is null/no-clip pre-login → full-screen).

Build green; App tests 160/160. Stage 4 tests + verify-annotations follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:15:08 +02:00
Erik
38a52a7dac feat(core): Stage 3 T3.3 — CellGraph.FindVisibleChildCell (retail find_visible_child_cell)
Port CEnvCell::find_visible_child_cell @ 0x0052dc50 (pseudo_c:311397): walk
rootId's StabList + the root itself, return the first EnvCell whose
PointInCell is true for worldPoint. Used to resolve the camera cell in
3rd-person from the physics cell graph rather than a fresh AABB reclassification.
Root is always the player cell (preserves U.4c flap fix). Returns null when
no stab-list cell or root itself contains the query point.

Confirmed (T3.3 Step 1): no production call site uses FindCameraCell for the
camera projection — the only AABB camera resolver is now deleted (T3.1).
FindVisibleChildCell is wired implicitly via Stage 4 (camera-outside-door scenario);
no GameWindow call site needed in Stage 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:37:10 +02:00
Erik
352086042e feat(render): Stage 3 T3.2 — seen_outside terrain/sky gate per CellManager::ChangePosition
Port retail CellManager::ChangePosition @ 0x004559B0 (pseudo_c:94649) landscape
policy. Three changes in GameWindow.OnRender:
1. Extract rootSeenOutside = physicsRoot?.SeenOutside ?? true after
   ComputeVisibilityFromRoot (outdoor null root → always seen_outside=true).
2. Replace IsInsideAnyCell AABB scan with seen_outside-derived predicate:
   playerInsideCell = cameraInsideCell && !rootSeenOutside.
   Semantics: sun zeroed only in sealed interior (dungeon); building interiors
   with seen_outside keep the sun (sky visible through door).
3. renderSky = !cameraInsideCell || rootSeenOutside (Stage 3 gate, interim:
   sky draws full-screen in building interiors until Stage 4 clips to doorway).
4. Weather gate updated to follow renderSky (seen_outside policy).

Retail anchors: CellManager::ChangePosition 0x004559B0 (landscape/sun policy),
SmartBox::RenderNormalMode 0x00453aa0 (sky gate per seen_outside).

NOTE: Interim regression — sky renders full-screen indoors for seen_outside
cells until Stage 4 wires OutsideView clip. Expected per EXECUTION POLICY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:37:00 +02:00
Erik
6a1fbbd44e refactor(render): Stage 3 T3.1 — delete FindCameraCell AABB grace-frame fallback
ComputeVisibilityFromRoot(null, …) now returns null (outdoor root) instead of
calling FindCameraCell(fallbackPos). Retail CellManager::ChangePosition
(0x004559B0) reads the transition-owned curr_cell — it does NOT re-derive from
a static position. W2a guarantees CurrCell is set from the first tick, so the
AABB fallback is dead. Deleted: FindCameraCell (389–446), _lastCameraCell,
_cellSwitchGraceFrames, CellSwitchGraceFrameCount. GetVisibleCells retains a
brute-force AABB scan for test-compat; ComputeVisibility stays for the same
reason. Updated 3 null-root tests in CellVisibilityFromRootTests to assert the
new null-returns-null behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:36:47 +02:00