# Camera-collision indoor engagement — the shared-root residual fix (2026-05-31) **Phase:** M1.5 "indoor world feels right" — post-flap-fix residuals. **Status:** design (pre-approved by user 2026-05-31; "most thorough + retail-faithful"). **Predecessors:** flap fix `0ee328a`; diagnosis `docs/research/2026-05-31-camera-collision-indoor-diagnosis.md`; camera-collision research `docs/research/2026-05-29-a8f-camera-collision-handoff.md`. ## Problem (one shared root, three faces) After the U.4c flap fix (indoor visibility rooted at the **player's** cell; the 3rd-person camera **eye** still drives the per-frame projection), three residuals remain — transparent outer walls (#2), terrain-through-floor (#78), "stairs everything grey" (#3). Evidence (`u4c-fix.log`, current code state): the eye is **outside the player's cell ~90% of frames**, at full chase distance (~3.4 m) — i.e. **the shipped camera collision is not engaging in interior cells.** When the eye is displaced, it projects the player-traversed portal openings to garbage/off-screen NDC (74% of frames), collapsing `OutsideView` to empty → terrain `Skip` (grey, #3) or tripping the giant-scissor fallback → over-include (bleed, #78/#2). The projection **from the eye is correct** (the OutsideView is a screen-space clip and the screen is the eye's view) — the fault is the eye being in the wrong place. So the root fix is **keeping the eye in valid space**, which resolves all three faces at once. ## Why the shipped collision doesn't engage (code-verified) `PhysicsCameraCollisionProbe.SweepEye` → `PhysicsEngine.ResolveWithTransition(... cellId = playerCell, moverFlags = IsViewer|PathClipped|FreeRotate|PerfectClip ...)`. The camera eye sweeps UP+BACK from the head-pivot (~2.6 m back, ~2.25 m up). In an acdream cottage the enclosing geometry (walls/floor/roof) lives in a **landblock-baked exterior-shell GfxObj** registered `cellScope=0` (outdoor/landblock-wide shadow list) — established by issue #98 (the cottage floor that capped the player's head sphere is GfxObj `0x01000A2B`, not cell BSP). `ShadowObjectRegistry.GetNearbyObjects` has the **issue-#98 indoor gate** at `ShadowObjectRegistry.cs:480`: ```csharp if ((primaryCellId & 0xFFFFu) >= 0x0100u) // primary cell is indoor return; // skip the outdoor radial sweep entirely ``` So while the viewer sphere's primary cell is the indoor cottage cell, the sweep is **gated away from the exterior-shell GfxObj** — the only geometry that encloses the cottage in our data model. The eye therefore finds nothing to stop it and flies to full distance. ## Retail behavior (decomp-verified — the faithfulness anchor) `SmartBox::update_viewer` @ `acclient_2013_pseudo_c.txt:92761-92892`: - roots the viewer `CTransition` at the **player/pivot cell** (`init_path(t, cell_1, pivot, sought)`; `cell_1` = `AdjustPosition`'s pivot cell, fallback `player->cell`); - `init_object(player, 0x5c)` = `IsViewer|PathClipped|FreeRotate|PerfectClip` (acdream matches); - sweeps `viewer_sphere` (0.3 m) via `find_valid_position` and publishes the **stopped** `sphere_path.curr_pos` + `curr_cell`; fallbacks `AdjustPosition` → snap-to-player. So retail bounds the viewer by **whatever the swept transition hits inside the player's cell.** Retail's interior EnvCells are **self-enclosing** (walls + ceiling in the cell's own geometry), so the viewer is stopped by interior geometry, and retail's "structural separation" (`CObjCell:: find_cell_list` `:308751-308769` adds outdoor GfxObjs only to outdoor cells' shadow lists) holds *because* the interior is self-contained. **acdream's divergence:** our cottages are NOT self-enclosing — the enclosure is the landblock shell GfxObj (per #98). So for our data model, the shell GfxObj **is** the enclosure the viewer must collide. Letting the viewer reach it is the faithful analog of retail's viewer-bounded-by-enclosure. ## The fix (Option A — viewer-exempt the #98 gate) Thread the mover flags (or a derived `isViewer` bool) from the `FindObjCollisions` call site (`ObjectInfo.State` is already on the `Transition`) down through to `ShadowObjectRegistry.GetNearbyObjects`, and change the gate to: ```csharp // Issue #98 gate is correct ONLY for the player foot/head capsule (it stops the cottage-floor // GfxObj from capping the head sphere from the cellar below). The camera viewer (IsViewer, a // single 0.3 m sphere, no capsule, no contact-plane) must reach the landblock-baked building // shell that forms the cottage enclosure — retail's update_viewer bounds the viewer by the // cell enclosure (acclient_2013_pseudo_c.txt:92761); in acdream's data model that enclosure is // the shell GfxObj. find_obj_collisions has no indoor gate (acclient_2013_pseudo_c.txt:308918). if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return; ``` `GetNearbyObjects` gains an `isViewer` parameter (default `false` → existing callers keep the gate). Only `PhysicsCameraCollisionProbe.SweepEye` passes `IsViewer`, so only the camera sweep changes behavior. ### Why not the alternatives - **Re-scope the cottage shell registration to the indoor cell** (`cellScope` = cottage cell): requires per-GfxObj→cell adjacency we don't have and isn't how retail models it; would also re-expose the player to the #98 head-cap. Rejected. - **Make interior cells self-enclosing (faithful hydration of ceiling/walls into cell BSP):** the genuinely-retail-faithful end state, but a large, #98-adjacent, high-risk refactor of cottage hydration. **Documented out-of-scope follow-on** (file as a residual after the visual gate). Option A is the low-risk bridge that matches retail's *observable* behavior for our data model. - **Retail fallback chain (`AdjustPosition` → snap-to-player):** faithful completeness, but not load-bearing for this residual (our sweep succeeds and returns a position; it doesn't *fail*). Out-of-scope follow-on. ## Tests (TDD) 1. **GREEN the RED test** `CameraCollisionIndoorTests.SweepEye_IndoorCellExteriorGfxObjWall_ NotReachedFromIndoorContext_CurrentlyFails`: after the fix, the viewer sweep stops at the shell wall → `pulledIn ≥ 0.5` (rename to drop `_CurrentlyFails`). 2. **#98 regression guard (new):** an `IsViewer` sweep in the cellar toward the cottage-floor GfxObj behaves correctly (the viewer single-sphere is not a head-capsule, so the #98 cap does not apply); assert the player's gate behavior is untouched (an `IsPlayer` sweep at the same site still hits the gate / keeps the #98 fix). Use the cottage GfxObj fixture (`RegisterCottageGfxObj`, `0x01000A2B`). 3. Full `dotnet build` + `dotnet test` green; with-fix failure set ⊆ baseline (the pre-existing static-leak flakiness is independent). ## Acceptance - Build + tests green; RED→GREEN; #98 guard passes. - **Visual gate (the one user step):** walk `+Acdream` into a Holtburg cottage / cellar / stairs and a window room with `ACDREAM_PROBE_FLAP=1`. Expect: the eye stops at walls/ceiling (no transparent outer walls, no grey, no terrain-through-floor); `[flap-sweep]` shows `pulledIn > 0` + `eyeInRoot=Y` for indoor cells; `[flap-cam]` shows `terrain=Planes` through windows (not `Skip`/`Scissor`-fallback). The visual gate is the **arbiter on real geometry** — if the eye still flies free in the main-floor residual cells (0174/0175), the residual is a different cause (interior-BSP completeness) and we iterate with the live `[flap-sweep]` data. ## Out of scope (documented residuals) - **U.5** outside-camera → building-interior peering, and the legitimate eye-through-an-open- doorway exit (both deferred in the Phase U spec). - **Interior-cell self-enclosure hydration** (the deeper retail divergence; faithful end state). - **Viewer fallback chain** (`AdjustPosition` → snap-to-player) for the find-valid-position-fails path. - The throwaway `[flap]`/`[flap-cam]`/`[flap-sweep]` apparatus is kept for the visual gate and stripped once the indoor residuals close.