diff --git a/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md b/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md new file mode 100644 index 00000000..6bd8351f --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md @@ -0,0 +1,114 @@ +# 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.Ok` → `AdjustPosition(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).