diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index 84eb67a..c5378e2 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
new file mode 100644
index 0000000..e1dc141
--- /dev/null
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Rendering;
+
+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.
+///
+///
+/// Sits behind
+/// next to the legacy ; both update every
+/// frame so toggling the flag swaps cameras instantly. Visible behavior
+/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on
+/// hills, jump-feedback without the legacy _trackedZ hack.
+///
+///
+///
+/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
+///
+///
+public sealed class RetailChaseCamera : ICamera
+{
+ // ICamera surface — filled in by Task 3.
+ public Vector3 Position { get; private set; }
+ public float Aspect { get; set; } = 16f / 9f;
+ public float FovY { get; set; } = MathF.PI / 3f;
+ public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
+ public Matrix4x4 Projection =>
+ Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
+
+ // Math primitives — pure, internal-static for unit-testability.
+
+ ///
+ /// Pick the heading vector that drives the camera basis. Slope-
+ /// aligned when velocity is non-trivial and the toggle is on; flat
+ /// fallback otherwise. Matches retail's target_status &
+ /// ALIGN_WITH_PLANE path with the contact-plane branch
+ /// collapsed into the flat fallback.
+ ///
+ internal static Vector3 ComputeHeading(Vector3 avgVelocity, float yaw, bool alignToSlope)
+ {
+ if (alignToSlope && avgVelocity.LengthSquared() > 1e-4f)
+ return Vector3.Normalize(avgVelocity);
+
+ return new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
+ }
+
+ ///
+ /// Build an orthonormal basis with forward = heading. World
+ /// up is (0, 0, 1); if heading is near-parallel to it
+ /// the right axis falls back to world +X so the cross
+ /// product doesn't collapse.
+ ///
+ internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading)
+ {
+ Vector3 forward = Vector3.Normalize(heading);
+ Vector3 worldUp = new(0f, 0f, 1f);
+
+ Vector3 right;
+ if (MathF.Abs(forward.Z) > 0.99f)
+ {
+ // Near-vertical forward — use world +X as the secondary axis.
+ right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
+ }
+ else
+ {
+ right = Vector3.Normalize(Vector3.Cross(forward, worldUp));
+ }
+ Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal)
+ return (forward, right, up);
+ }
+
+ ///
+ /// FIFO-push a velocity sample into the 5-entry ring. Returns the
+ /// updated ring (mutates the input array; the return is for fluent
+ /// usage in tests). grows from 0 toward 5
+ /// and stays at 5 once the ring is full.
+ ///
+ internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample)
+ {
+ if (ring.Length != 5)
+ throw new ArgumentException("velocity ring must have 5 entries", nameof(ring));
+
+ // Shift left by 1 (oldest is overwritten), append new sample at the tail.
+ for (int i = 0; i < 4; i++) ring[i] = ring[i + 1];
+ ring[4] = sample;
+ if (count < 5) count++;
+ return ring;
+ }
+
+ ///
+ /// Average the most-recent entries of the
+ /// ring (entries [5-count .. 5)). Returns
+ /// when count is zero.
+ ///
+ internal static Vector3 AverageVelocity(Vector3[] ring, int count)
+ {
+ if (count == 0) return Vector3.Zero;
+ Vector3 sum = Vector3.Zero;
+ for (int i = 5 - count; i < 5; i++) sum += ring[i];
+ return sum / count;
+ }
+
+ ///
+ /// Exponential-damping rate per frame.
+ /// alpha = clamp(stiffness * dt * 10, 0, 1). At
+ /// stiffness=0.45, dt=1/60 → ~0.075
+ /// (~150 ms half-life). Matches retail's
+ /// x_1 = stiffness * dt * 10 formulation.
+ ///
+ internal static float ComputeDampingAlpha(float stiffness, float dt)
+ {
+ float a = stiffness * dt * 10f;
+ if (a <= 0f) return 0f;
+ if (a >= 1f) return 1f;
+ return a;
+ }
+
+ ///
+ /// Low-pass filter for a single mouse axis. Mirrors retail's
+ /// CameraSet::FilterMouseInput: if last sample was within
+ /// , blend output with the average of
+ /// (previous, raw); otherwise pass-through. Final output =
+ /// raw * (1 - weight) + blended * weight. Updates
+ /// and
+ /// to the new state.
+ ///
+ internal static float FilterMouseAxis(
+ float raw,
+ float weight,
+ float nowSec,
+ ref float lastDelta,
+ ref float lastTimeSec,
+ float windowSec)
+ {
+ float avg;
+ if (nowSec - lastTimeSec < windowSec)
+ avg = (lastDelta + raw) * 0.5f;
+ else
+ avg = raw;
+
+ float output = raw * (1f - weight) + avg * weight;
+ lastDelta = output;
+ lastTimeSec = nowSec;
+ return output;
+ }
+
+ ///
+ /// Player-mesh translucency as a function of camera-to-pivot
+ /// distance. 0 = fully opaque, 1 = fully transparent.
+ /// Opaque at and beyond 0.45 m; fully transparent at and within
+ /// 0.20 m; linear ramp between. Matches retail's CameraSet::
+ /// UpdateCamera distance check (decomp :97703–97725).
+ ///
+ internal static float ComputeTranslucency(float distance)
+ {
+ const float Far = 0.45f;
+ const float Near = 0.20f;
+
+ if (distance >= Far) return 0f;
+ if (distance <= Near) return 1f;
+ // Linear: t = 1 - (Near - distance) / (Near - Far)
+ return 1f - (Near - distance) / (Near - Far);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
new file mode 100644
index 0000000..ddcaae3
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Numerics;
+using AcDream.App.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, 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_MovingHorizontal_MatchesNormalizedVelocity()
+ {
+ var avgVel = new Vector3(3f, 0f, 0f);
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
+ Assert.Equal(1f, h.X, 5);
+ Assert.Equal(0f, h.Y, 5);
+ Assert.Equal(0f, h.Z, 5);
+ }
+
+ [Fact]
+ public void Heading_MovingUphill_HasPositiveZ()
+ {
+ var avgVel = new Vector3(1f, 0f, 0.5f);
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
+ Assert.True(h.Z > 0f, $"expected positive Z component, got {h.Z}");
+ }
+
+ [Fact]
+ public void Heading_SlopeAlignDisabled_IgnoresVelocity()
+ {
+ var avgVel = new Vector3(0f, 0f, 1f); // pure upward; would dominate if slope-align were on
+ float yaw = 0f;
+
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, 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);
+ }
+
+ // ── 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);
+ }
+}