acdream/docs/research/2026-05-29-a8f-camera-collision-handoff.md
Erik 9757818e95 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>
2026-05-29 17:51:44 +02:00

392 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`).