acdream/docs/research/2026-07-06-camera-sought-position-pseudocode.md
Erik 48aaab811c fix #180: port retail's stateful camera sought-position - the sweep target converges onto the wall contact
The camera-collision sweep strobed the eye 0.27m every ~5-10 frames while
the compressed chase boom moved along corridor walls (pulledIn 0.27<->0.53
on ~1.4mm input drift): RetailChaseCamera re-demanded the FULL-length ideal
boom from scratch each frame, so the pivot->eye ray re-rolled the same
knife-edge r+-eps graze on the double-faced slabs every frame, and its two
first-contact solutions tear-interleaved at ~1700fps into the #176
"stripes/triangles".

Retail never re-rolls that ray. CameraManager::UpdateCamera (0x00456660)
interpolates FROM THE CURRENT SWEPT VIEWER toward the desired pose
(interpolate_origin/rotation, stiffness 0.45 x dt x 10, clamped) and the
result becomes viewer_sought_position (SmartBox::PlayerPhysicsUpdatedCallback
0x00452d60); update_viewer (0x00453ce0) sweeps pivot->SOUGHT. Pressed against
a wall the sweep ray extends one interpolation step past the contact
(sub-mm at high fps), so a bistable graze can move the eye by at most that
step - the strobe is structurally impossible. A 0.4mm/2e-4 dead-band parks
the sought exactly on the viewer when converged (0x00456fcd-0x00457035).

- RetailChaseCamera: _dampedEye -> _soughtEye + _publishedEye (retail's two
  Positions); lerp base = the published (swept) viewer; sweep targets the
  sought; total-fallback (ViewerCellId==0) resets the sought like
  set_viewer(player_pos, 1). The old "collision must NOT feed back into the
  damped state" comment had the coupling backwards - what stays clean is the
  transient desired pose, not the sought.
- SweepEye untouched (faithful update_viewer port, exonerated by the #180
  investigation).
- Tests: the old pin asserting instant full re-extension after a clamp
  (the divergence itself) replaced with four retail pins: gradual
  re-extension, sweep-target-converges-onto-contact, total-fallback
  re-extends from the player, wall-press glide stability.
- Pseudocode doc: docs/research/2026-07-06-camera-sought-position-pseudocode.md
  (UpdateCamera tail incl. the sought derivation + set_viewer reset semantics
  + Frame interpolate/close_rotation).
- Register: AD-37 (forward-vector nlerp vs quaternion slerp), AD-38
  (init-at-full-extension vs retail re-extend-from-player) - both
  pre-existing, identified during the decomp reading.

