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

13 KiB
Raw Blame History

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 — TransitionalInsertFindObjCollisions 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:
    engine.ShadowObjects.GetNearbyObjects(
        currPos, queryRadius,
        worldOffsetX, worldOffsetY, landblockId,
        nearbyObjs,
        portalReachableCells,
        primaryCellId: sp.CheckCellId);   // ← indoor cell
    
  3. ShadowObjectRegistry.GetNearbyObjects (line 480):
    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:

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_insertfind_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:

// 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 FindObjCollisionsGetNearbyObjects. 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.

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.csSweepEye_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