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>
This commit is contained in:
Erik 2026-06-05 10:44:04 +02:00
parent 2c7948a9f1
commit 0ffc3f5be9

View file

@ -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).