acdream/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
Erik 48aaab811c fix #180: port retail's stateful camera sought-position - the sweep target converges onto the wall contact
The camera-collision sweep strobed the eye 0.27m every ~5-10 frames while
the compressed chase boom moved along corridor walls (pulledIn 0.27<->0.53
on ~1.4mm input drift): RetailChaseCamera re-demanded the FULL-length ideal
boom from scratch each frame, so the pivot->eye ray re-rolled the same
knife-edge r+-eps graze on the double-faced slabs every frame, and its two
first-contact solutions tear-interleaved at ~1700fps into the #176
"stripes/triangles".

Retail never re-rolls that ray. CameraManager::UpdateCamera (0x00456660)
interpolates FROM THE CURRENT SWEPT VIEWER toward the desired pose
(interpolate_origin/rotation, stiffness 0.45 x dt x 10, clamped) and the
result becomes viewer_sought_position (SmartBox::PlayerPhysicsUpdatedCallback
0x00452d60); update_viewer (0x00453ce0) sweeps pivot->SOUGHT. Pressed against
a wall the sweep ray extends one interpolation step past the contact
(sub-mm at high fps), so a bistable graze can move the eye by at most that
step - the strobe is structurally impossible. A 0.4mm/2e-4 dead-band parks
the sought exactly on the viewer when converged (0x00456fcd-0x00457035).

- RetailChaseCamera: _dampedEye -> _soughtEye + _publishedEye (retail's two
  Positions); lerp base = the published (swept) viewer; sweep targets the
  sought; total-fallback (ViewerCellId==0) resets the sought like
  set_viewer(player_pos, 1). The old "collision must NOT feed back into the
  damped state" comment had the coupling backwards - what stays clean is the
  transient desired pose, not the sought.
- SweepEye untouched (faithful update_viewer port, exonerated by the #180
  investigation).
- Tests: the old pin asserting instant full re-extension after a clamp
  (the divergence itself) replaced with four retail pins: gradual
  re-extension, sweep-target-converges-onto-contact, total-fallback
  re-extends from the player, wall-press glide stability.
- Pseudocode doc: docs/research/2026-07-06-camera-sought-position-pseudocode.md
  (UpdateCamera tail incl. the sought derivation + set_viewer reset semantics
  + Frame interpolate/close_rotation).
- Register: AD-37 (forward-vector nlerp vs quaternion slerp), AD-38
  (init-at-full-extension vs retail re-extend-from-player) - both
  pre-existing, identified during the decomp reading.

