# Camera-collision indoor non-engagement — diagnosis + fix design (2026-05-31) ## One-line root cause **Cause (b)**: `ShadowObjectRegistry.GetNearbyObjects` (line 480) returns early when `primaryCellId` is an indoor cell, skipping the outdoor radial sweep that contains the landblock-baked cottage exterior-shell GfxObj. The issue-#98 fix that closes the cellar-up Z-cap inadvertently also blocks the camera sweep (`IsViewer`) from seeing the exterior building shell, so the camera passes through walls entirely unimpeded. --- ## Evidence ### Live capture (`u4c-fix.log`) Post-flap-fix capture with `ACDREAM_PROBE_FLAP` + `[flap-cam]` active: ``` [flap-cam] root=0xA9B40175 res=BruteForce eyeInRoot=n eye=(155.08,13.41,96.25) player=(154.88,9.98,94.00) terrain=Skip outVisible=False [flap-cam] root=0xA9B40175 res=Cache eyeInRoot=n eye=(155.08,13.37,96.25) player=(154.88,9.98,94.00) terrain=Skip outVisible=False ``` Key observations: - `eyeInRoot=n` on 90%+ of frames — the eye is NOT in the player's indoor cell - Eye at Y≈13.4, player at Y≈9.98 → eye is ~3.4m behind the player - Eye-player distance ≈ 3.43m (full chase distance, `RetailChaseCamera.Distance=2.61` + pitch) - `[flap-sweep]` diagnostic (added to `PhysicsCameraCollisionProbe.SweepEye`) was not active in this capture but was designed to distinguish: `bsp=ok pulledIn≈0` = cell loaded, BSP exists, sweep finds nothing; `resolved=n` = cell not loaded ### RED test (`CameraCollisionIndoorTests.cs`) ``` Failed AcDream.App.Tests.Rendering.CameraCollisionIndoorTests.SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails Error: Camera sweep should be stopped by the exterior-shell GfxObj wall at Y=4.0 (registered outdoor/landblock-wide, cellScope=0). Actual pulled-in: 0.0000 m (stopped eye Y=5.0000). ``` The test registers a cottage exterior-shell GfxObj at `cellScope=0` (landblock-wide, outdoor shadow list) and sweeps the camera probe from inside an indoor cell through the exterior wall. The sweep reaches full desired eye distance with `pulledIn=0`. --- ## Precise cause trace ### Step 1 — Camera sweep starts in indoor cell `PhysicsCameraCollisionProbe.SweepEye` calls `PhysicsEngine.ResolveWithTransition` with `moverFlags = IsViewer | PathClipped | FreeRotate | PerfectClip`. The `cellId` is the player's indoor cell (e.g. `0xA9B40175`, low byte `0x0175 ≥ 0x0100`). ### Step 2 — `TransitionalInsert` → `FindObjCollisions` with indoor primaryCellId On each sub-step of `FindTransitionalPosition`: 1. `FindEnvCollisions` — while the sphere center is inside the indoor CellBSP (`SphereIntersectsCellBsp` returns true), `sp.CheckCellId` stays as the indoor cell. The indoor cell's PhysicsBSP has NO exterior-wall polygon (the exterior shell is in a separate landblock-baked GfxObj, not in any indoor cell's BSP). 2. `FindObjCollisions` at `TransitionTypes.cs:2307-2312`: ```csharp engine.ShadowObjects.GetNearbyObjects( currPos, queryRadius, worldOffsetX, worldOffsetY, landblockId, nearbyObjs, portalReachableCells, primaryCellId: sp.CheckCellId); // ← indoor cell ``` 3. `ShadowObjectRegistry.GetNearbyObjects` (line 480): ```csharp if ((primaryCellId & 0xFFFFu) >= 0x0100u) return; // ← EARLY RETURN, outdoor sweep skipped ``` The cottage exterior-shell GfxObj is registered with `cellScope=0` (landblock-wide), so it lives in the outdoor per-cell shadow lists (`_cells[outdoorLandcellId]`). The portal-reachable set (`portalReachableCells`) only contains indoor cells reachable via portals — the outdoor cell containing the GfxObj shadow entry is NOT in that set. The GfxObj is NEVER returned to `FindObjCollisions`. ### Step 3 — `ResolveCellId` flips to outdoor as sphere exits CellBSP At the sub-step where the camera sphere center crosses the CellBSP boundary (`SphereIntersectsCellBsp` returns false), `FindEnvCollisions:1947-1949` calls `ResolveCellId`: ```csharp uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId); if (resolvedOutdoorCellId != sp.CheckCellId) sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); ``` `ResolveCellId` (line 321): `SphereIntersectsCellBsp` returns false → falls through to outdoor branch → returns an outdoor terrain cell. `sp.CheckCellId` is updated to the outdoor cell. ### Step 4 — Outdoor sub-steps: GfxObj found but sphere already past the wall Now with `primaryCellId = outdoor cell`, `GetNearbyObjects` does NOT hit the early-return (outdoor cell low byte < `0x0100`). The outdoor radial sweep runs. The cottage GfxObj shadow entry IS returned. **BUT**: the sphere center is now at Y ≈ CellBspBoundary + (radius + 0.01) ≈ 3.81 (just crossed the boundary). The exterior wall polygon is at Y = 4.0. The sphere center is approaching from Y = 3.81 toward Y = 5.0 (moving in the +Y direction). The wall polygon has its inward-facing normal = `-Y` (facing into the building interior). The sphere is on the BACK FACE of this polygon. `BSPQuery.FindCollisions` Path 5 near-miss check: `dot(normal, movement)` = `dot(-Y, +Y direction)` = `-1 < 0` → the sphere is moving INTO the polygon's back face, which is treated as a near-miss (sliding wall, not a stop). The sphere does not stop at the exterior wall. Even in the two-sided (`CullMode.None`) case: the test geometry confirms `pulledIn=0` (the sphere passes through entirely) — either the back-face hit fires the wrong collision path, or `PathClipped` stops iteration before the exterior wall is reached but after the interior had no collision. ### Summary of failing code path | Sub-step range | `sp.CheckCellId` | `GetNearbyObjects` outdoor sweep | Exterior GfxObj found? | Wall stops sphere? | |---|---|---|---|---| | 1 to ~N (sphere inside CellBSP) | Indoor (`0x...01XX`) | Skipped (line 480 early return) | NO | NO (not found) | | ~N+1 to end (sphere outside CellBSP) | Outdoor | Runs | YES | NO (back-face approach + `PathClipped` kills sub-steps) | --- ## Why the PLAYER's collision works correctly The player's sphere (`IsPlayer`, `radius=0.48, height=1.2`) sweeps horizontally across the FLOOR, never exiting the indoor CellBSP volume upward/backward. The player never crosses the exterior wall because the physics engine stops them at interior walls before they could. The issue #98 fix (skipping outdoor GfxObjs from the indoor context) is correct for the player — it prevents the cottage FLOOR polygon from capping the player's HEAD sphere from below when the player is in the cellar directly under the cottage. The CAMERA sweep (`IsViewer`, `radius=0.3`) goes UP and BACK from head-pivot to the desired eye position behind and above the player, exiting through the exterior wall — a trajectory the player never takes. The issue-#98 fix therefore correctly prevents `IsPlayer` head-cap but incorrectly prevents `IsViewer` exterior-wall stop. --- ## Fixture gap note The actual residual cells (`0xA9B40174`/`0xA9B40175`, main-floor cottage) are not in the test fixture set. The issue-#98 cellar fixtures cover `0xA9B4014X` (a different cellar cottage). The RED test uses a **fully synthetic** indoor cell with identity world transform. The mechanism is identical for all indoor cells — the early-return at `ShadowObjectRegistry.cs:480` fires on any cell whose low 16 bits are `≥ 0x0100`. The test name calls out the fixture gap: `IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext`. --- ## Fix design ### Option A — Exempt `IsViewer` from the indoor-primary gate (preferred, retail-faithful) Retail's `CEnvCell::find_collisions` at `acclient_2013_pseudo_c.txt:309560` iterates `this->shadow_object_list`. The issue-#98 fix mirrors this correctly for the PLAYER. But retail's `SmartBox::update_viewer` (0x00453ce0, `:92761-92892`) sweeps the camera using a `CTransition` that calls `find_valid_position` — which internally calls the regular `transitional_insert` → `find_obj_collisions`. For `IsViewer` the retail code reaches the GfxObj shadow list (it doesn't skip it), because the retail `find_obj_collisions` at 308918 iterates `currCell->shadow_object_list` REGARDLESS of cell type — there is no indoor-only restriction. The indoor-primary gate in acdream is an acdream-specific fix for a divergence we introduced (the #98 head-cap). Retail never had that divergence because retail adds outdoor GfxObjs to `add_all_outside_cells` (outdoor cells only) per `acclient_2013_pseudo_c.txt:308751-308769` — indoor EnvCells never have those in their shadow list in the first place. Our fix correctly simulates retail's indoor behavior for `IsPlayer` but over-applies to `IsViewer`. **Change**: in `ShadowObjectRegistry.GetNearbyObjects` at line 480: ```csharp // Before: if ((primaryCellId & 0xFFFFu) >= 0x0100u) return; // After: // Only skip the outdoor sweep for non-viewer sweeps. IsViewer (camera probe) // must reach the exterior building shell GfxObj regardless of cell type. // Retail's update_viewer passes through find_obj_collisions which has no // indoor-cell gate (named-retail acclient_2013_pseudo_c.txt:308918). // The issue-#98 indoor gate is correct only for IsPlayer sweeps. bool isViewer = moverFlags.HasFlag(ObjectInfoState.IsViewer); if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return; ``` This requires threading the `moverFlags` (or just an `isViewer: bool`) through `FindObjCollisions` → `GetNearbyObjects`. Currently `moverFlags` is on `ObjectInfo` which is on `Transition`. Add `moverFlags: ObjectInfo.State` (already available at the `FindObjCollisions` call site in `TransitionTypes.cs`) to the `GetNearbyObjects` signature. **Assertion flip**: when the fix lands, `pulledIn` for the test will be `≈ 0.3m` (sphere stopped at `WallY - radius = 3.7`) → `pulledIn ≥ 0.5m` becomes true → RED test goes GREEN. ### Option B — Use a targeted BSP ray/sphere cast (retail-unfaithful, not recommended) Instead of `ResolveWithTransition`, implement a direct `CastSphereAlongRay` that iterates indoor cell BSP + the landblock-baked GfxObj registry in a single pass. This avoids the indoor-gate conflict but diverges from retail's `update_viewer` path. Not recommended. ### Option C — Register cottage exterior shell in the indoor cell's portal-reachable set Add the cottage GfxObj shadow entries to each indoor cell's `cellScope` (using the indoor cell IDs). This ensures `portalReachableCells` iteration (line 459-470) finds the GfxObj even when the outdoor sweep is gated. However, this requires knowing which indoor cells each landblock-baked GfxObj is adjacent to — non-trivial and not how retail models it. Not recommended. --- ## Retail decomp citations - `SmartBox::update_viewer` @ 0x00453ce0 (`:92761-92892`): sweeps `viewer_sphere` via `CTransition` + `find_valid_position` from `viewer_sought_position` to `viewer`. - `CObjCell::find_obj_collisions` @ `:308918`: iterates `this->shadow_object_list` — no indoor/outdoor cell gate. - `CObjCell::find_cell_list` @ `:308751-308769`: branches indoor/outdoor seed; adds outdoor GfxObjs via `add_all_outside_cells` to outdoor cells' shadow lists only. Indoor cells' shadow lists never receive outdoor GfxObjs — this is retail's built-in separation (acdream has to simulate it with the issue-#98 gate). - `CTransition::init_object(player, 0x5c)` @ `:92864`: `0x5c` = `IsViewer | PathClipped | FreeRotate | PerfectClip`. --- ## Risks 1. **Re-opening issue #98**: if `IsViewer` is exempted from the indoor gate, the cottage floor polygon could again be returned to camera sweeps inside the cellar. However, the cellar camera sweep geometry (player at Z≈91, pivot at Z≈92.5, eye at Z≈95) travels upward and would approach the cottage floor at Z=94 — but the issue-#98 head-cap was specifically a `IsPlayer` / foot-sphere concern. The `IsViewer` sweep doesn't have a head sphere; it's a single 0.3m sphere. A test verifying the cellar camera sweep does NOT get capped by the cottage floor (separate test) would guard this. Low risk. 2. **Performance**: the outdoor radial sweep adds ~24 shadow-list iterations per camera sub-step (9 landblock cells × ~3 entries each). Camera probe runs at 60 Hz × ~14 sub-steps = 840 calls/frame. This is a CPU cost increase, but benchmarked camera overhead at 60 fps is <0.1ms; the impact is negligible. 3. **Other `IsViewer` callers**: confirm no other code passes `IsViewer` with an indoor primary cell where the outdoor GfxObj access would be incorrect. Current scan: only `PhysicsCameraCollisionProbe.SweepEye` passes `IsViewer`. Safe. --- ## Committed apparatus - **RED test**: `tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs` — `SweepEye_IndoorCellExteriorGfxObjWall_NotReachedFromIndoorContext_CurrentlyFails` - Fails with `pulledIn=0.0000 m`, stop Y=5.0000 (full eye distance) - Fix assertion: `pulledIn >= 0.5f` once fix lands - **This doc**: `docs/research/2026-05-31-camera-collision-indoor-diagnosis.md`