# 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 — `α·|desired−viewer|` ≈ 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.