acdream/docs/research/2026-05-31-camera-collision-indoor-diagnosis.md
Erik 3066460370 diag(render): camera-collision indoor non-engagement — RED test + diagnosis
Root 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 prevents the player's head sphere from being capped by the cottage floor
also prevents the IsViewer camera sweep from finding the exterior building shell.
Result: camera passes through exterior walls unimpeded, driving the residual
transparent-walls symptom after the U.4c flap fix.

Evidence: live capture shows eyeInRoot=n ~90% of frames, eye-player distance
3.43m (full chase, no pull-in). RED test deterministically reproduces: synthetic
indoor cell (0xA9B40175) + exterior GfxObj registered at cellScope=0; probe
SweepEye returns pulledIn=0.0000m (full eye distance Y=5.0, wall at Y=4.0).

Fix design: exempt IsViewer from the indoor-primary early-return gate in
GetNearbyObjects — retail's find_obj_collisions (named-retail :308918) has no
indoor/outdoor cell gate; the acdream fix is correct only for IsPlayer.

Apparatus committed:
- tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs (RED test)
- docs/research/2026-05-31-camera-collision-indoor-diagnosis.md (findings + design)
- PhysicsCameraCollisionProbe.cs [flap-sweep] diagnostic retained (U.4c spike)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:02:37 +02:00

172 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`