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

14 KiB
Raw Blame History

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.