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>
This commit is contained in:
parent
7272235a22
commit
48aaab811c
5 changed files with 571 additions and 56 deletions
268
docs/research/2026-07-06-camera-sought-position-pseudocode.md
Normal file
268
docs/research/2026-07-06-camera-sought-position-pseudocode.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue