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>
392 lines
22 KiB
Markdown
392 lines
22 KiB
Markdown
# Phase A8.F — camera-collision root cause + handoff (2026-05-29, session 2)
|
||
|
||
## TL;DR
|
||
|
||
The A8.F "flap" (building walls / ground blinking in and out) and the
|
||
missing/transparent-wall symptoms are **not** a builder or enforcement bug.
|
||
Their root cause is the **3rd-person camera EYE passing through walls**: the
|
||
A8.F renderer computes "am I inside a building?" and "which side of each
|
||
doorway am I on?" from the camera eye position, so when the eye clips a wall
|
||
those decisions flip frame-to-frame.
|
||
|
||
This session: (1) **fixed + committed** the worst symptom — the cellar terrain
|
||
flood (commit `9417d3c`); (2) established the recursive-clip **builder actually
|
||
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 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.)
|
||
|
||
---
|
||
|
||
## What this session shipped / established
|
||
|
||
- **Bug A FIXED + committed (`9417d3c`):** an empty `OutsideView` while inside a
|
||
building now draws **no** outdoor terrain/scenery (empty mask = "no outdoors
|
||
visible," not "all outdoors"). Previously the `else` branch disabled the
|
||
stencil and flooded ungated terrain over the cell interior. **Visual-confirmed
|
||
by the user: cottage cellar walls are solid now, no terrain bleed-through.**
|
||
App tests 108/108. All behind `ACDREAM_A8_INDOOR_BRANCH=1`; default play
|
||
unaffected.
|
||
- **"Bug B" (builder under-produces) is substantially NOT real.** The pv-dump
|
||
census showed `PortalVisibilityBuilder` produces correctly *narrowed*
|
||
`OutsideView` regions for most cells (`0172/0173/0162/015E/0165/016F` →
|
||
`polys=1`). The empty cases are mostly legitimate (a windowless cellar can't
|
||
see daylight) or driven by the camera being in an invalid position (see root
|
||
cause). The handoff predecessor's Finding 2 ("never narrows") does not hold.
|
||
- **Root cause reframed → the camera.** Confirmed below.
|
||
- **Camera research done** (Opus agent, findings verified against the decomp +
|
||
the code by this session).
|
||
|
||
### Diagnostics added (committed in `9417d3c`, all opt-in)
|
||
|
||
- `PortalVisibilityBuilder.Build` now emits a **`CAMPORTAL[i]` census** under
|
||
`ACDREAM_A8_DUMP_PV=1`: per camera-cell portal, before the BFS guards, it logs
|
||
`other=`, `polyLen=`, `hasPlane=`, `interiorSide=`, `planeN=`. This is what let
|
||
us see that the cottage front-door exit portal was *culled by the side test*
|
||
(`interiorSide=False`) rather than missing.
|
||
- `[opaque]` probe (`ACDREAM_PROBE_ENVCELL=1`) — opaque cell-render stats
|
||
(`cells`/`tris`) **before** the transparent loop overwrites them (the existing
|
||
`[envcells]` line reads post-loop and is misleading — ignore its `cells=1
|
||
tris=0`).
|
||
- `tools/A8CellAudit` `portals <cellId>` now **replicates `BuildLoadedCell`'s
|
||
polygon-vertex resolution** and prints `BUILDER_SEES=OK/EMPTY` per portal, so
|
||
exit-portal validity is checkable offline without a launch.
|
||
|
||
---
|
||
|
||
## The visual symptoms (user-observed, 2026-05-29, with `ACDREAM_A8_INDOOR_BRANCH=1`)
|
||
|
||
1. Cellar walls **solid** (Bug A fix confirmed). ✓
|
||
2. **Flap**: buildings/ground disappear when passing from inside to outside.
|
||
3. Cellar entrance no longer covered by terrain (Bug A fix). ✓
|
||
4. Looking into certain windows from **outside**: back walls missing.
|
||
5. Inside **other** buildings: external + internal walls transparent (showing
|
||
sky / NPCs / particles).
|
||
|
||
**The user's key correlation:** items 2/4/5 happen **when the camera passes
|
||
through a door, or when standing inside and panning the camera through a wall**.
|
||
They're intermittent — not always reproducible — which fits a camera-position
|
||
trigger, not a static rendering bug.
|
||
|
||
---
|
||
|
||
## Root cause: the camera eye drives A8.F visibility, and it clips through walls
|
||
|
||
`camPos` is the **camera eye**, extracted from the inverse of the active
|
||
camera's View matrix:
|
||
|
||
```
|
||
GameWindow.cs:7270-7271
|
||
Matrix4x4.Invert(camera.View, out var invView);
|
||
var camPos = new Vector3(invView.M41, invView.M42, invView.M43); // = the eye
|
||
```
|
||
|
||
(`camera.View` is `CreateLookAt(_dampedEye, …)`, so the translation is the
|
||
chase-camera eye — **not** the player avatar position, which is tracked
|
||
separately for interior lighting at `GameWindow.cs:7415-7417`.)
|
||
|
||
That eye then drives all three A8.F visibility decisions:
|
||
|
||
1. **Camera-cell + portal BFS** — `_cellVisibility.ComputeVisibility(camPos)`
|
||
(`GameWindow.cs:7323`) → `FindCameraCell` via `PointInCell(camPos, …)`. The
|
||
BFS portal side-test (`CellVisibility.cs:466-481`) culls portals the eye is on
|
||
the wrong side of.
|
||
2. **Strict inside-building gate** (`GameWindow.cs:7343-7346`):
|
||
`cameraInsideBuilding = a8IndoorBranchEnabled && PointInCell(camPos,
|
||
CameraCell) && CameraCell.BuildingId != null`. Picks the inside-out vs
|
||
outside-in render branch.
|
||
3. **Per-portal interior-side cull in the recursive-clip builder** —
|
||
`PortalVisibilityBuilder.CameraOnInteriorSide(cell, i, cameraPos)`
|
||
(`PortalVisibilityBuilder.cs:196-203`; cull site ~`:124`):
|
||
transforms `cameraPos` to cell-local and dot-tests against the portal plane.
|
||
|
||
**The flap mechanism:** when the eye damps to a position outside the room (or in
|
||
the next room), `PointInCell(eye)` flips and `CameraOnInteriorSide` inverts.
|
||
The camera-cell ping-pongs, the inside/outside branch switches, and the exit
|
||
portal through a doorway is culled-then-uncovered frame-to-frame → walls/ground
|
||
blink. The 3-frame `CellSwitchGraceFrameCount` hysteresis (`CellVisibility.cs:167`)
|
||
only masks single-frame blips; a sustained multi-frame clip defeats it.
|
||
|
||
**Hard evidence (this session's capture):** at cottage cell `0xA9B40170` the
|
||
front-door exit portal was valid (`polyLen=4`) but the census showed
|
||
`interiorSide=False` — i.e. the eye was on the *outdoor* side of the door plane
|
||
`(0,-0.995,0.105)`. The plane math puts the cull threshold at eye Y < −8.50 with
|
||
the door at Y≈−8.5: **the eye had poked out through the front wall while the
|
||
player stood inside.** The same portal projected fine when the BFS reached
|
||
`0170` from a deeper room (`0173`) where the eye was well inside.
|
||
|
||
---
|
||
|
||
## KEY FINDING: retail DOES collide the camera (swept sphere) — plus the close-up fade
|
||
|
||
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.
|
||
|
||
- **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, transparent at ≤0.20 m. **acdream already ports this** as
|
||
`RetailChaseCamera.ComputeTranslucency` (`RetailChaseCamera.cs:367-376`).
|
||
|
||
**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:** 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.
|
||
|
||
---
|
||
|
||
## acdream's current camera (file:line)
|
||
|
||
All under `src/AcDream.App/Rendering/` unless noted.
|
||
|
||
- **Two chase cameras, toggled per-frame:** legacy `ChaseCamera.cs` (rigid) and
|
||
default `RetailChaseCamera.cs` (damped). Selected by
|
||
`CameraController.Active` (`CameraController.cs:20-33`) via
|
||
`CameraDiagnostics.UseRetailChaseCamera` (default ON,
|
||
`src/AcDream.Core/Rendering/CameraDiagnostics.cs`).
|
||
- **Eye computation (`RetailChaseCamera.Update`, `:86-142`):**
|
||
`pivotWorld = playerPos + (0,0,1.5)`; `targetEye = pivotWorld +
|
||
forward*(-Distance·cosPitch) + up*(Distance·sinPitch)` (`:113-117`); then
|
||
exponential damping `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` (`:121-133`);
|
||
published as `Position` + `View = CreateLookAt(_dampedEye, …)` (`:136-137`).
|
||
**No geometry test anywhere between target and publish.**
|
||
- **The "input-lag for turning/jumping" port** = the RetailChaseCamera's three
|
||
smoothing mechanisms, all verified faithful to the decomp:
|
||
- Exponential damping (follow-lag): `:121-133` + `ComputeDampingAlpha:323-329`
|
||
↔ retail `:95866-95923` (`stiffness*dt*10` clamped).
|
||
- 5-frame velocity-averaged, slope-aligned heading (the jump/slope feel):
|
||
`:94-107`, `:290-314`, `ComputeHeading:211-257` ↔ retail `old_velocities[5]`
|
||
`:95644-95677`.
|
||
- Mouse low-pass filter (input lag on flicks): `FilterMouseDelta:164-177` +
|
||
`FilterMouseAxis:340-358` ↔ retail `CameraSet::FilterMouseInput` (0x00457530,
|
||
`:96250-96279`); wired at `GameWindow.cs:1171-1199`.
|
||
- **Constants** (from retail `CameraSet::SetDefaultOffsets` 0x00458F80,
|
||
`:97916-97967`): `PivotHeight=1.5`, `Distance=2.61` (=|(0,−2.5,0.75)|),
|
||
`Pitch=0.291 rad`, distance clamp `[2,40]`, pitch clamp `[−0.7,1.4]`. **No
|
||
collision-radius constant exists** (no collision is done).
|
||
- **No 1st-person mode** (retail `SetInHead` 0x00458CE0 unported).
|
||
- Spec: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md`
|
||
explicitly scopes collision OUT (`:454-457`: "we don't attempt 'camera
|
||
collides with wall' — same as retail").
|
||
|
||
---
|
||
|
||
## Integration: acdream already has the collision machinery
|
||
|
||
A camera "spring-arm" sweep can reuse the player's existing swept-sphere engine
|
||
(`src/AcDream.Core/Physics/`):
|
||
|
||
- **`PhysicsEngine.ResolveWithTransition(curPos, targetPos, cellId, radius,
|
||
height, stepUp, stepDown, isOnGround, body, moverFlags, entityId)`**
|
||
(`PhysicsEngine.cs:589`) → `Transition.FindTransitionalPosition`
|
||
(`TransitionTypes.cs:653`) → per sub-step `FindEnvCollisions`
|
||
(`:1933`, indoor branch fetches `GetCellStruct(cellId)` → `cellPhysics.BSP.Root`
|
||
→ `BSPQuery.FindCollisions`).
|
||
- **`BSPQuery`** primitives (`BSPQuery.cs`): `PointInsideCellBsp` (`:1034`),
|
||
`SphereIntersectsCellBsp` (`:1077`), `FindCollisions` (`:1637`),
|
||
`SphereIntersectsPoly` (`:2085`). A purpose-built pivot→eye cast off these is
|
||
likely a **better fit** than the full `Transition` (which carries unwanted
|
||
step-up / walkable / gravity semantics).
|
||
- **`CellPhysics`** (`PhysicsDataCache.cs:511-564`): per-cell `BSP` (collision),
|
||
`CellBSP` (point-in-cell), polys/planes, transforms. Fetched via
|
||
`GetCellStruct(cellId)`.
|
||
- Player sweep params for reference: `radius 0.48`, `height 1.2`
|
||
(`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:** 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 fix: a retail-faithful swept-sphere camera collision (no divergence)
|
||
|
||
Port retail's stage-2 collision (stage 1 = the damped desired eye, and stage 3 =
|
||
the close-up fade, are both already in acdream):
|
||
|
||
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).
|
||
|
||
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. **`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
|
||
cell-BSP-only sweep fixes the indoor case but misses outdoor shells (the
|
||
`FindObjCollisions` / ShadowEntry path would be needed for those). Decide scope.
|
||
5. **1st-person fallback:** none today; a spring arm must no-op at distance 0 if
|
||
1st-person is added.
|
||
6. **Interaction with the ported translucency fade** (`ComputeTranslucency`) —
|
||
verify the fade still behaves once the eye stops clipping.
|
||
7. **Pivot reference:** sweep from `pivotWorld` (head, `:113`), not the feet.
|
||
|
||
---
|
||
|
||
## Apparatus / diagnostics (committed `9417d3c`; opt-in)
|
||
|
||
Launch (PowerShell), then walk `+Acdream` into a Holtburg cottage:
|
||
|
||
```powershell
|
||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
||
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
|
||
$env:ACDREAM_A8_INDOOR_BRANCH="1"; $env:ACDREAM_A8_DUMP_PV="1"; $env:ACDREAM_PROBE_ENVCELL="1"
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f.log"
|
||
```
|
||
|
||
- `ACDREAM_A8_DUMP_PV=1` — `[pv-dump]` per camera cell incl. the **`CAMPORTAL[i]`
|
||
census** (polyLen + interiorSide per portal, before the guards) + the
|
||
EXIT-CULLED/PROJ/CLIP trace + `OUTSIDEVIEW polys=N`.
|
||
- `ACDREAM_PROBE_ENVCELL=1` — `[opaque]` cell-render stats (reliable; pre-loop).
|
||
- `ACDREAM_PROBE_VIS=1` — `[buildings]`/`[draworder]`/`[stencil]`/`[envcells]`
|
||
(heavy: ~17 GL queries × several calls per frame; keep captures short).
|
||
- `tools/A8CellAudit portals <cellId>` — offline portal dump with
|
||
`BUILDER_SEES=OK/EMPTY` per portal.
|
||
|
||
**A future "camera-cell ≠ player-cell" probe** would directly confirm the
|
||
flap-by-frame: log when `PointInCell(eye)` disagrees with the player's cell.
|
||
|
||
---
|
||
|
||
## Current state / safety
|
||
|
||
- **Default game safe** — everything gated behind `ACDREAM_A8_INDOOR_BRANCH=1`.
|
||
- Bug A fix + diagnostics committed (`9417d3c`); App tests 108/108.
|
||
- Builder works (not the bug); camera-collision work **not started** (awaiting the
|
||
design decision).
|
||
- Tree clean as of `9417d3c` plus untracked launch logs (`a8f-*.log`).
|
||
|
||
---
|
||
|
||
## Pickup prompt
|
||
|
||
> 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.
|
||
> 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.
|
||
|
||
---
|
||
|
||
## Reference index
|
||
|
||
**acdream code** (`src/…`):
|
||
- `AcDream.App/Rendering/RetailChaseCamera.cs` — eye `:113-137`, damping
|
||
`:121-133`/`:323-329`, heading `:211-257`, mouse filter `:340-358`,
|
||
translucency `:367-376`.
|
||
- `AcDream.App/Rendering/ChaseCamera.cs` (legacy), `CameraController.cs:20-33`,
|
||
`ICamera.cs`.
|
||
- `AcDream.Core/Rendering/CameraDiagnostics.cs` — `UseRetailChaseCamera`, tunables.
|
||
- `AcDream.App/Rendering/GameWindow.cs` — eye `:7270-7271`, visibility `:7323`,
|
||
`cameraInsideBuilding` `:7343-7346`, camera updates `:6851`/`:6862`, mouse-filter
|
||
`:1171-1199`.
|
||
- `AcDream.App/Rendering/PortalVisibilityBuilder.cs` — `CameraOnInteriorSide`
|
||
`:196-203`, cull `:124`, CAMPORTAL census in `Build`.
|
||
- `AcDream.App/Rendering/CellVisibility.cs` — `ComputeVisibility` `:272`,
|
||
`PointInCell` `:367`, BFS side-test `:466-481`, grace `:167`.
|
||
- `AcDream.Core/Physics/` — `PhysicsEngine.cs:589`, `TransitionTypes.cs:653/1933`,
|
||
`BSPQuery.cs:1034/1077/1637`, `PhysicsDataCache.cs:511-564`,
|
||
`PlayerMovementController.cs:1107-1108`.
|
||
- Spec: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md` (collision
|
||
out-of-scope `:454-457`).
|
||
|
||
**Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
|
||
- `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`) — 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`).
|