docs(render): Phase A8.F — correct camera handoff; retail DOES collide the camera

Correction after the user (who has played retail and observed the camera pull in
at walls) flagged the prior "no camera collision" conclusion. Verified against the
decomp: retail's camera collision lives in SmartBox::update_viewer (0x00453ce0),
NOT CameraManager::UpdateCamera. The earlier research traced only the producer
(UpdateCamera computes the desired/damped eye -> viewer_sought_position) and missed
the consumer (update_viewer), which sweeps a 0.3 m viewer_sphere via
CTransition::find_valid_position from the head-pivot to that eye and uses the
stopped position (fallbacks: AdjustPosition, then snap to player). The player-fade
when super close (CameraSet::UpdateCamera -> SetTranslucencyHierarchical) is a
SEPARATE stage, already ported as RetailChaseCamera.ComputeTranslucency.

Implication: a swept-sphere camera collision is RETAIL-FAITHFUL, not a divergence —
no special sign-off needed, and acdream already owns the Transition swept-sphere
engine. Updated TL;DR, KEY FINDING, the fix section (was "design decision"),
slot-in (collide the damped eye, after RetailChaseCamera.cs:131), open questions,
pickup prompt, and reference index. Memory updated likewise.

Lesson recorded: when the decomp says "no X" but a domain expert says X exists,
trace the CONSUMER of the computed value, not just the producer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 17:51:44 +02:00
parent ce909ad0a8
commit 9757818e95

View file

