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
system (Opus agent + my verification).
**The decision that blocks the next session** (brainstorm with the user FIRST):
retail's camera does **NOT** collide with walls and pull the eye in — it lets
the eye clip and **fades the player to translucent** instead (a mechanism
acdream already ports). So "make the camera move closer on a wall hit" is a
**deliberate divergence from retail**, not a faithful port. The next session
must choose an approach (spring arm vs. decouple visibility from the eye) and
get user sign-off before coding, per the CLAUDE.md no-redesign rule.
**The fix is retail-faithful (CORRECTED 2026-05-29 PM):** retail's camera **does**
avoid walls — `SmartBox::update_viewer` (0x00453ce0) sweeps a 0.3 m collision
sphere (`viewer_sphere`) from the player head-pivot to the desired eye via
`CTransition::find_valid_position` and uses the stopped position; it **also** fades
the player to translucent when that eye is super close (`CameraSet::UpdateCamera`).
Both are retail-faithful, and acdream already owns the `Transition` swept-sphere
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`
(0x00456660, decomp `:95505-95953`) computes the eye as
`pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`), and
writes it straight to the render viewer via
`SmartBox::PlayerPhysicsUpdatedCallback` (0x00452d60, decomp `:91842`) — no
raycast, no sphere sweep, no BSP query in between. The `CameraManager` struct
(`acclient.h:35238-35263`) has **no** collision-radius / clipped-distance /
obstruction field.
- **Retail's anti-clip mechanism is to fade the *player***.
- **Stage 1 — compute the *desired* eye (no collision here).**
`CameraManager::UpdateCamera` (0x00456660, decomp `:95505-95953`) computes
eye = `pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`),
and stores it as `SmartBox::viewer_sought_position` (a `Position`,
`acclient.h:35196`). This *producer* does no raycast — which is ALL the earlier
pass read, hence its wrong conclusion.
- **Stage 2 — collide the desired eye (the camera pull-in the user observes).**
`SmartBox::update_viewer` (0x00453ce0, decomp `:92761-92892`) — the *consumer* of
`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
`CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679`, `:97698`,
`:97725`, `:97737`) — opaque at ≥0.45 m camera-to-pivot, fully transparent at
≤0.20 m. **acdream already ports this** as `RetailChaseCamera.ComputeTranslucency`
(`RetailChaseCamera.cs:367-376`, far=0.45 / near=0.20).
`CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679/97698/97725/97737`)
— opaque at ≥0.45 m, transparent at ≤0.20 m. **acdream already ports this** as
`RetailChaseCamera.ComputeTranslucency` (`RetailChaseCamera.cs:367-376`).
**Verification (mine, this session):** I confirmed in the decomp that
`SetTranslucencyHierarchical` is called from `CameraSet::UpdateCamera` (the four
lines above), that `CameraManager::UpdateCamera`'s sole caller writes the eye
with no intervening clip, that `RetailChaseCamera.cs` has zero
collision/physics references (only `PlayerTranslucency`), and that `camPos` is
the eye. (I relied on the Opus agent's full read of `CameraManager::UpdateCamera`
for "zero collision calls in the whole function body"; I spot-checked the entry,
the damping tail, the caller, and the struct — all consistent.)
**Verification (mine, this session):** confirmed `viewer` / `viewer_sought_position`
are `Position` structs (`acclient.h:35193/35196`); read `SmartBox::update_viewer` in
full and confirmed the `CTransition` swept-sphere from pivot→sought + the
`set_viewer(sphere_path.curr_pos)` use of the collided position; confirmed
`viewer_sphere.radius = 0.300000012f` (`:93314`); confirmed the player-fade call
sites in `CameraSet::UpdateCamera`. The earlier "no collision" finding was **wrong**
it traced the *producer* (`CameraManager::UpdateCamera`) but not the *consumer*
(`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
retail does**. A spring arm would be a *modern divergence* from the retail
behavior acdream faithfully ported. Per CLAUDE.md ("do not replace working
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.
**Implication:** a swept-sphere camera collision is the **retail-faithful** fix, NOT a
divergence. No special divergence sign-off is needed; this is a straight port, and
acdream already owns the swept-sphere machinery.
---
@ -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**
radius (e.g. `PhysicsGlobals.DummySphereRadius = 0.1`), `height 0` (single
sphere), `isOnGround:false`, `body:null` (no contact-plane persistence).
- **Slot-in point:** clamp `targetEye` toward `pivotWorld` **between
`RetailChaseCamera.cs:117` (target) and `:131` (damp)** so the damping eases
toward the clamped point. This requires injecting a physics/collision probe
into `RetailChaseCamera` (currently GL-free, no engine ref); App→Core is the
allowed dependency direction, so inject `PhysicsEngine` or a narrow
`ICameraCollisionProbe` interface.
- **Slot-in point:** retail collides *after* damping (`CameraManager::UpdateCamera`
damps → `viewer_sought_position`; `SmartBox::update_viewer` then collides it). So in
acdream, sweep from `pivotWorld` (`RetailChaseCamera.cs:113`) to the **damped** eye
and replace the published eye — i.e. **after `:131` (damp), before `:136`
(publish)**. This requires injecting a collision probe into `RetailChaseCamera`
(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
leaving the room. Three shapes, none purely retail-faithful:
Port retail's stage-2 collision (stage 1 = the damped desired eye, and stage 3 =
the close-up fade, are both already in acdream):
- **Option A — modern spring arm (leading candidate; matches user's instinct).**
Sweep pivot→targetEye, stop the eye at the first wall hit. Fixes *both* the
render (no see-through) *and* the A8.F visibility (eye stays inside → stable
camera-cell + side-tests). **Diverges from retail** (retail lets the eye clip +
fades the player). The already-ported translucency fade still applies and will
fade *less* (eye stays farther from the player when not clipping). Needs user
sign-off for the divergence.
- **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).
1. Keep the existing damped desired-eye computation (`RetailChaseCamera`).
2. **Add the swept-sphere collision** (the missing piece): sweep a 0.3 m sphere from
the head-pivot to the *damped* eye via acdream's `Transition` swept-sphere, and
publish the **stopped** position as the camera eye. Mirror retail's fallbacks
(`AdjustPosition`, then snap to the player) when no valid spot is found.
3. Verify the already-ported player fade (`ComputeTranslucency`) still triggers once
the eye stops clipping (it will fade *less* — the eye now stays out of walls).
**Recommendation to put to the user:** Option A. It's the only one that fixes
both the render and the visibility, it matches the user's stated expectation,
and acdream already owns the collision machinery to do it. The cost is an
explicit, documented divergence from retail's no-collision design. Confirm with
the user, then (per roadmap discipline) file it as a phase before coding.
This fixes both the render (eye no longer behind walls) **and** the A8.F visibility
(stable camera-cell + side-tests, since the eye stays in valid space). Because it
matches retail, **no divergence sign-off is needed**. Per roadmap discipline, file it
as a phase before coding (a short brainstorm to confirm scope is fine, but the
behavior question is settled: this is what retail does).
---
## Open design questions for the implementation session
1. **Approval for the retail divergence** (Option A spring arm) — the gating item.
2. **Sweep radius:** small (≈0.10.2 m). Too small → eye hugs walls / near-plane
clips; too large → eye yanks in aggressively in tight rooms.
3. **Which primitive:** purpose-built `BSPQuery.FindCollisions` ray/sphere cast
(recommended) vs. full `ResolveWithTransition` (wrong semantics for a camera).
1. **`find_valid_position` equivalent:** retail uses `CTransition::find_valid_position`
(place/validate a sphere, not the movement sweep). Confirm acdream has an
equivalent, or adapt `Transition.FindTransitionalPosition` / a `BSPQuery`-level
cast. (Behavior question is settled — this is the API question.)
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
cell). **Cottage exterior shells live in landblock-baked GfxObjs**, not cells
(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 /
> missing-walls are caused by the 3rd-person camera EYE clipping through walls and
> destabilizing the camera-cell + portal side-tests (the eye drives
> `PointInCell` / `CameraOnInteriorSide` via `camPos` at GameWindow.cs:7271).
> Bug A (cellar terrain flood) is already fixed + committed (`9417d3c`); the
> recursive-clip builder works. **Before coding, brainstorm the fix with the user**
> (`superpowers:brainstorming`): retail does NOT collide the camera — it fades the
> player (`SetTranslucencyHierarchical`, already ported as
> `RetailChaseCamera.ComputeTranslucency`), so a "spring arm that pulls the eye in"
> is a deliberate divergence needing sign-off. Leading option: a modern spring-arm
> camera collision (Option A) sweeping pivot→targetEye via a small-radius
> `BSPQuery.FindCollisions` against `CellPhysics.BSP`, clamping `targetEye` between
> `RetailChaseCamera.cs:117` and `:131`. Watch the open questions (sweep radius,
> outdoor shells live in GfxObjs not cells, 1st-person, the existing fade). File a
> roadmap phase once the approach is agreed.
> destabilizing the camera-cell + portal side-tests (the eye drives `PointInCell` /
> `CameraOnInteriorSide` via `camPos` at GameWindow.cs:7271). Bug A (cellar terrain
> flood) is already fixed + committed (`9417d3c`); the recursive-clip builder works.
> The fix is **retail-faithful** (verified against the decomp): port retail's
> swept-sphere camera collision — `SmartBox::update_viewer` (0x00453ce0) sweeps a
> 0.3 m `viewer_sphere` via `CTransition::find_valid_position` from the head-pivot to
> the (damped) eye and uses the **stopped** position; the close-up player fade is
> already ported (`RetailChaseCamera.ComputeTranslucency`). acdream already owns the
> `Transition` swept-sphere engine. Slot the sweep in after `RetailChaseCamera.cs:131`
> (damp), before `:136` (publish): sweep `pivotWorld``_dampedEye`, radius 0.3 m, with
> retail's fallbacks (`AdjustPosition`, then snap to player). Check whether acdream has
> a `find_valid_position` equivalent or must adapt `FindTransitionalPosition`. Watch
> 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`).
**Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `CameraManager::UpdateCamera` @ 0x00456660 (`:95505-95953`) — NO collision;
damping `:95866-95923`; velocity ring `:95644-95677`.
- `CameraManager::UpdateCamera` @ 0x00456660 (`:95505-95953`) — computes the
*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
`SetTranslucencyHierarchical` `:97679/97698/97725/97737`.
- `CameraSet::FilterMouseInput` @ 0x00457530 (`:96250-96279`).
- `CameraSet::SetDefaultOffsets` @ 0x00458F80 (`:97916-97967`) — pivot (0,0,1.5),
viewer (0,2.5,0.75).
- `SmartBox::PlayerPhysicsUpdatedCallback` @ 0x00452d60 (`:91842`) — sole
UpdateCamera caller; eye → render with no clip.
- `CameraManager` struct `acclient.h:35238-35263` (no collision fields).
- `SmartBox::PlayerPhysicsUpdatedCallback` @ 0x00452d60 (`:91842`) — writes the
damped desired eye to `viewer_sought_position` (collision happens later, in
`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`).