Suites green (Core 2599+2skip / App 729+2skip / UI 425 / Net 385).
Pending: autonomous visual verify + user gate (#180 + the #176 re-gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:49:01 +02:00

908 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class RetailChaseCameraTests
{
// ── Heading source ────────────────────────────────────────────────
[Fact]
public void Heading_StationaryWithSlopeAlign_FallsBackToYawVector()
{
var avgVel = Vector3.Zero;
float yaw = MathF.PI / 4f; // 45°
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
alignToSlope: true);
Assert.Equal(MathF.Cos(yaw), h.X, 5);
Assert.Equal(MathF.Sin(yaw), h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
public void Heading_MovingOnFlatGround_HeadingIsHorizontalFacing()
{
// Player moving forward (yaw=0 = +X), on flat ground. Heading
// should be the yaw vector — the projection onto (0,0,1)-normal
// plane is a no-op since the base is already horizontal.
var avgVel = new Vector3(3f, 0f, 0f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
public void Heading_OnUphillSlope_TiltsWithSlope()
{
// Player facing +Y (yaw=π/2), walking up a slope rising in +Y.
// Slope normal tilts back-up: (0, -0.5, 0.866) (30° rise).
// Projection of (0,1,0) onto plane perpendicular to (0,-0.5,0.866):
// dot = 1*(-0.5) = -0.5
// projected = (0,1,0) - (0,-0.5,0.866)*(-0.5) = (0, 0.75, 0.433)
// normalized → (0, 0.866, 0.5) — slope-aligned heading with +Z tilt.
var avgVel = new Vector3(0f, 3f, 1.5f); // moving up the slope
var normal = new Vector3(0f, -0.5f, 0.866f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: MathF.PI / 2f,
isOnGround: true, contactPlaneNormal: normal,
alignToSlope: true);
Assert.True(h.Z > 0.4f, $"expected slope-aligned +Z tilt, got Z={h.Z}");
Assert.Equal(1f, h.Length(), 4);
}
[Fact]
public void Heading_AirborneJumpingStraightUp_StaysHorizontal()
{
// Player standing still, then jumps straight up. avgVel.xy is
// zero, the horizontal-velocity gate fires → returns the base
// facing direction. The vertical-velocity component is ignored.
// This is THE bug the contact-plane fix prevents: in the old
// code, normalize((0,0,5)) → (0,0,1) → camera basis tilted up.
var avgVel = new Vector3(0f, 0f, 5f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: false, contactPlaneNormal: Vector3.Zero,
alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
public void Heading_AirborneRunningJump_StaysHorizontal()
{
// Running jump: horizontal velocity nonzero, vertical also
// nonzero. Airborne path projects onto world up — strips Z
// from the (already horizontal) base heading, no-op. Camera
// basis stays horizontal even though player is rising.
var avgVel = new Vector3(3f, 0f, 4f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: false, contactPlaneNormal: Vector3.Zero,
alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
public void Heading_SlopeAlignDisabled_IgnoresVelocityAndContactPlane()
{
// Pure-vertical velocity + a tilted contact normal — neither
// should affect the heading when alignToSlope is off.
var avgVel = new Vector3(0f, 0f, 1f);
var tiltedNormal = new Vector3(0f, -0.5f, 0.866f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: true, contactPlaneNormal: tiltedNormal,
alignToSlope: false);
Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0)
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
// ── Basis from heading ────────────────────────────────────────────
[Fact]
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
{
var (forward, right, up) = RetailChaseCamera.BuildBasis(new Vector3(1f, 0f, 0f));
Assert.Equal(1f, forward.Length(), 5);
Assert.Equal(1f, right.Length(), 5);
Assert.Equal(1f, up.Length(), 5);
// Orthogonal
Assert.Equal(0f, Vector3.Dot(forward, right), 5);
Assert.Equal(0f, Vector3.Dot(forward, up), 5);
Assert.Equal(0f, Vector3.Dot(right, up), 5);
// forward = (1,0,0), world up = (0,0,1) → right = (0,-1,0), camera-up = (0,0,1)
Assert.Equal(0f, up.X, 5);
Assert.Equal(0f, up.Y, 5);
Assert.True(up.Z > 0f);
}
[Fact]
public void Basis_NearVerticalHeading_UsesXFallbackForRight()
{
// forward nearly straight up (rare; airborne edge case). Must not produce
// a zero-length right vector from cross(forward, worldUp).
var (_, right, up) = RetailChaseCamera.BuildBasis(new Vector3(0f, 0f, 1f));
Assert.Equal(1f, right.Length(), 5);
Assert.Equal(1f, up.Length(), 5);
Assert.Equal(0f, Vector3.Dot(right, up), 5);
}
// ── Velocity ring & averaging ────────────────────────────────────
[Fact]
public void VelocityRing_AveragesLastN()
{
var ring = new Vector3[5];
int count = 0;
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(3, 0, 0));
Assert.Equal(5, count);
var avg = RetailChaseCamera.AverageVelocity(ring, count);
Assert.Equal(1.8f, avg.X, 5);
Assert.Equal(0f, avg.Y, 5);
Assert.Equal(0f, avg.Z, 5);
}
[Fact]
public void VelocityRing_FifoEvictsOldest()
{
var ring = new Vector3[5];
int count = 0;
// Push 6 entries; oldest (the first 1,0,0) should be evicted.
for (int i = 0; i < 5; i++)
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(10, 0, 0));
Assert.Equal(5, count); // still capped at 5
// Sum of newest 5 entries: 4*(1,0,0) + (10,0,0) = (14,0,0), avg = 2.8
var avg = RetailChaseCamera.AverageVelocity(ring, count);
Assert.Equal(2.8f, avg.X, 5);
}
[Fact]
public void VelocityRing_PartialFillUsesActualCount()
{
var ring = new Vector3[5];
int count = 0;
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(4, 0, 0));
Assert.Equal(2, count);
var avg = RetailChaseCamera.AverageVelocity(ring, count);
Assert.Equal(3f, avg.X, 5); // (2+4)/2, not (2+4)/5
}
// ── Damping alpha ────────────────────────────────────────────────
[Fact]
public void DampingAlpha_RetailDefault_ProducesSevenAndAHalfPercent()
{
// stiffness=0.45, dt=1/60 → 0.45 * (1/60) * 10 ≈ 0.075
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f / 60f);
Assert.Equal(0.075f, alpha, 4);
}
[Fact]
public void DampingAlpha_LargeDtClampsToOne()
{
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f);
Assert.Equal(1f, alpha);
}
[Fact]
public void DampingAlpha_NegativeOrZero_ClampsToZero()
{
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 0f));
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.0f, dt: 1f));
}
// ── Mouse low-pass ───────────────────────────────────────────────
[Fact]
public void MouseFilter_BeyondWindow_OutputsRaw()
{
float lastDelta = 5f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 10f, weight: 0.5f, nowSec: 1.0f,
ref lastDelta, ref lastTime, windowSec);
// Beyond window, blended == raw, so out = raw * 0.5 + raw * 0.5 = raw.
Assert.Equal(10f, result, 5);
}
[Fact]
public void MouseFilter_WithinWindow_AveragesWithPrevious()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 0.5f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
// Within window: avg = (10 + 20)/2 = 15.
// Output: 20 * 0.5 + 15 * 0.5 = 17.5
Assert.Equal(17.5f, result, 5);
}
[Fact]
public void MouseFilter_WeightZero_OutputsRaw()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 0f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
Assert.Equal(20f, result, 5);
}
[Fact]
public void MouseFilter_WeightOne_OutputsAveraged()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 1f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
// weight=1 → out = avg = 15
Assert.Equal(15f, result, 5);
}
[Fact]
public void MouseFilter_UpdatesLastDeltaAndTime()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 0.5f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
Assert.Equal(result, lastDelta); // last is updated to output
Assert.Equal(0.1f, lastTime, 5); // last time advances
}
// ── Auto-fade translucency ───────────────────────────────────────
[Fact]
public void Translucency_AtFarThreshold_IsZero()
{
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 0.45f), 5);
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 1.00f), 5);
}
[Fact]
public void Translucency_MidwayBetweenThresholds_IsHalf()
{
// Midpoint between 0.20 and 0.45 = 0.325
// t = 1 - (0.20 - 0.325) / (0.20 - 0.45)
// = 1 - (-0.125) / (-0.25)
// = 1 - 0.5 = 0.5
Assert.Equal(0.5f, RetailChaseCamera.ComputeTranslucency(distance: 0.325f), 4);
}
[Fact]
public void Translucency_AtNearThreshold_IsOne()
{
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.20f), 5);
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
}
// ── Update() integration ─────────────────────────────────────────
[Fact]
public void FirstUpdate_SnapsToTarget()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
cam.Update(
playerPosition: new Vector3(10f, 20f, 30f),
playerYaw: 0f, // forward = +X
playerVelocity: Vector3.Zero,
isOnGround: true,
contactPlaneNormal: Vector3.UnitZ, // flat
dt: 1f / 60f);
// Expected target eye:
// pivot = (10, 20, 30+1.5=31.5)
// forward (yaw=0)= (1, 0, 0)
// right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0)
// up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1)
// viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0)
// eye = pivot + right*0 + forward*-5 + up*0
// = (10 - 5, 20, 31.5) = (5, 20, 31.5)
Assert.Equal(5f, cam.Position.X, 4);
Assert.Equal(20f, cam.Position.Y, 4);
Assert.Equal(31.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
}
[Fact]
public void SecondUpdate_LerpsTowardTarget()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
float savedTranslation = CameraDiagnostics.TranslationStiffness;
float savedRotation = CameraDiagnostics.RotationStiffness;
try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
// First update at origin: dampedEye = (-5, 0, 1.5).
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
var firstEye = cam.Position;
// Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5).
// alpha = 0.45 * (1/60) * 10 = 0.075.
// New eye = firstEye + 0.075 * (target - firstEye)
// = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5))
// = (-5,0,1.5) + 0.075 * (10,0,0)
// = (-4.25, 0, 1.5)
cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(-4.25f, cam.Position.X, 3);
Assert.Equal(0f, cam.Position.Y, 4);
Assert.Equal(1.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.TranslationStiffness = savedTranslation;
CameraDiagnostics.RotationStiffness = savedRotation;
}
}
[Fact]
public void Translucency_PropertyReflectsCurrentDampedDistance()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
CameraDiagnostics.AlignToSlope = false;
// Far from pivot — translucency should be 0.
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(0f, cam.PlayerTranslucency, 5);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
}
[Fact]
public void AdjustDistance_ClampsToRange()
{
var cam = new RetailChaseCamera { Distance = 5f };
cam.AdjustDistance(-100f);
Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance);
cam.AdjustDistance(+200f);
Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
}
[Fact]
public void AdjustPitch_ClampsToRange()
{
var cam = new RetailChaseCamera { Pitch = 0f };
cam.AdjustPitch(-10f);
Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch);
cam.AdjustPitch(+10f);
Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
}
// ── Camera collision (A8.F) ───────────────────────────────────────
private sealed class FakeProbe : ICameraCollisionProbe
{
public int Calls;
public Vector3 ReturnEye;
public uint ReturnCell;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Calls++;
return new CameraSweepResult(ReturnEye, ReturnCell);
}
}
[Fact]
public void Update_WithProbeAndFlagOn_PublishesCollidedEye()
{
CameraDiagnostics.CollideCamera = true;
var collided = new Vector3(1f, 2f, 3f);
var probe = new FakeProbe { ReturnEye = collided };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.Equal(1, probe.Calls);
Assert.Equal(collided, cam.Position);
}
[Fact]
public void Update_WithProbeAndFlagOn_ExposesSweptViewerCell()
{
CameraDiagnostics.CollideCamera = true;
var probe = new FakeProbe { ReturnEye = new Vector3(1f, 2f, 3f), ReturnCell = 0xA9B40170u };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0xA9B40171u, selfEntityId: 0x5);
Assert.Equal(0xA9B40170u, cam.ViewerCellId); // the swept cell, not the player cell
}
[Fact]
public void Update_FlagOff_ViewerCellFallsBackToPlayerCell()
{
CameraDiagnostics.CollideCamera = false;
try
{
var cam = new RetailChaseCamera { CollisionProbe = new FakeProbe { ReturnCell = 0xDEADu } };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0xA9B40171u, selfEntityId: 0x5);
Assert.Equal(0xA9B40171u, cam.ViewerCellId); // collision off → the passed player cell
}
finally { CameraDiagnostics.CollideCamera = true; }
}
[Fact]
public void Update_FlagOff_DoesNotConsultProbe()
{
CameraDiagnostics.CollideCamera = false;
try
{
var probe = new FakeProbe { ReturnEye = new Vector3(99f, 99f, 99f) };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.Equal(0, probe.Calls);
Assert.NotEqual(new Vector3(99f, 99f, 99f), cam.Position);
}
finally
{
CameraDiagnostics.CollideCamera = true; // reset even if an assert throws
}
}
[Fact]
public void Update_NullProbe_DoesNotThrow()
{
CameraDiagnostics.CollideCamera = true;
var cam = new RetailChaseCamera { CollisionProbe = null };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.NotEqual(default, cam.View);
}
[Fact]
public void Update_ProbePullsEyeInClose_FullyFadesPlayer()
{
// Spec §9 / retail stage 3: when the collided eye ends up very close to
// the head-pivot, the player mesh fades. Pivot = (0,0,1.5); a collided
// eye 0.1 m above it (≤ the 0.20 m full-fade threshold) → translucency 1.
CameraDiagnostics.CollideCamera = true;
var pulledIn = new Vector3(0f, 0f, 1.6f);
var cam = new RetailChaseCamera { CollisionProbe = new FakeProbe { ReturnEye = pulledIn } };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.Equal(pulledIn, cam.Position);
Assert.Equal(1f, cam.PlayerTranslucency, 3);
}
// Probe that clamps the eye to a fixed point on the FIRST call, then
// releases (returns the requested eye unchanged) on later calls.
private sealed class ClampThenReleaseProbe : ICameraCollisionProbe
{
public int Calls;
public Vector3 ClampEye;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Calls++;
return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId);
}
}
[Fact]
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
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) };
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: 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) ────────
[Fact]
public void ConvergenceSnap_StepBelowEpsilon_FreezesAtCurrent()
{
// Both the translation step and the rotation step are below the retail snap
// thresholds (0.0004 m / 0.0002) → freeze: return the CURRENT damped state,
// not the candidate. This is the exact fixed point retail's UpdateCamera reaches.
var damped = new Vector3(5f, 6f, 7f);
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
var candidate = damped + new Vector3(0.0001f, 0f, 0f); // 0.1 mm step < 0.4 mm
var candFwd = forward; // no rotation step
var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
Assert.True(frozen);
Assert.Equal(damped, eye); // exact — returns the input, freezing the drift
Assert.Equal(forward, fwd);
}
[Fact]
public void ConvergenceSnap_TranslationStepAboveEpsilon_ReturnsCandidate()
{
var damped = new Vector3(5f, 6f, 7f);
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
var candidate = damped + new Vector3(0.01f, 0f, 0f); // 1 cm step ≫ 0.4 mm
var candFwd = forward;
var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
Assert.False(frozen);
Assert.Equal(candidate, eye); // still converging → apply the lerp step
Assert.Equal(candFwd, fwd);
}
[Fact]
public void ConvergenceSnap_RotationStepAboveEpsilon_ReturnsCandidate()
{
// Translation has converged but the heading is still turning — retail does NOT
// freeze unless BOTH are close (it returns the interpolated frame). So a small
// translation step must NOT freeze while the forward is still rotating.
var damped = new Vector3(5f, 6f, 7f);
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
var candidate = damped + new Vector3(0.0001f, 0f, 0f); // sub-epsilon translation
var candFwd = Vector3.Normalize(new Vector3(1f, 0.05f, 0f)); // ~0.05 rad turn ≫ 0.0002
var (_, _, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
Assert.False(frozen);
}
[Fact]
public void Update_AtRestAfterConvergence_BoomFreezesAtExactFixedPoint()
{
// The retail UpdateCamera snap freezes the boom at an exact fixed point once the
// per-frame step falls below ~0.4 mm. Without it, Vector3.Lerp asymptotes forever
// — the eye dithers sub-millimetre every frame and walks across the portal plane,
// flipping the viewer cell (the indoor flicker). Hold a pose DIFFERENT from the
// init pose so the boom has to converge over many frames; with collision OFF
// (Position == _dampedEye), two consecutive post-convergence frames must be
// BIT-IDENTICAL. (At frame 120, α≈0.075, displacement ~7 m, the un-snapped step is
// ~5e-5 m ≈ tens of float ULP — distinguishably nonzero — so this is a real RED.)
bool savedAlign = CameraDiagnostics.AlignToSlope;
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.AlignToSlope = false; // deterministic heading
CameraDiagnostics.CollideCamera = false; // Position == _dampedEye
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var cam = new RetailChaseCamera { Distance = 2.61f, Pitch = 0.291f };
// Frame 1 at pose A: init snaps the damped eye to A's target.
cam.Update(Vector3.Zero, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
// Hold pose B for many frames → the boom lerps A's target → B's target.
var posB = new Vector3(5f, 5f, 0f);
for (int i = 0; i < 120; i++)
cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
Vector3 a = cam.Position;
cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
Vector3 b = cam.Position;
Assert.Equal(a, b); // exact — frozen, not dithering
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
}