@ -15,13 +15,16 @@ works** for most cells (the prior "Bug B" framing was wrong); (3) **reframed the
root cause** to the camera, with the user's help; (4) researched the camera root cause** to the camera, with the user's help; (4) researched the camera
system (Opus agent + my verification). system (Opus agent + my verification).
**The decision that blocks the next session** (brainstorm with the user FIRST): **The fix is retail-faithful (CORRECTED 2026-05-29 PM):** retail's camera **does**
retail's camera does **NOT** collide with walls and pull the eye in — it lets avoid walls — `SmartBox::update_viewer` (0x00453ce0) sweeps a 0.3 m collision
the eye clip and **fades the player to translucent** instead (a mechanism sphere (`viewer_sphere`) from the player head-pivot to the desired eye via
acdream already ports). So "make the camera move closer on a wall hit" is a `CTransition::find_valid_position` and uses the stopped position; it **also** fades
**deliberate divergence from retail**, not a faithful port. The next session the player to translucent when that eye is super close (`CameraSet::UpdateCamera`).
must choose an approach (spring arm vs. decouple visibility from the eye) and Both are retail-faithful, and acdream already owns the `Transition` swept-sphere
get user sign-off before coding, per the CLAUDE.md no-redesign rule. engine to port the collision (the fade is already ported). So the next session
implements a faithful swept-sphere camera collision — **no divergence, no sign-off
needed.** (An earlier pass in this doc wrongly said "retail has no camera
collision"; that was a research error — see the KEY FINDING section.)
--- ---
@ -124,42 +127,48 @@ player stood inside.** The same portal projected fine when the BFS reached
--- ---
## KEY FINDING: retail does NOT collide the camera — it fades the player ## KEY FINDING: retail DOES collide the camera (swept sphere) — plus the close-up fade
This **reframes the intended fix** and needs user attention. Retail avoids walls via a **swept-sphere spring arm**, then fades the player when
very close. Three stages — an earlier research pass conflated stages 1+3 and missed
stage 2, producing a wrong "no collision" conclusion that this section corrects.
- **Retail's camera does no eye-vs-wall collision.** `CameraManager::UpdateCamera` - **Stage 1 — compute the *desired* eye (no collision here).**
(0x00456660, decomp `:95505-95953`) computes the eye as `CameraManager::UpdateCamera` (0x00456660, decomp `:95505-95953`) computes
`pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`), and eye = `pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`),
writes it straight to the render viewer via and stores it as `SmartBox::viewer_sought_position` (a `Position`,
`SmartBox::PlayerPhysicsUpdatedCallback` (0x00452d60, decomp `:91842`) — no `acclient.h:35196`). This *producer* does no raycast — which is ALL the earlier
raycast, no sphere sweep, no BSP query in between. The `CameraManager` struct pass read, hence its wrong conclusion.
(`acclient.h:35238-35263`) has **no** collision-radius / clipped-distance / - **Stage 2 — collide the desired eye (the camera pull-in the user observes).**
obstruction field. `SmartBox::update_viewer` (0x00453ce0, decomp `:92761-92892`) — the *consumer* of
- **Retail's anti-clip mechanism is to fade the *player***. `viewer_sought_position` — runs it through a swept-sphere `CTransition`:
`makeTransition``init_object(player, 0x5c)``init_sphere(1, &viewer_sphere, 1f)`
`init_path(cell, pivot, sought)`**`find_valid_position`** → on success
`set_viewer(sphere_path.curr_pos)` (the STOPPED position). Fallbacks:
`CPhysicsObj::AdjustPosition`, then snap to the player's position. `viewer_sphere`
is a global `CSphere`, **radius 0.3 m**, center (0,0,0) (decomp `:93308-93314`,
`:1144645`).
- **Stage 3 — fade the player when the (collided) eye is super close.**
`CameraSet::UpdateCamera` (0x00458ae0) calls `CameraSet::UpdateCamera` (0x00458ae0) calls
`CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679`, `:97698`, `CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679/97698/97725/97737`)
`:97725`, `:97737`) — opaque at ≥0.45 m camera-to-pivot, fully transparent at — opaque at ≥0.45 m, transparent at ≤0.20 m. **acdream already ports this** as
≤0.20 m. **acdream already ports this** as `RetailChaseCamera.ComputeTranslucency` `RetailChaseCamera.ComputeTranslucency` (`RetailChaseCamera.cs:367-376`).
(`RetailChaseCamera.cs:367-376`, far=0.45 / near=0.20).
**Verification (mine, this session):** I confirmed in the decomp that **Verification (mine, this session):** confirmed `viewer` / `viewer_sought_position`
`SetTranslucencyHierarchical` is called from `CameraSet::UpdateCamera` (the four are `Position` structs (`acclient.h:35193/35196`); read `SmartBox::update_viewer` in
lines above), that `CameraManager::UpdateCamera`'s sole caller writes the eye full and confirmed the `CTransition` swept-sphere from pivot→sought + the
with no intervening clip, that `RetailChaseCamera.cs` has zero `set_viewer(sphere_path.curr_pos)` use of the collided position; confirmed
collision/physics references (only `PlayerTranslucency`), and that `camPos` is `viewer_sphere.radius = 0.300000012f` (`:93314`); confirmed the player-fade call
the eye. (I relied on the Opus agent's full read of `CameraManager::UpdateCamera` sites in `CameraSet::UpdateCamera`. The earlier "no collision" finding was **wrong**
for "zero collision calls in the whole function body"; I spot-checked the entry, it traced the *producer* (`CameraManager::UpdateCamera`) but not the *consumer*
the damping tail, the caller, and the struct — all consistent.) (`update_viewer`), where the collision lives. **Caught by the user**, who has played
retail and observed the camera pulling in at walls. (Lesson worth keeping: when the
decomp says "no X" but a domain expert says X exists, trace the consumer of the
computed value, not just the producer.)
**Implication:** "make the camera move closer when it hits a wall" is **not what **Implication:** a swept-sphere camera collision is the **retail-faithful** fix, NOT a
retail does**. A spring arm would be a *modern divergence* from the retail divergence. No special divergence sign-off is needed; this is a straight port, and
behavior acdream faithfully ported. Per CLAUDE.md ("do not replace working acdream already owns the swept-sphere machinery.
retail-faithful logic with a modern redesign without explicit user approval"),
the next session must get the user's sign-off on the approach before coding —
*but note* that here "retail-faithful" literally means "no collision," which is
the behavior that produces the A8.F flap. So some divergence or decoupling is
unavoidable to fix the flap.
--- ---
@ -222,51 +231,51 @@ A camera "spring-arm" sweep can reuse the player's existing swept-sphere engine
(`PlayerMovementController.cs:1107-1108`). A camera probe wants a **small** (`PlayerMovementController.cs:1107-1108`). A camera probe wants a **small**
radius (e.g. `PhysicsGlobals.DummySphereRadius = 0.1`), `height 0` (single radius (e.g. `PhysicsGlobals.DummySphereRadius = 0.1`), `height 0` (single
sphere), `isOnGround:false`, `body:null` (no contact-plane persistence). sphere), `isOnGround:false`, `body:null` (no contact-plane persistence).
- **Slot-in point:** clamp `targetEye` toward `pivotWorld` **between - **Slot-in point:** retail collides *after* damping (`CameraManager::UpdateCamera`
`RetailChaseCamera.cs:117` (target) and `:131` (damp)** so the damping eases damps → `viewer_sought_position`; `SmartBox::update_viewer` then collides it). So in
toward the clamped point. This requires injecting a physics/collision probe acdream, sweep from `pivotWorld` (`RetailChaseCamera.cs:113`) to the **damped** eye
into `RetailChaseCamera` (currently GL-free, no engine ref); App→Core is the and replace the published eye — i.e. **after `:131` (damp), before `:136`
allowed dependency direction, so inject `PhysicsEngine` or a narrow (publish)**. This requires injecting a collision probe into `RetailChaseCamera`
`ICameraCollisionProbe` interface. (currently GL-free, no engine ref); App→Core is the allowed dependency direction, so
inject `PhysicsEngine` or a narrow `ICameraCollisionProbe` interface. Check whether
acdream has a `find_valid_position` equivalent (retail uses that, not the movement
sweep) or whether `FindTransitionalPosition` must be adapted.
--- ---
## The design decision (MUST brainstorm with the user before coding) ## The fix: a retail-faithful swept-sphere camera collision (no divergence)
Fixing the flap requires the eye (or the point feeding visibility) to stop Port retail's stage-2 collision (stage 1 = the damped desired eye, and stage 3 =
leaving the room. Three shapes, none purely retail-faithful: the close-up fade, are both already in acdream):
- **Option A — modern spring arm (leading candidate; matches user's instinct).** 1. Keep the existing damped desired-eye computation (`RetailChaseCamera`).
Sweep pivot→targetEye, stop the eye at the first wall hit. Fixes *both* the 2. **Add the swept-sphere collision** (the missing piece): sweep a 0.3 m sphere from
render (no see-through) *and* the A8.F visibility (eye stays inside → stable the head-pivot to the *damped* eye via acdream's `Transition` swept-sphere, and
camera-cell + side-tests). **Diverges from retail** (retail lets the eye clip + publish the **stopped** position as the camera eye. Mirror retail's fallbacks
fades the player). The already-ported translucency fade still applies and will (`AdjustPosition`, then snap to the player) when no valid spot is found.
fade *less* (eye stays farther from the player when not clipping). Needs user 3. Verify the already-ported player fade (`ComputeTranslucency`) still triggers once
sign-off for the divergence. the eye stops clipping (it will fade *less* — the eye now stays out of walls).
- **Option B — decouple visibility from the rendered eye.** Leave the rendered
eye retail-faithful (clips + fades), but feed `ComputeVisibility` /
`PointInCell` / `CameraOnInteriorSide` a *clamped* point. Fixes the flap, but
the render still shows through walls when the eye clips (you'd see exterior
through the wall). Less satisfying visually.
- **Option C — make A8.F robust to an outside eye.** When the eye is outside but
the player is inside, use the *player's* cell for the camera-cell + side tests.
Similar outcome to B (flap fixed, render still clips).
**Recommendation to put to the user:** Option A. It's the only one that fixes This fixes both the render (eye no longer behind walls) **and** the A8.F visibility
both the render and the visibility, it matches the user's stated expectation, (stable camera-cell + side-tests, since the eye stays in valid space). Because it
and acdream already owns the collision machinery to do it. The cost is an matches retail, **no divergence sign-off is needed**. Per roadmap discipline, file it
explicit, documented divergence from retail's no-collision design. Confirm with as a phase before coding (a short brainstorm to confirm scope is fine, but the
the user, then (per roadmap discipline) file it as a phase before coding. behavior question is settled: this is what retail does).
--- ---
## Open design questions for the implementation session ## Open design questions for the implementation session
1. **Approval for the retail divergence** (Option A spring arm) — the gating item. 1. **`find_valid_position` equivalent:** retail uses `CTransition::find_valid_position`
2. **Sweep radius:** small (≈0.10.2 m). Too small → eye hugs walls / near-plane (place/validate a sphere, not the movement sweep). Confirm acdream has an
clips; too large → eye yanks in aggressively in tight rooms. equivalent, or adapt `Transition.FindTransitionalPosition` / a `BSPQuery`-level
3. **Which primitive:** purpose-built `BSPQuery.FindCollisions` ray/sphere cast cast. (Behavior question is settled — this is the API question.)
(recommended) vs. full `ResolveWithTransition` (wrong semantics for a camera). 2. **Sweep radius:** retail's `viewer_sphere` is **0.3 m** — start there to stay
faithful; tune only if the eye hugs walls / near-plane clips in tight rooms.
3. **Which primitive:** retail uses the full `CTransition` (`init_object` on the
player + `find_valid_position`). A purpose-built `BSPQuery.FindCollisions`
ray/sphere cast may be a leaner fit but diverges from retail's exact call —
prefer matching retail's `Transition` path first.
4. **Indoor vs outdoor geometry:** indoor walls are in `CellPhysics.BSP` (per 4. **Indoor vs outdoor geometry:** indoor walls are in `CellPhysics.BSP` (per
cell). **Cottage exterior shells live in landblock-baked GfxObjs**, not cells cell). **Cottage exterior shells live in landblock-baked GfxObjs**, not cells
(cf. issue #98/#101 — cottage floors/walls in GfxObj `0x01000A2B` etc.). A (cf. issue #98/#101 — cottage floors/walls in GfxObj `0x01000A2B` etc.). A
@ -320,19 +329,21 @@ flap-by-frame: log when `PointInCell(eye)` disagrees with the player's cell.
> Read `docs/research/2026-05-29-a8f-camera-collision-handoff.md`. The A8.F flap / > Read `docs/research/2026-05-29-a8f-camera-collision-handoff.md`. The A8.F flap /
> missing-walls are caused by the 3rd-person camera EYE clipping through walls and > missing-walls are caused by the 3rd-person camera EYE clipping through walls and
> destabilizing the camera-cell + portal side-tests (the eye drives > destabilizing the camera-cell + portal side-tests (the eye drives `PointInCell` /
> `PointInCell` / `CameraOnInteriorSide` via `camPos` at GameWindow.cs:7271). > `CameraOnInteriorSide` via `camPos` at GameWindow.cs:7271). Bug A (cellar terrain
> Bug A (cellar terrain flood) is already fixed + committed (`9417d3c`); the > flood) is already fixed + committed (`9417d3c`); the recursive-clip builder works.
> recursive-clip builder works. **Before coding, brainstorm the fix with the user** > The fix is **retail-faithful** (verified against the decomp): port retail's
> (`superpowers:brainstorming`): retail does NOT collide the camera — it fades the > swept-sphere camera collision — `SmartBox::update_viewer` (0x00453ce0) sweeps a
> player (`SetTranslucencyHierarchical`, already ported as > 0.3 m `viewer_sphere` via `CTransition::find_valid_position` from the head-pivot to
> `RetailChaseCamera.ComputeTranslucency`), so a "spring arm that pulls the eye in" > the (damped) eye and uses the **stopped** position; the close-up player fade is
> is a deliberate divergence needing sign-off. Leading option: a modern spring-arm > already ported (`RetailChaseCamera.ComputeTranslucency`). acdream already owns the
> camera collision (Option A) sweeping pivot→targetEye via a small-radius > `Transition` swept-sphere engine. Slot the sweep in after `RetailChaseCamera.cs:131`
> `BSPQuery.FindCollisions` against `CellPhysics.BSP`, clamping `targetEye` between > (damp), before `:136` (publish): sweep `pivotWorld``_dampedEye`, radius 0.3 m, with
> `RetailChaseCamera.cs:117` and `:131`. Watch the open questions (sweep radius, > retail's fallbacks (`AdjustPosition`, then snap to player). Check whether acdream has
> outdoor shells live in GfxObjs not cells, 1st-person, the existing fade). File a > a `find_valid_position` equivalent or must adapt `FindTransitionalPosition`. Watch
> roadmap phase once the approach is agreed. > the open questions (outdoor building shells live in GfxObjs not cells; 1st-person;
> the fade interaction). A short brainstorm to confirm scope is fine; then file a
> roadmap phase. No divergence sign-off needed — this is what retail does.
--- ---
@ -359,13 +370,23 @@ flap-by-frame: log when `PointInCell(eye)` disagrees with the player's cell.
out-of-scope `:454-457`). out-of-scope `:454-457`).
**Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): **Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `CameraManager::UpdateCamera` @ 0x00456660 (`:95505-95953`) — NO collision; - `CameraManager::UpdateCamera` @ 0x00456660 (`:95505-95953`) — computes the
damping `:95866-95923`; velocity ring `:95644-95677`. *desired* eye (no collision here); damping `:95866-95923`; velocity ring
`:95644-95677`; result stored as `viewer_sought_position`.
- `SmartBox::update_viewer` @ 0x00453ce0 (`:92761-92892`) — **THE camera collision**:
swept `viewer_sphere` via `CTransition` (`init_sphere`/`init_path`/`find_valid_position`)
pivot→sought; `set_viewer(sphere_path.curr_pos)`; fallbacks `AdjustPosition` then snap
to player.
- `viewer_sphere` — global `CSphere`, radius 0.3 m, center (0,0,0) (`:93308-93314`;
decl `:1144645`).
- `CameraSet::UpdateCamera` @ 0x00458AE0 (`:97643-97742`) — player-fade - `CameraSet::UpdateCamera` @ 0x00458AE0 (`:97643-97742`) — player-fade
`SetTranslucencyHierarchical` `:97679/97698/97725/97737`. `SetTranslucencyHierarchical` `:97679/97698/97725/97737`.
- `CameraSet::FilterMouseInput` @ 0x00457530 (`:96250-96279`). - `CameraSet::FilterMouseInput` @ 0x00457530 (`:96250-96279`).
- `CameraSet::SetDefaultOffsets` @ 0x00458F80 (`:97916-97967`) — pivot (0,0,1.5), - `CameraSet::SetDefaultOffsets` @ 0x00458F80 (`:97916-97967`) — pivot (0,0,1.5),
viewer (0,2.5,0.75). viewer (0,2.5,0.75).
- `SmartBox::PlayerPhysicsUpdatedCallback` @ 0x00452d60 (`:91842`) — sole - `SmartBox::PlayerPhysicsUpdatedCallback` @ 0x00452d60 (`:91842`) — writes the
UpdateCamera caller; eye → render with no clip. damped desired eye to `viewer_sought_position` (collision happens later, in
- `CameraManager` struct `acclient.h:35238-35263` (no collision fields). `update_viewer`).
- `CameraManager` struct `acclient.h:35238-35263` — no collision fields (the
collision lives in `update_viewer`, not `CameraManager`); `viewer` /
`viewer_sought_position` are `Position`s (`acclient.h:35193/35196`).