# Phase A8.F — camera-collision root cause + handoff (2026-05-29, session 2) ## TL;DR The A8.F "flap" (building walls / ground blinking in and out) and the missing/transparent-wall symptoms are **not** a builder or enforcement bug. Their root cause is the **3rd-person camera EYE passing through walls**: the A8.F renderer computes "am I inside a building?" and "which side of each doorway am I on?" from the camera eye position, so when the eye clips a wall those decisions flip frame-to-frame. This session: (1) **fixed + committed** the worst symptom — the cellar terrain flood (commit `9417d3c`); (2) established the recursive-clip **builder actually works** for most cells (the prior "Bug B" framing was wrong); (3) **reframed the root cause** to the camera, with the user's help; (4) researched the camera system (Opus agent + my verification). **The fix is retail-faithful (CORRECTED 2026-05-29 PM):** retail's camera **does** avoid walls — `SmartBox::update_viewer` (0x00453ce0) sweeps a 0.3 m collision sphere (`viewer_sphere`) from the player head-pivot to the desired eye via `CTransition::find_valid_position` and uses the stopped position; it **also** fades the player to translucent when that eye is super close (`CameraSet::UpdateCamera`). Both are retail-faithful, and acdream already owns the `Transition` swept-sphere engine to port the collision (the fade is already ported). So the next session implements a faithful swept-sphere camera collision — **no divergence, no sign-off needed.** (An earlier pass in this doc wrongly said "retail has no camera collision"; that was a research error — see the KEY FINDING section.) --- ## What this session shipped / established - **Bug A FIXED + committed (`9417d3c`):** an empty `OutsideView` while inside a building now draws **no** outdoor terrain/scenery (empty mask = "no outdoors visible," not "all outdoors"). Previously the `else` branch disabled the stencil and flooded ungated terrain over the cell interior. **Visual-confirmed by the user: cottage cellar walls are solid now, no terrain bleed-through.** App tests 108/108. All behind `ACDREAM_A8_INDOOR_BRANCH=1`; default play unaffected. - **"Bug B" (builder under-produces) is substantially NOT real.** The pv-dump census showed `PortalVisibilityBuilder` produces correctly *narrowed* `OutsideView` regions for most cells (`0172/0173/0162/015E/0165/016F` → `polys=1`). The empty cases are mostly legitimate (a windowless cellar can't see daylight) or driven by the camera being in an invalid position (see root cause). The handoff predecessor's Finding 2 ("never narrows") does not hold. - **Root cause reframed → the camera.** Confirmed below. - **Camera research done** (Opus agent, findings verified against the decomp + the code by this session). ### Diagnostics added (committed in `9417d3c`, all opt-in) - `PortalVisibilityBuilder.Build` now emits a **`CAMPORTAL[i]` census** under `ACDREAM_A8_DUMP_PV=1`: per camera-cell portal, before the BFS guards, it logs `other=`, `polyLen=`, `hasPlane=`, `interiorSide=`, `planeN=`. This is what let us see that the cottage front-door exit portal was *culled by the side test* (`interiorSide=False`) rather than missing. - `[opaque]` probe (`ACDREAM_PROBE_ENVCELL=1`) — opaque cell-render stats (`cells`/`tris`) **before** the transparent loop overwrites them (the existing `[envcells]` line reads post-loop and is misleading — ignore its `cells=1 tris=0`). - `tools/A8CellAudit` `portals ` now **replicates `BuildLoadedCell`'s polygon-vertex resolution** and prints `BUILDER_SEES=OK/EMPTY` per portal, so exit-portal validity is checkable offline without a launch. --- ## The visual symptoms (user-observed, 2026-05-29, with `ACDREAM_A8_INDOOR_BRANCH=1`) 1. Cellar walls **solid** (Bug A fix confirmed). ✓ 2. **Flap**: buildings/ground disappear when passing from inside to outside. 3. Cellar entrance no longer covered by terrain (Bug A fix). ✓ 4. Looking into certain windows from **outside**: back walls missing. 5. Inside **other** buildings: external + internal walls transparent (showing sky / NPCs / particles). **The user's key correlation:** items 2/4/5 happen **when the camera passes through a door, or when standing inside and panning the camera through a wall**. They're intermittent — not always reproducible — which fits a camera-position trigger, not a static rendering bug. --- ## Root cause: the camera eye drives A8.F visibility, and it clips through walls `camPos` is the **camera eye**, extracted from the inverse of the active camera's View matrix: ``` GameWindow.cs:7270-7271 Matrix4x4.Invert(camera.View, out var invView); var camPos = new Vector3(invView.M41, invView.M42, invView.M43); // = the eye ``` (`camera.View` is `CreateLookAt(_dampedEye, …)`, so the translation is the chase-camera eye — **not** the player avatar position, which is tracked separately for interior lighting at `GameWindow.cs:7415-7417`.) That eye then drives all three A8.F visibility decisions: 1. **Camera-cell + portal BFS** — `_cellVisibility.ComputeVisibility(camPos)` (`GameWindow.cs:7323`) → `FindCameraCell` via `PointInCell(camPos, …)`. The BFS portal side-test (`CellVisibility.cs:466-481`) culls portals the eye is on the wrong side of. 2. **Strict inside-building gate** (`GameWindow.cs:7343-7346`): `cameraInsideBuilding = a8IndoorBranchEnabled && PointInCell(camPos, CameraCell) && CameraCell.BuildingId != null`. Picks the inside-out vs outside-in render branch. 3. **Per-portal interior-side cull in the recursive-clip builder** — `PortalVisibilityBuilder.CameraOnInteriorSide(cell, i, cameraPos)` (`PortalVisibilityBuilder.cs:196-203`; cull site ~`:124`): transforms `cameraPos` to cell-local and dot-tests against the portal plane. **The flap mechanism:** when the eye damps to a position outside the room (or in the next room), `PointInCell(eye)` flips and `CameraOnInteriorSide` inverts. The camera-cell ping-pongs, the inside/outside branch switches, and the exit portal through a doorway is culled-then-uncovered frame-to-frame → walls/ground blink. The 3-frame `CellSwitchGraceFrameCount` hysteresis (`CellVisibility.cs:167`) only masks single-frame blips; a sustained multi-frame clip defeats it. **Hard evidence (this session's capture):** at cottage cell `0xA9B40170` the front-door exit portal was valid (`polyLen=4`) but the census showed `interiorSide=False` — i.e. the eye was on the *outdoor* side of the door plane `(0,-0.995,0.105)`. The plane math puts the cull threshold at eye Y < −8.50 with the door at Y≈−8.5: **the eye had poked out through the front wall while the player stood inside.** The same portal projected fine when the BFS reached `0170` from a deeper room (`0173`) where the eye was well inside. --- ## KEY FINDING: retail DOES collide the camera (swept sphere) — plus the close-up fade Retail avoids walls via a **swept-sphere spring arm**, then fades the player when very close. Three stages — an earlier research pass conflated stages 1+3 and missed stage 2, producing a wrong "no collision" conclusion that this section corrects. - **Stage 1 — compute the *desired* eye (no collision here).** `CameraManager::UpdateCamera` (0x00456660, decomp `:95505-95953`) computes eye = `pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`), and stores it as `SmartBox::viewer_sought_position` (a `Position`, `acclient.h:35196`). This *producer* does no raycast — which is ALL the earlier pass read, hence its wrong conclusion. - **Stage 2 — collide the desired eye (the camera pull-in the user observes).** `SmartBox::update_viewer` (0x00453ce0, decomp `:92761-92892`) — the *consumer* of `viewer_sought_position` — runs it through a swept-sphere `CTransition`: `makeTransition` → `init_object(player, 0x5c)` → `init_sphere(1, &viewer_sphere, 1f)` → `init_path(cell, pivot, sought)` → **`find_valid_position`** → on success `set_viewer(sphere_path.curr_pos)` (the STOPPED position). Fallbacks: `CPhysicsObj::AdjustPosition`, then snap to the player's position. `viewer_sphere` is a global `CSphere`, **radius 0.3 m**, center (0,0,0) (decomp `:93308-93314`, `:1144645`). - **Stage 3 — fade the player when the (collided) eye is super close.** `CameraSet::UpdateCamera` (0x00458ae0) calls `CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679/97698/97725/97737`) — opaque at ≥0.45 m, transparent at ≤0.20 m. **acdream already ports this** as `RetailChaseCamera.ComputeTranslucency` (`RetailChaseCamera.cs:367-376`). **Verification (mine, this session):** confirmed `viewer` / `viewer_sought_position` are `Position` structs (`acclient.h:35193/35196`); read `SmartBox::update_viewer` in full and confirmed the `CTransition` swept-sphere from pivot→sought + the `set_viewer(sphere_path.curr_pos)` use of the collided position; confirmed `viewer_sphere.radius = 0.300000012f` (`:93314`); confirmed the player-fade call sites in `CameraSet::UpdateCamera`. The earlier "no collision" finding was **wrong** — it traced the *producer* (`CameraManager::UpdateCamera`) but not the *consumer* (`update_viewer`), where the collision lives. **Caught by the user**, who has played retail and observed the camera pulling in at walls. (Lesson worth keeping: when the decomp says "no X" but a domain expert says X exists, trace the consumer of the computed value, not just the producer.) **Implication:** a swept-sphere camera collision is the **retail-faithful** fix, NOT a divergence. No special divergence sign-off is needed; this is a straight port, and acdream already owns the swept-sphere machinery. --- ## acdream's current camera (file:line) All under `src/AcDream.App/Rendering/` unless noted. - **Two chase cameras, toggled per-frame:** legacy `ChaseCamera.cs` (rigid) and default `RetailChaseCamera.cs` (damped). Selected by `CameraController.Active` (`CameraController.cs:20-33`) via `CameraDiagnostics.UseRetailChaseCamera` (default ON, `src/AcDream.Core/Rendering/CameraDiagnostics.cs`). - **Eye computation (`RetailChaseCamera.Update`, `:86-142`):** `pivotWorld = playerPos + (0,0,1.5)`; `targetEye = pivotWorld + forward*(-Distance·cosPitch) + up*(Distance·sinPitch)` (`:113-117`); then exponential damping `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` (`:121-133`); published as `Position` + `View = CreateLookAt(_dampedEye, …)` (`:136-137`). **No geometry test anywhere between target and publish.** - **The "input-lag for turning/jumping" port** = the RetailChaseCamera's three smoothing mechanisms, all verified faithful to the decomp: - Exponential damping (follow-lag): `:121-133` + `ComputeDampingAlpha:323-329` ↔ retail `:95866-95923` (`stiffness*dt*10` clamped). - 5-frame velocity-averaged, slope-aligned heading (the jump/slope feel): `:94-107`, `:290-314`, `ComputeHeading:211-257` ↔ retail `old_velocities[5]` `:95644-95677`. - Mouse low-pass filter (input lag on flicks): `FilterMouseDelta:164-177` + `FilterMouseAxis:340-358` ↔ retail `CameraSet::FilterMouseInput` (0x00457530, `:96250-96279`); wired at `GameWindow.cs:1171-1199`. - **Constants** (from retail `CameraSet::SetDefaultOffsets` 0x00458F80, `:97916-97967`): `PivotHeight=1.5`, `Distance=2.61` (=|(0,−2.5,0.75)|), `Pitch=0.291 rad`, distance clamp `[2,40]`, pitch clamp `[−0.7,1.4]`. **No collision-radius constant exists** (no collision is done). - **No 1st-person mode** (retail `SetInHead` 0x00458CE0 unported). - Spec: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md` explicitly scopes collision OUT (`:454-457`: "we don't attempt 'camera collides with wall' — same as retail"). --- ## Integration: acdream already has the collision machinery A camera "spring-arm" sweep can reuse the player's existing swept-sphere engine (`src/AcDream.Core/Physics/`): - **`PhysicsEngine.ResolveWithTransition(curPos, targetPos, cellId, radius, height, stepUp, stepDown, isOnGround, body, moverFlags, entityId)`** (`PhysicsEngine.cs:589`) → `Transition.FindTransitionalPosition` (`TransitionTypes.cs:653`) → per sub-step `FindEnvCollisions` (`:1933`, indoor branch fetches `GetCellStruct(cellId)` → `cellPhysics.BSP.Root` → `BSPQuery.FindCollisions`). - **`BSPQuery`** primitives (`BSPQuery.cs`): `PointInsideCellBsp` (`:1034`), `SphereIntersectsCellBsp` (`:1077`), `FindCollisions` (`:1637`), `SphereIntersectsPoly` (`:2085`). A purpose-built pivot→eye cast off these is likely a **better fit** than the full `Transition` (which carries unwanted step-up / walkable / gravity semantics). - **`CellPhysics`** (`PhysicsDataCache.cs:511-564`): per-cell `BSP` (collision), `CellBSP` (point-in-cell), polys/planes, transforms. Fetched via `GetCellStruct(cellId)`. - Player sweep params for reference: `radius 0.48`, `height 1.2` (`PlayerMovementController.cs:1107-1108`). A camera probe wants a **small** radius (e.g. `PhysicsGlobals.DummySphereRadius = 0.1`), `height 0` (single sphere), `isOnGround:false`, `body:null` (no contact-plane persistence). - **Slot-in point:** retail collides *after* damping (`CameraManager::UpdateCamera` damps → `viewer_sought_position`; `SmartBox::update_viewer` then collides it). So in acdream, sweep from `pivotWorld` (`RetailChaseCamera.cs:113`) to the **damped** eye and replace the published eye — i.e. **after `:131` (damp), before `:136` (publish)**. This requires injecting a collision probe into `RetailChaseCamera` (currently GL-free, no engine ref); App→Core is the allowed dependency direction, so inject `PhysicsEngine` or a narrow `ICameraCollisionProbe` interface. Check whether acdream has a `find_valid_position` equivalent (retail uses that, not the movement sweep) or whether `FindTransitionalPosition` must be adapted. --- ## The fix: a retail-faithful swept-sphere camera collision (no divergence) Port retail's stage-2 collision (stage 1 = the damped desired eye, and stage 3 = the close-up fade, are both already in acdream): 1. Keep the existing damped desired-eye computation (`RetailChaseCamera`). 2. **Add the swept-sphere collision** (the missing piece): sweep a 0.3 m sphere from the head-pivot to the *damped* eye via acdream's `Transition` swept-sphere, and publish the **stopped** position as the camera eye. Mirror retail's fallbacks (`AdjustPosition`, then snap to the player) when no valid spot is found. 3. Verify the already-ported player fade (`ComputeTranslucency`) still triggers once the eye stops clipping (it will fade *less* — the eye now stays out of walls). This fixes both the render (eye no longer behind walls) **and** the A8.F visibility (stable camera-cell + side-tests, since the eye stays in valid space). Because it matches retail, **no divergence sign-off is needed**. Per roadmap discipline, file it as a phase before coding (a short brainstorm to confirm scope is fine, but the behavior question is settled: this is what retail does). --- ## Open design questions for the implementation session 1. **`find_valid_position` equivalent:** retail uses `CTransition::find_valid_position` (place/validate a sphere, not the movement sweep). Confirm acdream has an equivalent, or adapt `Transition.FindTransitionalPosition` / a `BSPQuery`-level cast. (Behavior question is settled — this is the API question.) 2. **Sweep radius:** retail's `viewer_sphere` is **0.3 m** — start there to stay faithful; tune only if the eye hugs walls / near-plane clips in tight rooms. 3. **Which primitive:** retail uses the full `CTransition` (`init_object` on the player + `find_valid_position`). A purpose-built `BSPQuery.FindCollisions` ray/sphere cast may be a leaner fit but diverges from retail's exact call — prefer matching retail's `Transition` path first. 4. **Indoor vs outdoor geometry:** indoor walls are in `CellPhysics.BSP` (per cell). **Cottage exterior shells live in landblock-baked GfxObjs**, not cells (cf. issue #98/#101 — cottage floors/walls in GfxObj `0x01000A2B` etc.). A cell-BSP-only sweep fixes the indoor case but misses outdoor shells (the `FindObjCollisions` / ShadowEntry path would be needed for those). Decide scope. 5. **1st-person fallback:** none today; a spring arm must no-op at distance 0 if 1st-person is added. 6. **Interaction with the ported translucency fade** (`ComputeTranslucency`) — verify the fade still behaves once the eye stops clipping. 7. **Pivot reference:** sweep from `pivotWorld` (head, `:113`), not the feet. --- ## Apparatus / diagnostics (committed `9417d3c`; opt-in) Launch (PowerShell), then walk `+Acdream` into a Holtburg cottage: ```powershell $env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" $env:ACDREAM_A8_INDOOR_BRANCH="1"; $env:ACDREAM_A8_DUMP_PV="1"; $env:ACDREAM_PROBE_ENVCELL="1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f.log" ``` - `ACDREAM_A8_DUMP_PV=1` — `[pv-dump]` per camera cell incl. the **`CAMPORTAL[i]` census** (polyLen + interiorSide per portal, before the guards) + the EXIT-CULLED/PROJ/CLIP trace + `OUTSIDEVIEW polys=N`. - `ACDREAM_PROBE_ENVCELL=1` — `[opaque]` cell-render stats (reliable; pre-loop). - `ACDREAM_PROBE_VIS=1` — `[buildings]`/`[draworder]`/`[stencil]`/`[envcells]` (heavy: ~17 GL queries × several calls per frame; keep captures short). - `tools/A8CellAudit portals ` — offline portal dump with `BUILDER_SEES=OK/EMPTY` per portal. **A future "camera-cell ≠ player-cell" probe** would directly confirm the flap-by-frame: log when `PointInCell(eye)` disagrees with the player's cell. --- ## Current state / safety - **Default game safe** — everything gated behind `ACDREAM_A8_INDOOR_BRANCH=1`. - Bug A fix + diagnostics committed (`9417d3c`); App tests 108/108. - Builder works (not the bug); camera-collision work **not started** (awaiting the design decision). - Tree clean as of `9417d3c` plus untracked launch logs (`a8f-*.log`). --- ## Pickup prompt > Read `docs/research/2026-05-29-a8f-camera-collision-handoff.md`. The A8.F flap / > missing-walls are caused by the 3rd-person camera EYE clipping through walls and > destabilizing the camera-cell + portal side-tests (the eye drives `PointInCell` / > `CameraOnInteriorSide` via `camPos` at GameWindow.cs:7271). Bug A (cellar terrain > flood) is already fixed + committed (`9417d3c`); the recursive-clip builder works. > The fix is **retail-faithful** (verified against the decomp): port retail's > swept-sphere camera collision — `SmartBox::update_viewer` (0x00453ce0) sweeps a > 0.3 m `viewer_sphere` via `CTransition::find_valid_position` from the head-pivot to > the (damped) eye and uses the **stopped** position; the close-up player fade is > already ported (`RetailChaseCamera.ComputeTranslucency`). acdream already owns the > `Transition` swept-sphere engine. Slot the sweep in after `RetailChaseCamera.cs:131` > (damp), before `:136` (publish): sweep `pivotWorld`→`_dampedEye`, radius 0.3 m, with > retail's fallbacks (`AdjustPosition`, then snap to player). Check whether acdream has > a `find_valid_position` equivalent or must adapt `FindTransitionalPosition`. Watch > the open questions (outdoor building shells live in GfxObjs not cells; 1st-person; > the fade interaction). A short brainstorm to confirm scope is fine; then file a > roadmap phase. No divergence sign-off needed — this is what retail does. --- ## Reference index **acdream code** (`src/…`): - `AcDream.App/Rendering/RetailChaseCamera.cs` — eye `:113-137`, damping `:121-133`/`:323-329`, heading `:211-257`, mouse filter `:340-358`, translucency `:367-376`. - `AcDream.App/Rendering/ChaseCamera.cs` (legacy), `CameraController.cs:20-33`, `ICamera.cs`. - `AcDream.Core/Rendering/CameraDiagnostics.cs` — `UseRetailChaseCamera`, tunables. - `AcDream.App/Rendering/GameWindow.cs` — eye `:7270-7271`, visibility `:7323`, `cameraInsideBuilding` `:7343-7346`, camera updates `:6851`/`:6862`, mouse-filter `:1171-1199`. - `AcDream.App/Rendering/PortalVisibilityBuilder.cs` — `CameraOnInteriorSide` `:196-203`, cull `:124`, CAMPORTAL census in `Build`. - `AcDream.App/Rendering/CellVisibility.cs` — `ComputeVisibility` `:272`, `PointInCell` `:367`, BFS side-test `:466-481`, grace `:167`. - `AcDream.Core/Physics/` — `PhysicsEngine.cs:589`, `TransitionTypes.cs:653/1933`, `BSPQuery.cs:1034/1077/1637`, `PhysicsDataCache.cs:511-564`, `PlayerMovementController.cs:1107-1108`. - Spec: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md` (collision out-of-scope `:454-457`). **Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): - `CameraManager::UpdateCamera` @ 0x00456660 (`:95505-95953`) — computes the *desired* eye (no collision here); damping `:95866-95923`; velocity ring `:95644-95677`; result stored as `viewer_sought_position`. - `SmartBox::update_viewer` @ 0x00453ce0 (`:92761-92892`) — **THE camera collision**: swept `viewer_sphere` via `CTransition` (`init_sphere`/`init_path`/`find_valid_position`) pivot→sought; `set_viewer(sphere_path.curr_pos)`; fallbacks `AdjustPosition` then snap to player. - `viewer_sphere` — global `CSphere`, radius 0.3 m, center (0,0,0) (`:93308-93314`; decl `:1144645`). - `CameraSet::UpdateCamera` @ 0x00458AE0 (`:97643-97742`) — player-fade `SetTranslucencyHierarchical` `:97679/97698/97725/97737`. - `CameraSet::FilterMouseInput` @ 0x00457530 (`:96250-96279`). - `CameraSet::SetDefaultOffsets` @ 0x00458F80 (`:97916-97967`) — pivot (0,0,1.5), viewer (0,−2.5,0.75). - `SmartBox::PlayerPhysicsUpdatedCallback` @ 0x00452d60 (`:91842`) — writes the damped desired eye to `viewer_sought_position` (collision happens later, in `update_viewer`). - `CameraManager` struct `acclient.h:35238-35263` — no collision fields (the collision lives in `update_viewer`, not `CameraManager`); `viewer` / `viewer_sought_position` are `Position`s (`acclient.h:35193/35196`).