acdream/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
Erik 9e70031bc6 feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks)
Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye
now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end:

- Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT
  via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector,
  head up at floor level); outdoor keeps the player cell.
- Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags).
- Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell.
- Fallback 1 (pc:92878): AdjustPosition(sought_eye).
- Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This
  also makes cellId==0 faithful (was returning the desired eye; retail snaps to
  player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye.

Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail
find_valid_position != 0, pc:273898) so SweepEye knows when to fall back.

TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2,
SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto-
advance feet->room, so the pivot-seated start cell is genuinely decisive. Core
1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression.

Per the live-capture finding, the visible payoff is the cellar-corner (point 3);
the cottage-room bluish void stays for residual C. Spec:
docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:10:32 +02:00

608 lines
23 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_CollisionDoesNotCorruptDampedState()
{
// 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 };
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)
Step(); // frame 2: releases
// 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}");
}
}