Suites green (Core 2599+2skip / App 729+2skip / UI 425 / Net 385).
Pending: autonomous visual verify + user gate (#180 + the #176 re-gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:49:01 +02:00

268 lines
14 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.

# Retail camera sought-position derivation — pseudocode (#180 fix)
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, PDB-named Binary Ninja decomp). Read 2026-07-06 for #180 (the
camera-sweep eye strobe = the #176 "stripes").
Functions covered (address = retail VA, line = pseudo-C line):
| Function | VA | pseudo-C line |
|---|---|---|
| `CameraManager::UpdateCamera` | `0x00456660` | 95505 |
| `SmartBox::PlayerPhysicsUpdatedCallback` | `0x00452d60` | 91836 |
| `SmartBox::set_viewer` | `0x00452c40` | 91780 |
| `SmartBox::update_viewer` (consumption; already ported) | `0x00453ce0` | 92761 |
| `CameraManager::QueryPivotPosition` | `0x004564f0` | 95448 |
| `Frame::interpolate_origin` | `0x005352f0` | 319607 |
| `Frame::interpolate_rotation` | `0x00535390` | 319618 |
| `Frame::close_rotation` | `0x00455d70` | 94838 |
| `CameraManager::CameraManager` (ctor defaults) | `0x00457090` | 95957 |
## 1. The state machine — three Positions, one loop
`SmartBox` holds **two viewer Positions**; `CameraManager` computes a third
(the desired pose) transiently each frame:
- **`viewer`** — the PUBLISHED camera pose. What the renderer/sound/sky use.
Written only by `set_viewer` (the swept result each frame).
- **`viewer_sought_position`** — the STATEFUL sweep target. Persisted across
frames. Converges from the current `viewer` toward the desired pose.
- **desired pose** (transient inside `UpdateCamera`) — pivot + heading-rotated
`viewer_offset`, the full-length ideal boom. Never swept directly.
Per-frame loop (order matters):
```
CPhysics::UseTime (0x00509900 loop, line 271640):
for each physics obj: CPhysicsObj::update_object(obj)
if obj == player:
SmartBox::PlayerPhysicsUpdatedCallback(smartbox) // 0x005099f2
→ sought = CameraManager::UpdateCamera(mgr, &viewer) // ← CURRENT swept viewer in
...later in the frame...
SmartBox::UseTime → SmartBox::update_viewer (called at 0x00454c34)
→ sweep viewer_sphere: pivot → sought
→ set_viewer(swept_result, reset_sought=0) // viewer = swept; sought UNTOUCHED
```
So each frame's sweep target derives from the PREVIOUS frame's collided
position. **The full-length ideal boom ray is never re-rolled per frame**
that is the #180 fix: after a wall contact the sought sits at (or a small step
past) the contact, and re-extends toward the ideal pose gradually.
## 2. `SmartBox::set_viewer(pos, reset_sought)` — 0x00452c40
```
set_viewer(this, pos, reset_sought):
this->viewer = pos // objcell_id + frame
if reset_sought != 0:
this->viewer_sought_position = pos // HARD RESET of the sought
this->viewer_cell = null
... (viewer light, sound listener, sky position, SceneTool::SetupCamera)
```
`reset_sought=1` call sites: teleport/hard resets, the `player->cell == 0`
bail in `update_viewer` (:92775), and the total sweep-failure fallback
(:92886). This is why retail's camera re-extends outward from your head after
a portal: sought = viewer = player position, then converges back out to the
boom.
## 3. `SmartBox::PlayerPhysicsUpdatedCallback` — 0x00452d60
```
PlayerPhysicsUpdatedCallback(this):
Position ret = CameraManager::UpdateCamera(this->camera_manager, &this->viewer)
this->viewer_sought_position = ret // objcell_id (0x00452d84) + frame (0x00452d87)
```
The argument is `&this->viewer` — the **current swept** viewer, not the
previous sought. The return becomes the new sought.
## 4. `CameraManager::UpdateCamera(this, ret, viewer)` — 0x00456660
Head (previously read): disabled bail, dt bookkeeping, manual offset-key
integration, pivot query. Tail (read this session): the desired pose + the
stateful interpolation. Full shape:
```
UpdateCamera(this, ret, viewer): // viewer = SmartBox::viewer (swept)
if !m_bEnabled: return viewer // 0x00456672 — pass-through
dt = Timer::cur_time - last_update_time // real seconds, float64
last_update_time = Timer::cur_time
// Manual camera-adjust keys integrate into the offsets (held-key rates):
if m_dwCameraOffsetMovement: viewer_offset += FlagsToVector(flags) * dt // 0x004566ca
if m_dwPivotOffsetMovement: pivot_offset += FlagsToVector(flags) * dt // 0x00456718
pivot_obj = CPhysicsObj::GetObjectA(pivot_object_id)
ok = pivot_obj != null && QueryPivotPosition(&pivotPos) // 0x004567f1
if !ok: return viewer // 0x00457050 tail — pass-through
// ── Everything below happens in the VIEWER's coordinate space ──
p = Position::localtoglobal(viewer, pivotPos) // pivot origin in viewer space (0x00456815)
look = (0,0,0) // the look-direction accumulator
if target_status & 2: // LOOK_AT_OBJECT (0x0045684a)
target = GetObjectA(target_object_id) // + optional part anchor
t = localtoglobal(viewer, targetPos, target_offset)
d = t - p
if normalize(d) ok: look += d
if m_bAlignCameraToSlope && (target_status & 0x10): // ALIGN_WITH_PLANE (0x00456928)
avgVel = 5-slot ring average of pivot velocity // 0x00456944..0x00456a5f
if normalize(avgVel) ok:
if |avgVel.x| < 2e-4 && |avgVel.y| < 2e-4: // near-vertical motion only
n = (transient_state & CONTACT) ? contact_plane.N
: (heading-of-avgVel or (0,0,1)) // 0x00456ab1
fwd = pivot's local +Y in world // localtoglobalvec (0x00456b90)
slopeFwd = fwd - n * dot(fwd, n) // project facing onto the plane
if normalize(slopeFwd) ok:
if target_status & 1: look += headingFrame(slopeFwd).rotate(direction)
else: look += slopeFwd
else if target_status & 1: look += pivot.rotate(direction)
else: // not moving →
if target_status & 1: // LOOK_IN_DIRECTION (0x00456dfa)
look += pivot.rotate(direction) // player facing ⊗ this->direction
if normalize(look) failed: // 0x00456ca4 — degenerate accumulator
look = pivot.rotate(direction) // fall back to the facing direction
// ── Desired pose: boom offset in the heading frame ──
Frame headingFrame // var_40
headingFrame.origin = p // pivot, viewer-space (0x00456cd7-0x00456cf1)
headingFrame.set_vector_heading(look) // 0x00456cf8
desiredEye = headingFrame.localtoglobal(viewer_offset) // pivot + R(look)·offset (0x00456d0d)
if target_status & 4: // LOOK_AT_PIVOT (0x00456d33)
look = p - desiredEye // re-aim from the DESIRED eye at the pivot
if normalize(look) failed: look = (0,0,0)
if normalize(look) failed: // 0x00456e59
look = pivot.rotate(direction)
Frame desired // var_c8
desired.origin = desiredEye
desired.set_vector_heading(look) // 0x00456e8c
// ── Stiffness → per-frame interpolation factors ──
tSnap = t_stiffness >= 1.0 - 2e-4 // 0x00456e9e
t = tSnap ? 1.0 : min(t_stiffness * dt * 10, 1.0) // 0x00456ec2
rSnap = r_stiffness >= 1.0 - 2e-4 // 0x00456ee1
r = rSnap ? 1.0 : min(r_stiffness * dt * 10, 1.0) // 0x00456f04
// ── THE STATEFUL STEP: interpolate FROM THE SWEPT VIEWER ──
Frame f // var_150
f.interpolate_origin(viewer.frame, desired, t) // lerp origins (0x00456fae)
f.interpolate_rotation(viewer.frame, desired, r) // slerp quaternions (0x00456fc8)
// ── Dead-band: park the sought ON the viewer when converged ──
if !tSnap: // 0x00456fe6
if distance(f.origin, viewer) < 4e-4 // 2 × 2e-4 m (0x00456fe1, 0x00456fed)
&& !rSnap
&& Frame::close_rotation(f, viewer.frame, 2e-4):
return viewer // EXACT copy — sought parks on the viewer
return Position{ objcell_id: viewer.objcell_id, frame: f } // 0x00457038-0x00457071
```
Ctor defaults (0x00457090): `t_stiffness = r_stiffness = 0.45`,
`viewer_offset = (0,-3,0)`, `direction = (0,1,0)`,
`target_status = LOOK_IN_DIRECTION`, `m_bAlignCameraToSlope = 1`,
`m_rCameraAdjustmentSpeed = 40`.
### Units & feel
`t = 0.45 · dt · 10 = 4.5·dt` → an exponential approach with rate constant
4.5/s (time constant ≈ 0.22 s: the sought covers ~63% of the remaining gap
every 0.22 s, frame-rate independently). The dead-band (0.4 mm translation +
2e-4 quaternion-component rotation) turns the asymptote into an exact fixed
point.
## 5. Consumption: `SmartBox::update_viewer` — 0x00453ce0 (already ported)
Already faithfully ported by `PhysicsCameraCollisionProbe.SweepEye`
(exonerated in the #180 investigation — see the handoff's DO-NOT-RETRY). For
completeness, the sought's role:
```
update_viewer(this):
if player.cell == null: set_viewer(player.pos, reset_sought=1); return // :92775
pivot = player/part frame origin + R·pivot_offset // :92815
startCell = indoor ? AdjustPosition-seat at pivot : player.cell // :92832
sweep viewer_sphere (r=0.3): pivot → viewer_sought_position, flags 0x5c // :92860-92868
if find_valid_position ok: set_viewer(swept.curr_pos, 0); viewer_cell = swept.curr_cell
else if AdjustPosition(sought) ok: set_viewer(sought_seated, 0)
else: set_viewer(player.pos, reset_sought=1); viewer_cell = null // :92886
```
Note both `reset_sought=1` sites map to acdream's
`CameraSweepResult.ViewerCellId == 0` returns in `SweepEye` (the cell-0 bail
and Fallback 2) — a clean 1:1 seam for the port.
## 6. Frame helpers
```
Frame::interpolate_origin(this, a, b, t): // 0x005352f0
this.origin = (1-t)·a.origin + t·b.origin // plain lerp
Frame::interpolate_rotation(this, a, b, t): // 0x00535390
d = dot(a.q, b.q)
if d < 0: b.q = -b.q; d = -d // shortest-path sign flip
if 1-d < 2e-4: w = (1-t, t) // near-identical → nlerp weights
else: θ = acos(d); w = (sin((1-t)θ), sin(tθ)) / sin θ // slerp
this.q = w₀·a.q + w₁·b.q // (+ re-cache)
Frame::close_rotation(a, b, eps): // 0x00455d70
return |a.qw-b.qw| ≤ eps && |a.qx-b.qx| ≤ eps
&& |a.qy-b.qy| ≤ eps && |a.qz-b.qz| ≤ eps // component-wise, all four
```
## 7. Why this kills the #180 strobe (mechanism)
The probed failure: with the compressed boom moving along a wall, the
full-length pivot→ideal-eye ray grazes the corridor's double-faced slabs at
r±ε and has TWO first-contact solutions 0.27 m apart; mm-scale input drift
flips between them every few frames (`pulledIn` 0.27 ↔ 0.53).
With the retail sought: the target sits a single interpolation step past the
current contact — `α·|desiredviewer|` ≈ 0.7 mm at 1700 fps, ≈ 2 cm at 60 fps
(for the 0.27 m compression). The sweep ray extends only that far past the
wall, so even a bistable contact solution can only move the eye by the step
size, not 0.27 m. The strobe amplitude collapses from 0.27 m to sub-mm; the
eye glides along the wall following the player's drift. No damping of the
SWEPT result is involved — the published eye still hard-follows the sweep
every frame, exactly like retail.
## 8. Port mapping → `RetailChaseCamera`
| Retail | acdream |
|---|---|
| `viewer` (swept, published) | `_publishedEye` (+ `Position` property) |
| `viewer_sought_position` | `_soughtEye` (was `_dampedEye` — which wrongly lerped from ITSELF) |
| desired pose (transient) | `targetEye`/`targetForward` (existing boom math — unchanged) |
| `interpolate_origin(viewer→desired, t)` | `Vector3.Lerp(_publishedEye, targetEye, tAlpha)` |
| `interpolate_rotation(viewer→desired, r)` | forward-vector lerp (adaptation — register AD-37) |
| dead-band `return viewer` | `ApplyConvergenceSnap(_publishedEye, …)` parks sought on the viewer |
| `set_viewer(pos, 1)` resets sought | `swept.ViewerCellId == 0``_soughtEye = swept.Eye` |
| sweep pivot→sought | `SweepEye(pivotWorld, _soughtEye, …)` (was `_dampedEye` = full boom) |
Intentional adaptations (register rows added with the port commit):
- **AD-37** — rotation state is a forward VECTOR (nlerp + normalize, roll
always 0, up = world Z) instead of retail's quaternion slerp
(`interpolate_rotation` 0x00535390). Pre-existing shape of
`RetailChaseCamera`; the close_rotation dead-band test compares forward
vectors against the same 2e-4 epsilon.
- **AD-38** — first-frame init seats sought = viewer = the full-length target
eye; retail initializes via `set_viewer(player_pos, 1)` so the camera
re-extends from the player's head at login/teleport. acdream's teleports
don't reset the camera today; the pivot-anchored sweep self-heals within
one convergence (~1 s).
The previous in-code comment ("the collision must NOT feed back into the
damped state — retail keeps viewer_sought_position separate from viewer")
was WRONG about the direction of the coupling: retail's sought is derived
FROM the swept viewer every frame (0x00452d75); what stays clean is the
*desired pose*, which is recomputed transiently and never swept directly.
The old shape (lerp sought→ideal from itself + sweep the full boom) is what
made the knife-edge re-roll — and the #180 strobe — possible.