From 48aaab811c3fb454ed18dcc43f10b860ce4ee5de Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 6 Jul 2026 16:49:01 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 8 +- .../retail-divergence-register.md | 2 + ...07-06-camera-sought-position-pseudocode.md | 268 ++++++++++++++++++ .../Rendering/RetailChaseCamera.cs | 105 ++++--- .../Rendering/RetailChaseCameraTests.cs | 244 ++++++++++++++-- 5 files changed, 571 insertions(+), 56 deletions(-) create mode 100644 docs/research/2026-07-06-camera-sought-position-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9f442dc1..f64af641 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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") -**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) **Filed:** 2026-07-06 **Component:** camera / physics (NOT render β€” every render suspect eliminated by isolation) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 56fa7242..b3299213 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -96,6 +96,8 @@ accepted-divergence entries (#96, #49, #50). | AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList`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-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 | --- diff --git a/docs/research/2026-07-06-camera-sought-position-pseudocode.md b/docs/research/2026-07-06-camera-sought-position-pseudocode.md new file mode 100644 index 00000000..b718f8d6 --- /dev/null +++ b/docs/research/2026-07-06-camera-sought-position-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 553e977c..0eb4b474 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -8,8 +8,12 @@ namespace AcDream.App.Rendering; /// Retail-faithful chase camera. Ports the chase-cam behavior from the /// 2013 acclient (CameraManager + CameraSet, decomp at /// docs/research/named-retail/acclient_2013_pseudo_c.txt:95505): -/// exponential damping toward a target pose, 5-frame velocity-averaged -/// slope-aligned heading frame, mouse-input low-pass filter. +/// a STATEFUL sought position that converges from the current swept +/// viewer toward the desired boom pose (CameraManager::UpdateCamera +/// 0x00456660 β†’ viewer_sought_position, the #180 fix), 5-frame +/// velocity-averaged slope-aligned heading frame, mouse-input low-pass +/// filter. Pseudocode: +/// docs/research/2026-07-06-camera-sought-position-pseudocode.md. /// /// /// Sits behind @@ -97,11 +101,23 @@ public sealed class RetailChaseCamera : ICamera private const float SnapEpsilon = 0.000199999995f * 2f; 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 int _velocityCount; - private Vector3 _dampedEye; + private Vector3 _soughtEye; + private Vector3 _publishedEye; private Vector3 _dampedForward = new(1f, 0f, 0f); private bool _initialised; @@ -155,10 +171,18 @@ public sealed class RetailChaseCamera : ICamera Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical; 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) { - _dampedEye = targetEye; + _soughtEye = targetEye; + _publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player) _dampedForward = targetForward; _initialised = true; } @@ -166,36 +190,48 @@ public sealed class RetailChaseCamera : ICamera { float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, 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)); - // Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed - // point once the lerp step is sub-epsilon, instead of dithering forever. This is - // the at-rest flicker fix β€” see ApplyConvergenceSnap + SnapEpsilon. - (_dampedEye, _dampedForward, _) = - ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward); + // Retail UpdateCamera dead-band (0x00456fcd–0x00457035): once the step + // off the viewer is sub-epsilon in translation AND rotation, the sought + // parks EXACTLY ON the viewer β€” an exact fixed point instead of an + // asymptote. Kills the at-rest drift AND the residual micro-jitter when + // pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon. + (_soughtEye, _dampedForward, _) = + ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward); } - // 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer - // (0x00453ce0) keeps TWO states: viewer_sought_position (the damped - // desired eye) and viewer (the published eye = set_viewer(curr_pos)). - // The collision produces the PUBLISHED eye each frame but must NOT - // feed back into the damped state β€” writing the clamped result into - // _dampedEye makes next frame's lerp start from the wall and fight - // the clamp, which shows up as visible oscillation/vibration when the - // eye is pressed against a wall. So collide into a separate local and - // leave _dampedEye as the clean, uncollided sought position. - Vector3 publishedEye = _dampedEye; + // 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer + // (0x00453ce0) sweeps the viewer_sphere pivot β†’ viewer_sought_position + // and publishes the swept result as the viewer (set_viewer(curr_pos, 0) + // β€” the sought is NOT reset on success). Pressed against a wall, the + // sweep ray extends only one interpolation step past the contact, so a + // knife-edge rΒ±Ξ΅ graze can move the eye by at most that step (sub-mm at + // high fps) instead of re-solving the full-length boom with its 0.27 m + // bistable contact pair β€” the #180 strobe fix. + Vector3 publishedEye = _soughtEye; // 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 // robust per-frame "which cell is the camera in?" answer. ViewerCellId = cellId; 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; 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 // smoothly-damped look direction toward the pivot). @@ -396,22 +432,23 @@ public sealed class RetailChaseCamera : ICamera } /// - /// Retail CameraManager::UpdateCamera convergence snap (decomp 0x00456fcd). - /// After the per-frame lerp, if the translation step from - /// to is below AND the - /// rotation step is below , retail returns the input - /// position unchanged β€” an exact fixed point. Returns frozen=true with the - /// current state in that case; otherwise frozen=false with the candidate. - /// Both conditions are required (retail couples origin + rotation in the snap test), + /// Retail CameraManager::UpdateCamera dead-band (decomp 0x00456fcd–0x00457035). + /// After the per-frame lerp, if the translation step from + /// (the interpolation base = the current swept viewer) to + /// is below AND the rotation step is below + /// , retail returns the VIEWER unchanged β€” the sought + /// parks exactly on it (return viewer, 0x00457025). Returns frozen=true + /// with the viewer state in that case; otherwise frozen=false 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. /// 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 rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon; + bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon; + bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon; 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); } diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index d4e60347..db57c369 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -579,31 +579,233 @@ public class RetailChaseCameraTests } [Fact] - public void Update_CollisionDoesNotCorruptDampedState() + public void Update_AfterClampReleases_EyeReExtendsGradually() { - // 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; - var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) }; - var cam = new RetailChaseCamera { CollisionProbe = probe }; + // #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 + { + CameraDiagnostics.CollideCamera = true; + CameraDiagnostics.TranslationStiffness = 0.45f; + CameraDiagnostics.RotationStiffness = 0.45f; - void Step() => cam.Update( - playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, - isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, - cellId: 0x100, selfEntityId: 0x5); + var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) }; + var cam = new RetailChaseCamera { CollisionProbe = probe }; - Step(); // frame 1: clamps to (0,0,2) - Step(); // frame 2: releases + void Step() => cam.Update( + playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, + isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, + cellId: 0x100, selfEntityId: 0x5); - // Constant pose β†’ target eye β‰ˆ (-2.5, 0, 2.25). Full recovery means - // Position.X is near the target (< -2), not pinned near the clamp (Xβ‰ˆ0). - Assert.True(cam.Position.X < -2f, - $"published eye should fully recover to the target after release, got {cam.Position}"); + Step(); // frame 1: clamps to (0,0,2) β€” the viewer sits at the clamp + Step(); // frame 2: releases + + // Constant pose β†’ target eye β‰ˆ (-2.5, 0, 2.25). Frame 2's eye is one + // 7.5% lerp step off the clamp (X β‰ˆ -0.19) β€” near the clamp, NOT the + // full target. + 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 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 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) ────────