diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index 25fa2a9..0470311 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -52,6 +52,14 @@ public sealed class RetailChaseCamera : ICamera
/// Height of look-at anchor above the player's feet (m). Retail default 1.5.
public float PivotHeight { get; set; } = 1.5f;
+ ///
+ /// Optional spring-arm collision probe. When set (and
+ /// is true), the damped eye
+ /// is swept from the head-pivot and stopped at the first wall. Null leaves
+ /// the eye uncollided (the default for tests and the legacy path).
+ ///
+ public ICameraCollisionProbe? CollisionProbe { get; init; }
+
/// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.
public float PlayerTranslucency { get; private set; }
@@ -89,7 +97,9 @@ public sealed class RetailChaseCamera : ICamera
Vector3 playerVelocity,
bool isOnGround,
Vector3 contactPlaneNormal,
- float dt)
+ float dt,
+ uint cellId = 0,
+ uint selfEntityId = 0)
{
// 1. Push velocity into 5-frame ring, get average.
PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
@@ -132,6 +142,14 @@ public sealed class RetailChaseCamera : ICamera
_dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
}
+ // 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
+ // (0x00453ce0) sweeps viewer_sphere from the head-pivot to the
+ // desired eye and uses the stopped position. Keeps the eye out of
+ // walls so the A8.F camera-cell + portal side-tests stay stable.
+ // A null probe or disabled flag leaves the eye unchanged.
+ if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
+ _dampedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
+
// 6. Publish renderer surface.
Position = _dampedEye;
View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f));
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index ed7bef4..234f3a7 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -445,4 +445,66 @@ public class RetailChaseCameraTests
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 Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
+ {
+ Calls++;
+ return ReturnEye;
+ }
+ }
+
+ [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.True(probe.Calls >= 1);
+ Assert.Equal(collided, cam.Position);
+ }
+
+ [Fact]
+ public void Update_FlagOff_DoesNotConsultProbe()
+ {
+ CameraDiagnostics.CollideCamera = false;
+ 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);
+
+ CameraDiagnostics.CollideCamera = true; // reset
+ }
+
+ [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);
+ }
}