From 9757818e958731a36a7156584812f721dfd1eb9d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 29 May 2026 17:51:44 +0200 Subject: [PATCH] =?UTF-8?q?docs(render):=20Phase=20A8.F=20=E2=80=94=20corr?= =?UTF-8?q?ect=20camera=20handoff;=20retail=20DOES=20collide=20the=20camer?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...2026-05-29-a8f-camera-collision-handoff.md | 201 ++++++++++-------- 1 file changed, 111 insertions(+), 90 deletions(-) diff --git a/docs/research/2026-05-29-a8f-camera-collision-handoff.md b/docs/research/2026-05-29-a8f-camera-collision-handoff.md index 7336337..20842d4 100644 --- a/docs/research/2026-05-29-a8f-camera-collision-handoff.md +++ b/docs/research/2026-05-29-a8f-camera-collision-handoff.md @@ -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`).