acdream/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md
Erik 0ffc3f5be9 docs(A): spec — verbatim SmartBox::update_viewer completion (Render Residual A)
Live ACDREAM_PROBE_FLAP capture (Holtburg cottage/cellar) proved the V1 camera
spring-arm already contains the eye (eyeInRoot=Y 99.75%, viewerCell never 0,
indoor collide 97.6% in 0174). The dominant inside-cottage bluish void is the
render-sealing residual C (DrawPortal), NOT the camera.

This spec scopes the FAITHFUL completion of Residual A: port the two missing
update_viewer pieces verbatim — the indoor start-cell seated at the pivot via
CPhysicsObj::AdjustPosition (pc:280009) → CEnvCell::find_visible_child_cell
(pc:311397), plus the two AdjustPosition/snap-to-player fallbacks — and land
FindVisibleChildCell (which residual C also needs).

Faithful layering (mirrors retail SmartBox→CPhysicsObj): primitives in Core
(PhysicsEngine.AdjustPosition + CellTransit.FindVisibleChildCell + ResolveResult.Ok),
orchestration in App PhysicsCameraCollisionProbe.SweepEye. Deterministic crux test
(start-cell resolution) in Core.Tests with the cottage fixtures; SweepEye glue in
App.Tests. Visible payoff is narrow (the cellar-corner, point 3); the cottage-room
void stays for residual C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:44:04 +02:00

7.2 KiB

Render Residual A — Camera collision: verbatim SmartBox::update_viewer completion

Date: 2026-06-05 · Phase: M1.5 render residual A · Branch: claude/thirsty-goldberg-51bb9b

1. The finding (why this is a faithfulness completion, not a visible-bug fix)

A live ACDREAM_PROBE_FLAP capture this session (Holtburg cottage + cellar) proved the V1 camera spring-arm already works:

Metric Result Meaning
[flap-cam] eyeInRoot 186,349 Y / 470 n eye inside the player's cell 99.75%
viewerCell == 0 (eye in the void) 0 of ~318k frames the sweep never lands the eye in invalid space
indoor collide rate, cell 0174 97.6% spring-arm engages cell-BSP walls hard

The dominant inside-cottage bluish void (seeing other buildings / particles / NPCs through the walls) is the render-sealing residual C (PView::DrawPortal), NOT the camera — the eye is already in a valid cell, yet the renderer draws the GL clear colour past unsealed geometry. User-confirmed.

This task therefore completes Residual A as a faithful verbatim port and lands FindVisibleChildCell, which C also needs. Its one shot at a visible win is the cellar-corner (user point 3): there the player's feet are in the cellar but the pivot/head is up at cottage-floor level, so the pivot-seated start cell genuinely differs from the feet cell — the only configuration where the faithful start-cell changes the sweep's outcome.

2. Retail target (the oracle — port verbatim)

  • SmartBox::update_viewer 0x453ce0 pc:92761 — start-cell → sweep → fallbacks.
  • CPhysicsObj::AdjustPosition 0x511d80 pc:280009 — indoor → find_visible_child_cell; outdoor → LandDefs::adjust_to_outside.
  • CEnvCell::find_visible_child_cell 0x52dc50 pc:311397 — this/portals/stab_list point_in_cell.
  • CEnvCell::GetVisible 0x52dc10 pc:311378 — cell-graph resolve.
  • find_valid_position pc:273890 = return find_transitional_position(this) pc:273613 — the sweep is already faithful; do NOT re-port it.
  • init_object(player, 0x5c) = IsViewer | PathClipped | FreeRotate | PerfectClip; init_sphere(1, viewer_sphere, 1.0) (ONE sphere, r=0.3 pc:93314).

Decoded update_viewer (indoor branch):

pivot = head frame · pivot_offset
if player indoor (objcell_id >= 0x100):
    if AdjustPosition(pivot, viewer_sphere) -> cell_1: start = cell_1     # seat start at the PIVOT
    else: start = player->cell                                            #   fallback to feet cell
else: start = player->cell
sweep viewer_sphere pivot -> sought_eye, startCell=start, flags=0x5c      # PathClipped = hard stop
if find_valid_position:  set_viewer(curr_pos);  viewer_cell = curr_cell;  return
if AdjustPosition(sought_eye, viewer_sphere) -> var_170:                  # FALLBACK 1
    set_viewer(sought_eye);  viewer_cell = var_170;  return
set_viewer(player->m_position);  viewer_cell = null                       # FALLBACK 2: snap to player

3. Design — faithful layering (Core primitives ← App orchestration)

Retail's update_viewer is a SmartBox (camera) method that calls down into physics (CPhysicsObj::AdjustPosition, CTransition). acdream mirrors that split exactly:

Core (AcDream.Core.Physics) — the physics primitives

  • CellTransit.FindVisibleChildCell(IDataCache, uint startCellId, Vector3 worldPoint, bool useStabList) — sibling of the existing FindCellList (retail find_cell_list); both are cell-membership resolvers. Port of find_visible_child_cell:
    start = cg.GetVisible(startCellId);            if start == null: return 0
    if PointInsideCellBsp(start, toLocal(start, worldPoint)): return start.Id   # point_in_cell
    ids = useStabList ? start.VisibleCellIds : start.Portals.Select(OtherCellId)
    foreach id in ids:
        c = cg.GetVisible(id)
        if c != null && PointInsideCellBsp(c, toLocal(c, worldPoint)): return c.Id
    return 0
    
    Each candidate transforms worldPoint through its OWN InverseWorldTransform before the BSP test (matches CellTransit.cs:520).
  • PhysicsEngine.AdjustPosition(uint seedCellId, Vector3 worldPoint) -> (uint cellId, bool found) — port of CPhysicsObj::AdjustPosition, indoor branch: FindVisibleChildCell(seed, point, useStabList:true). Outdoor branch (seedLow < 0x100) reuses the existing terrain-grid resolution. Retail's seen_outside -> adjust_to_outside sub-fallback is deferred (not on the cottage/cellar path; adding it unverified would be guessing — see §6).
  • ResolveResult.Ok (new bool, default true) — surfaces the ok already computed at PhysicsEngine.cs:718 (FindTransitionalPosition), the faithful map of find_valid_position != 0. Default-true → existing callers unaffected.

App (AcDream.App.Rendering) — the camera orchestration

  • PhysicsCameraCollisionProbe.SweepEye gains the verbatim update_viewer body:
    1. indoor (cellId >= 0x100) → start = AdjustPosition(cellId, pivot) else cellId;
    2. sweep pivot → desiredEye from start (existing ResolveWithTransition, viewer flags);
    3. r.Ok → return (swept eye, r.CellId);
    4. !r.OkAdjustPosition(cellId, desiredEye) → return (desiredEye, thatCell) (fallback 1);
    5. else → return (playerPos, 0) (fallback 2, snap to player). SweepEye needs the player world position for fallback 2 → add a Vector3 playerPos parameter to ICameraCollisionProbe.SweepEye (passed by RetailChaseCamera.Update).

4. Tests

  • Core.Tests (CellarLipWedgeTests pattern, RED→GREEN): load cottage fixtures 0171/0174/0175. Seed the captured corner frame — player (153.55, 9.32, 93.11) in 0174, pivot (153.55, 9.32, 94.61). Assert AdjustPosition(0174, pivot) / FindVisibleChildCell(0174, pivot, true) resolves the pivot to its actual (floor-level) cell, not the cellar. <200 ms, iterable.
  • App.Tests: focused SweepEye orchestration test — start-cell seated, fallback-2 snaps to playerPos when the sweep fails. Fixtures loaded by the SolutionRoot() path-walk.

5. Validation / visual gate

  • Core baseline 1317 pass / 4 fail (documented) / 1 skip maintained (+ the new tests); App green.
  • Visual gate: stand in the cottage cellar, press into a corner, rotate — the cellar-corner void should improve (point 3). Inside-looking-out must be unregressed. The cottage-room bluish void is NOT in scope (Residual C).

6. No-shortcuts rules (per master plan §4)

  1. Every ported behaviour cites its decomp anchor (address + pc:line) in a comment.
  2. No suppression flags / grace periods / if (problem) return guards. The two fallbacks are retail's own; fallback 2 (snap-to-player) is the faithful "never leave the eye invalid", not a band-aid.
  3. The seen_outside → adjust_to_outside sub-fallback inside AdjustPosition is deferred, not stubbed — documented as out-of-path; revisit if a capture shows the camera needs it.
  4. Do NOT re-add a CurrCell write inside ResolveWithTransition/ResolveCellId (the blue-hole clobber — CurrCell is player-only via UpdatePlayerCurrCell).
  5. Do NOT conflate A (eye containment) with C (DrawPortal outside-looking-in).