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
|
|
@ -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<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) ────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue