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:
parent
ce909ad0a8
commit
9757818e95
1 changed files with 111 additions and 90 deletions
|
|
@ -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.1–0.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`).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue