# 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 decision that blocks the next session** (brainstorm with the user FIRST): retail's camera does **NOT** collide with walls and pull the eye in — it lets the eye clip and **fades the player to translucent** instead (a mechanism acdream already ports). So "make the camera move closer on a wall hit" is a **deliberate divergence from retail**, not a faithful port. The next session must choose an approach (spring arm vs. decouple visibility from the eye) and get user sign-off before coding, per the CLAUDE.md no-redesign rule. --- ## 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 NOT collide the camera — it fades the player This **reframes the intended fix** and needs user attention. - **Retail's camera does no eye-vs-wall collision.** `CameraManager::UpdateCamera` (0x00456660, decomp `:95505-95953`) computes the eye as `pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`), and writes it straight to the render viewer via `SmartBox::PlayerPhysicsUpdatedCallback` (0x00452d60, decomp `:91842`) — no raycast, no sphere sweep, no BSP query in between. The `CameraManager` struct (`acclient.h:35238-35263`) has **no** collision-radius / clipped-distance / obstruction field. - **Retail's anti-clip mechanism is to fade the *player***. `CameraSet::UpdateCamera` (0x00458ae0) calls `CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679`, `:97698`, `:97725`, `:97737`) — opaque at ≥0.45 m camera-to-pivot, fully transparent at ≤0.20 m. **acdream already ports this** as `RetailChaseCamera.ComputeTranslucency` (`RetailChaseCamera.cs:367-376`, far=0.45 / near=0.20). **Verification (mine, this session):** I confirmed in the decomp that `SetTranslucencyHierarchical` is called from `CameraSet::UpdateCamera` (the four lines above), that `CameraManager::UpdateCamera`'s sole caller writes the eye with no intervening clip, that `RetailChaseCamera.cs` has zero collision/physics references (only `PlayerTranslucency`), and that `camPos` is the eye. (I relied on the Opus agent's full read of `CameraManager::UpdateCamera` for "zero collision calls in the whole function body"; I spot-checked the entry, the damping tail, the caller, and the struct — all consistent.) **Implication:** "make the camera move closer when it hits a wall" is **not what retail does**. A spring arm would be a *modern divergence* from the retail behavior acdream faithfully ported. Per CLAUDE.md ("do not replace working retail-faithful logic with a modern redesign without explicit user approval"), the next session must get the user's sign-off on the approach before coding — *but note* that here "retail-faithful" literally means "no collision," which is the behavior that produces the A8.F flap. So some divergence or decoupling is unavoidable to fix the flap. --- ## 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:** clamp `targetEye` toward `pivotWorld` **between `RetailChaseCamera.cs:117` (target) and `:131` (damp)** so the damping eases toward the clamped point. This requires injecting a physics/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. --- ## The design decision (MUST brainstorm with the user before coding) Fixing the flap requires the eye (or the point feeding visibility) to stop leaving the room. Three shapes, none purely retail-faithful: - **Option A — modern spring arm (leading candidate; matches user's instinct).** Sweep pivot→targetEye, stop the eye at the first wall hit. Fixes *both* the render (no see-through) *and* the A8.F visibility (eye stays inside → stable camera-cell + side-tests). **Diverges from retail** (retail lets the eye clip + fades the player). The already-ported translucency fade still applies and will fade *less* (eye stays farther from the player when not clipping). Needs user sign-off for the divergence. - **Option B — decouple visibility from the rendered eye.** Leave the rendered eye retail-faithful (clips + fades), but feed `ComputeVisibility` / `PointInCell` / `CameraOnInteriorSide` a *clamped* point. Fixes the flap, but the render still shows through walls when the eye clips (you'd see exterior through the wall). Less satisfying visually. - **Option C — make A8.F robust to an outside eye.** When the eye is outside but the player is inside, use the *player's* cell for the camera-cell + side tests. Similar outcome to B (flap fixed, render still clips). **Recommendation to put to the user:** Option A. It's the only one that fixes both the render and the visibility, it matches the user's stated expectation, and acdream already owns the collision machinery to do it. The cost is an explicit, documented divergence from retail's no-collision design. Confirm with the user, then (per roadmap discipline) file it as a phase before coding. --- ## Open design questions for the implementation session 1. **Approval for the retail divergence** (Option A spring arm) — the gating item. 2. **Sweep radius:** small (≈0.1–0.2 m). Too small → eye hugs walls / near-plane clips; too large → eye yanks in aggressively in tight rooms. 3. **Which primitive:** purpose-built `BSPQuery.FindCollisions` ray/sphere cast (recommended) vs. full `ResolveWithTransition` (wrong semantics for a camera). 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. **Before coding, brainstorm the fix with the user** > (`superpowers:brainstorming`): retail does NOT collide the camera — it fades the > player (`SetTranslucencyHierarchical`, already ported as > `RetailChaseCamera.ComputeTranslucency`), so a "spring arm that pulls the eye in" > is a deliberate divergence needing sign-off. Leading option: a modern spring-arm > camera collision (Option A) sweeping pivot→targetEye via a small-radius > `BSPQuery.FindCollisions` against `CellPhysics.BSP`, clamping `targetEye` between > `RetailChaseCamera.cs:117` and `:131`. Watch the open questions (sweep radius, > outdoor shells live in GfxObjs not cells, 1st-person, the existing fade). File a > roadmap phase once the approach is agreed. --- ## 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`) — NO collision; damping `:95866-95923`; velocity ring `:95644-95677`. - `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`) — sole UpdateCamera caller; eye → render with no clip. - `CameraManager` struct `acclient.h:35238-35263` (no collision fields).