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:
Erik 2026-07-06 16:49:01 +02:00
parent 7272235a22
commit 48aaab811c
5 changed files with 571 additions and 56 deletions

View file

@ -48,7 +48,13 @@ Copy this block when adding a new issue:
## #180 — Camera-collision sweep bistable at a compressed boom → per-frame eye strobe (the #176 "stripes") ## #180 — Camera-collision sweep bistable at a compressed boom → per-frame eye strobe (the #176 "stripes")
**Status:** OPEN — mechanism PINNED numerically; fix = port retail's stateful sought-position **Status:** 🟡 FIX SHIPPED 2026-07-06 (stateful sought-position ported per
`CameraManager::UpdateCamera` 0x00456660 — the interpolation base is the
CURRENT swept viewer and the sweep targets the converging sought, never the
full-length boom; pseudocode
`docs/research/2026-07-06-camera-sought-position-pseudocode.md`; register
AD-37/AD-38 filed for the two pre-existing adaptations found in the reading).
Awaiting autonomous visual verify + the user gate (with the #176 re-gate).
**Severity:** HIGH (the visible flicker/stripe artifact the #176 gate keeps failing on; corridor camera constantly rides walls) **Severity:** HIGH (the visible flicker/stripe artifact the #176 gate keeps failing on; corridor camera constantly rides walls)
**Filed:** 2026-07-06 **Filed:** 2026-07-06
**Component:** camera / physics (NOT render — every render suspect eliminated by isolation) **Component:** camera / physics (NOT render — every render suspect eliminated by isolation)

View file

@ -96,6 +96,8 @@ accepted-divergence entries (#96, #49, #50).
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a | | AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) | | AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) | | AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
| AD-37 | Camera rotation state is a forward VECTOR (nlerp + normalize; roll always 0, up = world Z); retail's sought carries a full Frame and slerps quaternions (`Frame::interpolate_rotation` shortest-path slerp with 2e-4 nlerp fallback). The dead-band compares forward-vector distance against the same 2e-4 epsilon retail applies per quaternion component | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_dampedForward`, `ApplyConvergenceSnap`) | The chase camera never rolls (heading frames are Z-up by construction), so a forward vector spans the reachable rotation space; identified (not introduced) during the #180 UpdateCamera tail reading | If a future camera mode needs roll (death cam, cutscene) the vector state can't represent it; large-angle per-frame turns nlerp (chord) vs slerp (arc) — imperceptible at 0.45-stiffness step sizes | `Frame::interpolate_rotation` 0x00535390, `Frame::close_rotation` 0x00455d70; pseudocode doc 2026-07-06-camera-sought-position |
| AD-38 | First camera frame seats sought = viewer = the full-length target eye (starts converged); retail hard resets both to the PLAYER's position (`set_viewer(pos, reset_sought=1)`) at login/teleport/cell-loss, so the retail camera visibly re-extends outward from the head over ~1 s | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_initialised` branch) | acdream teleports don't reset the camera object today; the pivot-anchored sweep self-heals in one convergence, and the login zoom-out is cosmetic. Identified (not introduced) during the #180 reading | Login/teleport first frames show a fully-extended boom where retail shows a zoom-out; if a teleport lands the stale sought behind distant geometry the first sweeps clip until convergence (sub-second) | `SmartBox::set_viewer` 0x00452c40 (reset_sought=1 sites pc:92775, pc:92886); retire by wiring teleport → sought reset |
--- ---

View 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 — `α·|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.

View file

@ -8,8 +8,12 @@ namespace AcDream.App.Rendering;
/// Retail-faithful chase camera. Ports the chase-cam behavior from the /// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at /// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>): /// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged /// a STATEFUL sought position that converges from the current swept
/// slope-aligned heading frame, mouse-input low-pass filter. /// viewer toward the desired boom pose (<c>CameraManager::UpdateCamera</c>
/// 0x00456660 → <c>viewer_sought_position</c>, the #180 fix), 5-frame
/// velocity-averaged slope-aligned heading frame, mouse-input low-pass
/// filter. Pseudocode:
/// <c>docs/research/2026-07-06-camera-sought-position-pseudocode.md</c>.
/// ///
/// <para> /// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/> /// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
@ -97,11 +101,23 @@ public sealed class RetailChaseCamera : ICamera
private const float SnapEpsilon = 0.000199999995f * 2f; private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f; private const float RotCloseEpsilon = 0.000199999995f;
// ── Damped state ──────────────────────────────────────────────── // ── Stateful camera state (retail SmartBox's two Positions) ─────
//
// _soughtEye = retail viewer_sought_position — the persisted sweep
// TARGET, re-derived each frame from the current swept
// viewer (NOT from itself).
// _publishedEye = retail viewer — the swept, published eye; the base
// of next frame's interpolation (SmartBox::
// PlayerPhysicsUpdatedCallback passes &this->viewer
// into UpdateCamera, 0x00452d75).
// _dampedForward = the sought's look direction. Sweeps translate but
// never rotate, so the viewer's rotation is always the
// previous sought rotation — one field serves both.
private readonly Vector3[] _velocityRing = new Vector3[5]; private readonly Vector3[] _velocityRing = new Vector3[5];
private int _velocityCount; private int _velocityCount;
private Vector3 _dampedEye; private Vector3 _soughtEye;
private Vector3 _publishedEye;
private Vector3 _dampedForward = new(1f, 0f, 0f); private Vector3 _dampedForward = new(1f, 0f, 0f);
private bool _initialised; private bool _initialised;
@ -155,10 +171,18 @@ public sealed class RetailChaseCamera : ICamera
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical; Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye); Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
// 5. Exponential damping (independent translation + rotation rates). // 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
// desired pose and assigns the result to viewer_sought_position
// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the sweep
// target converges onto whatever the collision produced last frame
// and re-extends gradually. The full-length ideal boom is never swept
// directly. Pseudocode:
// docs/research/2026-07-06-camera-sought-position-pseudocode.md.
if (!_initialised) if (!_initialised)
{ {
_dampedEye = targetEye; _soughtEye = targetEye;
_publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player)
_dampedForward = targetForward; _dampedForward = targetForward;
_initialised = true; _initialised = true;
} }
@ -166,36 +190,48 @@ public sealed class RetailChaseCamera : ICamera
{ {
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt); float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt); float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha); // interpolate_origin(viewer.frame → desired, t) — the lerp base is the
// VIEWER (0x00456fae), not the previous sought. The forward base is the
// viewer's rotation ≡ the previous sought forward (sweeps never rotate).
Vector3 candidateEye = Vector3.Lerp(_publishedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha)); Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed // Retail UpdateCamera dead-band (0x00456fcd0x00457035): once the step
// point once the lerp step is sub-epsilon, instead of dithering forever. This is // off the viewer is sub-epsilon in translation AND rotation, the sought
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon. // parks EXACTLY ON the viewer — an exact fixed point instead of an
(_dampedEye, _dampedForward, _) = // asymptote. Kills the at-rest drift AND the residual micro-jitter when
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward); // pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon.
(_soughtEye, _dampedForward, _) =
ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward);
} }
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer // 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
// (0x00453ce0) keeps TWO states: viewer_sought_position (the damped // (0x00453ce0) sweeps the viewer_sphere pivot → viewer_sought_position
// desired eye) and viewer (the published eye = set_viewer(curr_pos)). // and publishes the swept result as the viewer (set_viewer(curr_pos, 0)
// The collision produces the PUBLISHED eye each frame but must NOT // — the sought is NOT reset on success). Pressed against a wall, the
// feed back into the damped state — writing the clamped result into // sweep ray extends only one interpolation step past the contact, so a
// _dampedEye makes next frame's lerp start from the wall and fight // knife-edge r±ε graze can move the eye by at most that step (sub-mm at
// the clamp, which shows up as visible oscillation/vibration when the // high fps) instead of re-solving the full-length boom with its 0.27 m
// eye is pressed against a wall. So collide into a separate local and // bistable contact pair — the #180 strobe fix.
// leave _dampedEye as the clean, uncollided sought position. Vector3 publishedEye = _soughtEye;
Vector3 publishedEye = _dampedEye;
// The viewer cell defaults to the player cell (collision off / null probe); the sweep // The viewer cell defaults to the player cell (collision off / null probe); the sweep
// overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a // overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a
// robust per-frame "which cell is the camera in?" answer. // robust per-frame "which cell is the camera in?" answer.
ViewerCellId = cellId; ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null) if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{ {
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition); var swept = CollisionProbe.SweepEye(pivotWorld, _soughtEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye; publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId; ViewerCellId = swept.ViewerCellId;
// Total-failure fallback = retail set_viewer(player_pos, reset_sought=1)
// (update_viewer :92886 and the cell==0 bail :92775 — both surface here as
// ViewerCellId == 0): the sought resets to the returned position and
// re-extends from there.
if (swept.ViewerCellId == 0)
_soughtEye = swept.Eye;
} }
// Retail viewer — the base of next frame's interpolation (step 5).
_publishedEye = publishedEye;
// 6. Publish renderer surface (from the collided eye; rotation stays the // 6. Publish renderer surface (from the collided eye; rotation stays the
// smoothly-damped look direction toward the pivot). // smoothly-damped look direction toward the pivot).
@ -396,22 +432,23 @@ public sealed class RetailChaseCamera : ICamera
} }
/// <summary> /// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd). /// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd0x00457035).
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/> /// After the per-frame lerp, if the translation step from <paramref name="viewerEye"/>
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the /// (the interpolation base = the current swept viewer) to <paramref name="candidateEye"/>
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input /// is below <see cref="SnapEpsilon"/> AND the rotation step is below
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the /// <see cref="RotCloseEpsilon"/>, retail returns the VIEWER unchanged — the sought
/// current state in that case; otherwise <c>frozen=false</c> with the candidate. /// parks exactly on it (<c>return viewer</c>, 0x00457025). Returns <c>frozen=true</c>
/// Both conditions are required (retail couples origin + rotation in the snap test), /// with the viewer state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the test),
/// so the boom keeps converging while the heading is still turning. /// so the boom keeps converging while the heading is still turning.
/// </summary> /// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap( internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward) Vector3 viewerEye, Vector3 viewerForward, Vector3 candidateEye, Vector3 candidateForward)
{ {
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon; bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon; bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged) if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point return (viewerEye, viewerForward, true); // park: exact fixed point on the viewer
return (candidateEye, candidateForward, false); return (candidateEye, candidateForward, false);
} }

View file

@ -579,16 +579,23 @@ public class RetailChaseCameraTests
} }
[Fact] [Fact]
public void Update_CollisionDoesNotCorruptDampedState() public void Update_AfterClampReleases_EyeReExtendsGradually()
{
// #180: retail's sought position is STATEFUL — CameraManager::UpdateCamera
// (0x00456660) interpolates from the CURRENT SWEPT VIEWER toward the desired
// pose, so after an obstruction clears the eye re-extends at the stiffness
// rate (τ ≈ 0.22 s), NOT in one jump. (The previous pin here asserted the
// instant full re-extension — the exact divergence that re-rolled the
// full-length knife-edge boom ray per frame and produced the strobe.)
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{ {
// Regression for the wall-press vibration: the sweep must NOT write its
// clamped result back into the damped "sought" eye (retail keeps
// viewer_sought_position separate from viewer). Frame 1 clamps the eye
// near the pivot; frame 2 releases. With the damp state kept clean, the
// published eye returns straight to the (constant) target on frame 2; if
// it were corrupted, frame 2 would only lerp ~7.5% back from the clamp
// and stay pinned near it.
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) }; var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) };
var cam = new RetailChaseCamera { CollisionProbe = probe }; var cam = new RetailChaseCamera { CollisionProbe = probe };
@ -597,13 +604,208 @@ public class RetailChaseCameraTests
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5); cellId: 0x100, selfEntityId: 0x5);
Step(); // frame 1: clamps to (0,0,2) Step(); // frame 1: clamps to (0,0,2) — the viewer sits at the clamp
Step(); // frame 2: releases Step(); // frame 2: releases
// Constant pose → target eye ≈ (-2.5, 0, 2.25). Full recovery means // Constant pose → target eye ≈ (-2.5, 0, 2.25). Frame 2's eye is one
// Position.X is near the target (< -2), not pinned near the clamp (X≈0). // 7.5% lerp step off the clamp (X ≈ -0.19) — near the clamp, NOT the
Assert.True(cam.Position.X < -2f, // full target.
$"published eye should fully recover to the target after release, got {cam.Position}"); Assert.True(cam.Position.X > -0.5f,
$"eye must re-extend gradually from the contact, got {cam.Position}");
// ...and converges onto the target over the following seconds.
for (int i = 0; i < 200; i++) Step();
Assert.True(cam.Position.X < -2.4f,
$"eye should converge to the target after release, got {cam.Position}");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// Probe that records every requested sweep target and clamps the eye to a
// fixed contact point (a wall the boom is pressed into).
private sealed class RecordingClampProbe : ICameraCollisionProbe
{
public readonly System.Collections.Generic.List<Vector3> Requests = new();
public Vector3 ClampEye;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Requests.Add(desiredEye);
return new CameraSweepResult(ClampEye, cellId);
}
}
[Fact]
public void Update_SweepTargetConvergesOntoContact_NotTheFullBoom()
{
// THE #180 structural pin. Retail sweeps pivot → viewer_sought_position
// (SmartBox::update_viewer 0x00453ce0), and the sought derives from the
// swept viewer — so while pressed against a wall the requested sweep
// target sits ONE interpolation step past the contact. acdream's old shape
// re-requested the full-length ideal boom every frame; with mm input drift
// that full ray grazed distant geometry at r±ε and flipped its first-contact
// solution 0.27 m along the boom every few frames (the #176 stripes).
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var wall = new Vector3(0f, 0f, 2f);
var probe = new RecordingClampProbe { ClampEye = wall };
var cam = new RetailChaseCamera { CollisionProbe = probe };
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Step(); // frame 1: init requests the full boom (AD-38), eye clamps to the wall
Step(); // frame 2: the request must now derive from the CLAMPED viewer
// Frame 1 documents the contrast: the init request is the full-length
// target, ~2.5 m from the contact.
Assert.True(Vector3.Distance(probe.Requests[0], wall) > 2f,
$"frame-1 init request should be the full boom, got {probe.Requests[0]}");
// Frame 2's request = one 7.5% lerp step off the wall (~0.19 m) — the
// knife-edge full-length ray is never re-rolled.
float reach = Vector3.Distance(probe.Requests[1], wall);
Assert.True(reach < 0.3f,
$"frame-2 sweep target must sit one step past the contact, got {reach:F3} m past it");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// Probe that fails entirely on the FIRST call (retail set_viewer(player_pos, 1):
// eye = player, viewer_cell = 0), then releases; records requests.
private sealed class FallbackThenReleaseProbe : ICameraCollisionProbe
{
public readonly System.Collections.Generic.List<Vector3> Requests = new();
public int Calls;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Calls++;
Requests.Add(desiredEye);
return Calls == 1
? new CameraSweepResult(playerPos, 0u) // total fallback (pc:92886)
: new CameraSweepResult(desiredEye, cellId);
}
}
[Fact]
public void Update_TotalFallback_ReExtendsFromThePlayer()
{
// After the total sweep failure retail resets viewer AND sought to the
// player's position (set_viewer(player_pos, reset_sought=1)) — the camera
// re-extends outward from the head, so the next sweep target is NEAR THE
// PLAYER, not the full-length boom.
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var probe = new FallbackThenReleaseProbe();
var cam = new RetailChaseCamera { CollisionProbe = probe };
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Step(); // frame 1: total fallback — viewer snaps to the player, cell 0
Assert.Equal(Vector3.Zero, cam.Position);
Assert.Equal(0u, cam.ViewerCellId);
Step(); // frame 2: the sweep target re-extends FROM the player
float reach = Vector3.Distance(probe.Requests[1], Vector3.Zero);
Assert.True(reach < 0.3f,
$"post-fallback sweep target must re-extend from the player, got {reach:F3} m out");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// First-contact model of an infinite wall plane at X = WallX: requests whose
// ray from the pivot crosses the plane stop AT the plane; others pass through.
private sealed class WallPlaneProbe : ICameraCollisionProbe
{
public float WallX = -1f;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
if (desiredEye.X >= WallX || desiredEye.X - pivot.X >= -1e-6f)
return new CameraSweepResult(desiredEye, cellId);
float t = (WallX - pivot.X) / (desiredEye.X - pivot.X);
return new CameraSweepResult(pivot + (desiredEye - pivot) * t, cellId);
}
}
[Fact]
public void Update_PressedAgainstWall_EyeGlidesStably()
{
// The wall-press equilibrium: the sought parks one step past the contact,
// the sweep clips it back, and the published eye stays glued to the wall
// with no oscillation — retail's glide. (This was the fear behind the old
// "must not feed back" comment; the retail shape is stable by construction
// because the sweep target converges instead of fighting the full boom.)
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var cam = new RetailChaseCamera { CollisionProbe = new WallPlaneProbe { WallX = -1f } };
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
// Settle a few frames, then watch 30 frames for per-frame jumps.
for (int i = 0; i < 5; i++) Step();
Vector3 prev = cam.Position;
float maxDelta = 0f;
for (int i = 0; i < 30; i++)
{
Step();
maxDelta = MathF.Max(maxDelta, Vector3.Distance(cam.Position, prev));
prev = cam.Position;
}
Assert.True(MathF.Abs(cam.Position.X - (-1f)) < 1e-3f,
$"eye should sit on the wall plane, got {cam.Position}");
Assert.True(maxDelta < 1e-3f,
$"pressed against a wall the eye must not jump frame-to-frame, max delta {maxDelta:F5} m");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
} }
// ── Convergence snap (Part 1: kills the at-rest boom drift) ──────── // ── Convergence snap (Part 1: kills the at-rest boom drift) ────────