diff --git a/docs/research/2026-05-29-a8f-camera-collision-handoff.md b/docs/research/2026-05-29-a8f-camera-collision-handoff.md new file mode 100644 index 0000000..7336337 --- /dev/null +++ b/docs/research/2026-05-29-a8f-camera-collision-handoff.md @@ -0,0 +1,371 @@ +# 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).