acdream/docs/superpowers/plans/2026-05-18-retail-chase-camera.md
Erik 73dee43d14 docs(camera): impl plan — retail-faithful chase camera with dev-tools toggle
8 tasks: CameraDiagnostics static (Task 1) → RetailChaseCamera math
primitives (Task 2) → Update() integration (Task 3) → CameraController
dual-camera (Task 4) → InputAction + DebugVM mirrors (Task 5) →
DebugPanel section (Task 6) → GameWindow wiring (Task 7) → build +
test + visual handoff (Task 8). Each task is TDD-shaped with exact
code in every step. PlayerTranslucency is computed + tested but
applying to the player mesh is explicitly deferred (Q1 escape clause
in the spec).

For docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:24:34 +02:00

66 KiB
Raw Blame History

Retail-faithful chase camera with dev-tools toggle — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Port retail's CameraManager + CameraSet chase-cam behavior — exponential damping, slope-aligned heading frame, mouse low-pass — to a new RetailChaseCamera class controlled by a CameraDiagnostics flag, with sliders in the DebugPanel for live tuning.

Architecture: Two ICamera implementations coexist (ChaseCamera legacy + RetailChaseCamera new). CameraController carries both and exposes Active based on a CameraDiagnostics.UseRetailChaseCamera flag. Both update every frame so toggle swaps are instant. DebugVM mirrors CameraDiagnostics statics as properties; the new "Chase camera" DebugPanel section writes through those mirrors.

Tech Stack: C# .NET 10, System.Numerics, Silk.NET (windowing), ImGui.NET (panel rendering), xUnit (tests).

Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md


File structure

New files:

Path Responsibility
src/AcDream.Core/Rendering/CameraDiagnostics.cs Static class owning the six tunables (toggle, slope-align, 2× stiffness, low-pass window, adjustment speed). Env-var defaults + setter passthrough.
src/AcDream.App/Rendering/RetailChaseCamera.cs The new camera. Implements ICamera. Holds damped state (eye, forward), 5-frame velocity ring, mouse-filter state. Math primitives internal static for testability.
tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs Default values + env-var parse + setter passthrough.
tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs Five test groups: heading, velocity ring, damping, mouse low-pass, auto-fade.

Modified files:

Path What changes
src/AcDream.App/Rendering/CameraController.cs Carry RetailChase alongside Chase. Active consults flag. EnterChaseMode takes both.
src/AcDream.UI.Abstractions/Input/InputAction.cs Four new actions: CameraZoomIn, CameraZoomOut, CameraRaise, CameraLower. Default-unbound.
src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs Six new mirror properties forwarding to CameraDiagnostics (one for each tunable).
src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs New DrawChaseCamera(r) method called from Render. Toggle + slope-align checkbox + four sliders.
src/AcDream.App/Rendering/GameWindow.cs Construct both cameras at chase entry, update both per frame with new playerVelocity arg, hold-key offset integration via new InputActions, mouse-Y goes through RetailChaseCamera.FilterMouseDelta before AdjustPitch.
src/AcDream.App/AcDream.App.csproj (if not already) InternalsVisibleTo("AcDream.App.Tests") so the test project can call the camera's internal math helpers.

Deferred to a follow-up (#post-camera-toggle issue): applying PlayerTranslucency to the player mesh via the WB draw dispatcher. The math is computed + tested in this plan; the wire-up is out of scope.


Task 1: CameraDiagnostics static class + tests

Files:

  • Create: src/AcDream.Core/Rendering/CameraDiagnostics.cs

  • Create: tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs

  • Step 1: Write the failing tests

Create tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs:

using AcDream.Core.Rendering;
using Xunit;

namespace AcDream.Core.Tests.Rendering;

public class CameraDiagnosticsTests
{
    // NOTE: These tests assume the env vars ACDREAM_RETAIL_CHASE and
    // ACDREAM_CAMERA_ALIGN_SLOPE are NOT set when running the test
    // suite. Static class is initialised on first access; values reflect
    // the env state at that time.

    [Fact]
    public void Defaults_AreRetailValues()
    {
        // Reset to defaults explicitly (test isolation; another test may
        // have flipped these earlier in the run).
        CameraDiagnostics.TranslationStiffness  = 0.45f;
        CameraDiagnostics.RotationStiffness     = 0.45f;
        CameraDiagnostics.MouseLowPassWindowSec = 0.25f;
        CameraDiagnostics.CameraAdjustmentSpeed = 40.0f;
        CameraDiagnostics.AlignToSlope          = true;
        CameraDiagnostics.UseRetailChaseCamera  = false;

        Assert.Equal(0.45f, CameraDiagnostics.TranslationStiffness);
        Assert.Equal(0.45f, CameraDiagnostics.RotationStiffness);
        Assert.Equal(0.25f, CameraDiagnostics.MouseLowPassWindowSec);
        Assert.Equal(40.0f, CameraDiagnostics.CameraAdjustmentSpeed);
        Assert.True(CameraDiagnostics.AlignToSlope);
        Assert.False(CameraDiagnostics.UseRetailChaseCamera);
    }

    [Fact]
    public void Setters_PersistRuntimeChanges()
    {
        CameraDiagnostics.TranslationStiffness = 0.8f;
        CameraDiagnostics.UseRetailChaseCamera = true;

        Assert.Equal(0.8f, CameraDiagnostics.TranslationStiffness);
        Assert.True(CameraDiagnostics.UseRetailChaseCamera);

        // Reset so other tests aren't poisoned.
        CameraDiagnostics.TranslationStiffness = 0.45f;
        CameraDiagnostics.UseRetailChaseCamera = false;
    }
}
  • Step 2: Run test, verify failure
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests"

Expected: compile error — CameraDiagnostics doesn't exist.

  • Step 3: Implement the class

Create src/AcDream.Core/Rendering/CameraDiagnostics.cs:

using System;

namespace AcDream.Core.Rendering;

/// <summary>
/// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors
/// the <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/> pattern:
/// static fields seeded from env vars at process start, runtime-settable
/// via property setters that the DebugPanel writes to.
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
/// </para>
/// </summary>
public static class CameraDiagnostics
{
    /// <summary>
    /// Master toggle. When false (default) the legacy
    /// <c>AcDream.App.Rendering.ChaseCamera</c> is the active camera;
    /// when true, the retail-faithful <c>RetailChaseCamera</c> is.
    /// Initial state from <c>ACDREAM_RETAIL_CHASE=1</c>.
    /// </summary>
    public static bool UseRetailChaseCamera { get; set; } =
        Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") == "1";

    /// <summary>
    /// When true (default), the camera basis follows the player's
    /// 5-frame averaged velocity vector — tilts with the terrain on
    /// hills. When false, the basis is built from a flat (yaw, 0) vector
    /// and the camera stays horizontal even on slopes. Initial state
    /// from <c>ACDREAM_CAMERA_ALIGN_SLOPE</c>; default-on if unset.
    /// </summary>
    public static bool AlignToSlope { get; set; } =
        Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0";

    /// <summary>
    /// Per-frame translation damping rate. Retail default 0.45. Higher
    /// (→ 1.0) snaps faster; lower (→ 0.0) lags more. Formula per frame:
    /// <c>alpha = clamp(TranslationStiffness * dt * 10, 0, 1)</c>.
    /// </summary>
    public static float TranslationStiffness { get; set; } = 0.45f;

    /// <summary>
    /// Per-frame rotation damping rate. Independent of translation —
    /// can be tuned higher so the camera swings to look at you faster
    /// than it physically catches up. Retail default 0.45.
    /// </summary>
    public static float RotationStiffness { get; set; } = 0.45f;

    /// <summary>
    /// Mouse-delta low-pass window (seconds). Mouse deltas spaced
    /// closer than this are averaged with the previous delta before
    /// being fed to pitch/yaw adjustments. Smooths out jitter on
    /// high-DPI mice. Retail default 0.25.
    /// </summary>
    public static float MouseLowPassWindowSec { get; set; } = 0.25f;

    /// <summary>
    /// Per-second rate that held-key offset adjustments
    /// (CameraZoomIn/Out, CameraRaise/Lower) integrate into the
    /// camera's Distance / Pitch. Retail default 40.0.
    /// </summary>
    public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
}
  • Step 4: Run tests, verify pass
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests"

Expected: PASS (2 tests).

  • Step 5: Verify full solution builds
dotnet build

Expected: build succeeded, 0 errors.

  • Step 6: Commit
git add src/AcDream.Core/Rendering/CameraDiagnostics.cs tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs
git commit -m "$(cat <<'EOF'
feat(camera): add CameraDiagnostics static tunable owner

Six knobs for the upcoming retail chase camera: UseRetailChaseCamera
master toggle (env ACDREAM_RETAIL_CHASE), AlignToSlope (env
ACDREAM_CAMERA_ALIGN_SLOPE, default on), TranslationStiffness +
RotationStiffness (both 0.45 retail default), MouseLowPassWindowSec
(0.25), CameraAdjustmentSpeed (40.0). DebugPanel mirror lands later;
this commit just stands up the static surface + defaults + tests.

Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: RetailChaseCamera math primitives (TDD)

Files:

  • Create: src/AcDream.App/Rendering/RetailChaseCamera.cs (skeleton + 5 internal static math helpers)
  • Create: tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs (math tests)
  • Modify: src/AcDream.App/AcDream.App.csproj — add InternalsVisibleTo

Strategy: Five math primitives, each written test-first. Math is pure — no instance state needed for these methods. The state-machine (velocity ring, damped pose, mouse-filter timestamps) lands in Task 3 when we assemble Update().

  • Step 1: Verify InternalsVisibleTo exists in AcDream.App.csproj

Look at src/AcDream.App/AcDream.App.csproj for an existing <InternalsVisibleTo Include="AcDream.App.Tests" /> element or an [assembly: InternalsVisibleTo("AcDream.App.Tests")] attribute in an AssemblyInfo.cs / GlobalUsings.cs / similar. If missing, add to the csproj:

<ItemGroup>
  <InternalsVisibleTo Include="AcDream.App.Tests" />
</ItemGroup>

(Use the existing <ItemGroup> if there's a logical place, otherwise create a new one near the bottom.)

  • Step 2: Write the failing tests for the math helpers

Create tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs:

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, 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);
    }
}
  • Step 3: Run tests, verify they fail to compile (class doesn't exist)
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"

Expected: compile error.

  • Step 4: Implement the math primitives in RetailChaseCamera

Create src/AcDream.App/Rendering/RetailChaseCamera.cs:

using System;
using System.Numerics;
using AcDream.Core.Rendering;

namespace AcDream.App.Rendering;

/// <summary>
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged
/// slope-aligned heading frame, mouse-input low-pass filter.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
/// next to the legacy <see cref="ChaseCamera"/>; 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 <c>_trackedZ</c> hack.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
/// </para>
/// </summary>
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.

    /// <summary>
    /// 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 <c>target_status &amp;
    /// ALIGN_WITH_PLANE</c> path with the contact-plane branch
    /// collapsed into the flat fallback.
    /// </summary>
    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);
    }

    /// <summary>
    /// Build an orthonormal basis with <c>forward = heading</c>. World
    /// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it
    /// the right axis falls back to world <c>+X</c> so the cross
    /// product doesn't collapse.
    /// </summary>
    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);
    }

    /// <summary>
    /// 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). <paramref name="count"/> grows from 0 toward 5
    /// and stays at 5 once the ring is full.
    /// </summary>
    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;
    }

    /// <summary>
    /// Average the <paramref name="count"/> most-recent entries of the
    /// ring (entries <c>[5-count .. 5)</c>). Returns
    /// <see cref="Vector3.Zero"/> when count is zero.
    /// </summary>
    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;
    }

    /// <summary>
    /// Exponential-damping rate per frame.
    /// <c>alpha = clamp(stiffness * dt * 10, 0, 1)</c>. At
    /// <c>stiffness=0.45</c>, <c>dt=1/60</c> → <c>~0.075</c>
    /// (~150 ms half-life). Matches retail's
    /// <c>x_1 = stiffness * dt * 10</c> formulation.
    /// </summary>
    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;
    }

    /// <summary>
    /// Low-pass filter for a single mouse axis. Mirrors retail's
    /// <c>CameraSet::FilterMouseInput</c>: if last sample was within
    /// <paramref name="windowSec"/>, blend output with the average of
    /// (previous, raw); otherwise pass-through. Final output =
    /// <c>raw * (1 - weight) + blended * weight</c>. Updates
    /// <paramref name="lastDelta"/> and <paramref name="lastTimeSec"/>
    /// to the new state.
    /// </summary>
    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;
    }

    /// <summary>
    /// Player-mesh translucency as a function of camera-to-pivot
    /// distance. <c>0</c> = fully opaque, <c>1</c> = fully transparent.
    /// Opaque at and beyond 0.45 m; fully transparent at and within
    /// 0.20 m; linear ramp between. Matches retail's <c>CameraSet::
    /// UpdateCamera</c> distance check (decomp :9770397725).
    /// </summary>
    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);
    }
}
  • Step 5: Run tests, verify all pass
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"

Expected: PASS (18 tests across heading / basis / ring / damping / mouse / fade groups).

  • Step 6: Verify full solution builds
dotnet build

Expected: 0 errors.

  • Step 7: Commit
git add src/AcDream.App/Rendering/RetailChaseCamera.cs src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
git commit -m "$(cat <<'EOF'
feat(camera): add RetailChaseCamera math primitives

Five pure-math helpers in the new RetailChaseCamera class:
ComputeHeading (slope-align with flat fallback), BuildBasis (heading
→ orthonormal frame, near-vertical fallback), PushVelocity +
AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's
stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency
(linear ramp 0.20..0.45 m). 18 tests, all pass. State machine + Update()
land in the next commit.

Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: RetailChaseCamera.Update() integration + damping state tests

Files:

  • Modify: src/AcDream.App/Rendering/RetailChaseCamera.cs — add state fields, Update(), Distance / Pitch / YawOffset / PivotHeight properties, AdjustPitch / AdjustDistance, PlayerTranslucency, public FilterMouseDelta wrapper.

  • Modify: tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs — add integration tests for first-frame snap + lerp-toward-target + translucency-on-actual-distance.

  • Step 1: Write failing integration tests — append to RetailChaseCameraTests.cs:

    // ── Update() integration ─────────────────────────────────────────

    [Fact]
    public void FirstUpdate_SnapsToTarget()
    {
        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,
            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);
    }

    [Fact]
    public void SecondUpdate_LerpsTowardTarget()
    {
        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, 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, 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);
    }

    [Fact]
    public void Translucency_PropertyReflectsCurrentDampedDistance()
    {
        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, dt: 1f / 60f);
        Assert.Equal(0f, cam.PlayerTranslucency, 5);
    }

    [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);
    }
  • Step 2: Run tests, verify failure
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"

Expected: compile errors (missing Update, Distance, Pitch, PivotHeight, PlayerTranslucency, AdjustDistance, AdjustPitch, DistanceMin/Max, PitchMin/Max symbols).

  • Step 3: Add state + properties + Update() in RetailChaseCamera.cs

Inside the RetailChaseCamera class, above the math primitives, add:

    // ── Public tunables (per-instance) ──────────────────────────────

    /// <summary>Length of the viewer_offset vector. Retail default ≈ 2.61.</summary>
    public float Distance { get; set; } = 2.61f;

    /// <summary>Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).</summary>
    public float Pitch { get; set; } = 0.291f;

    /// <summary>
    /// Yaw offset added on top of player yaw when slope-align is off
    /// or velocity is too small to derive a heading. Used by hold-RMB
    /// orbit to swing the camera around the player without rotating
    /// the character.
    /// </summary>
    public float YawOffset { get; set; } = 0f;

    /// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
    public float PivotHeight { get; set; } = 1.5f;

    /// <summary>Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.</summary>
    public float PlayerTranslucency { get; private set; }

    /// <summary>Clamp bounds carried over from legacy ChaseCamera.</summary>
    public const float DistanceMin = 2f;
    public const float DistanceMax = 40f;
    public const float PitchMin    = -0.7f;
    public const float PitchMax    = 1.4f;

    // ── Damped state ────────────────────────────────────────────────

    private readonly Vector3[] _velocityRing = new Vector3[5];
    private int     _velocityCount;
    private Vector3 _dampedEye;
    private Vector3 _dampedForward = new(1f, 0f, 0f);
    private bool    _initialised;

    // Mouse-filter state — shared by FilterMouseDelta entrypoint.
    private float _lastMouseDeltaX;
    private float _lastMouseDeltaY;
    private float _lastFilterTimeSec;

Then add the Update + adjustment methods + FilterMouseDelta between the state fields and the math primitives section:

    /// <summary>
    /// Advance the camera one frame. Caller passes the player's current
    /// pose + velocity (in world space) + the frame's <c>dt</c> in
    /// seconds. After this returns, <see cref="Position"/>,
    /// <see cref="View"/>, and <see cref="PlayerTranslucency"/> reflect
    /// the new state.
    /// </summary>
    public void Update(Vector3 playerPosition, float playerYaw, Vector3 playerVelocity, float dt)
    {
        // 1. Push velocity into 5-frame ring, get average.
        PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
        Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount);

        // 2. Heading vector — slope-aligned when fast enough, flat fallback otherwise.
        Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope);

        // 3. Orthonormal heading-frame basis.
        var (forward, right, up) = BuildBasis(heading);

        // 4. Target pose.
        Vector3 pivotWorld    = playerPosition + new Vector3(0f, 0f, PivotHeight);
        float   horizontal    = Distance * MathF.Cos(Pitch);
        float   vertical      = Distance * MathF.Sin(Pitch);
        // viewer_offset = (0, -horizontal, vertical) in (right, forward, up) basis.
        Vector3 targetEye     = pivotWorld + forward * (-horizontal) + up * vertical;
        Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);

        // 5. Exponential damping (independent translation + rotation rates).
        if (!_initialised)
        {
            _dampedEye     = targetEye;
            _dampedForward = targetForward;
            _initialised   = true;
        }
        else
        {
            float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
            float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness,    dt);
            _dampedEye     = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
            _dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
        }

        // 6. Publish renderer surface.
        Position = _dampedEye;
        View     = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f));

        // 7. Auto-fade translucency.
        float d = Vector3.Distance(_dampedEye, pivotWorld);
        PlayerTranslucency = ComputeTranslucency(d);
    }

    /// <summary>
    /// Adjust the camera distance (zoom) by a delta, clamped to
    /// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
    /// legacy <c>ChaseCamera.AdjustDistance</c>.
    /// </summary>
    public void AdjustDistance(float delta) =>
        Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);

    /// <summary>
    /// Adjust the camera pitch by a delta (radians), clamped to
    /// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
    /// <c>ChaseCamera.AdjustPitch</c>.
    /// </summary>
    public void AdjustPitch(float delta) =>
        Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);

    /// <summary>
    /// Public entry point for the mouse-input low-pass filter. Calls
    /// <see cref="FilterMouseAxis"/> on each axis with shared state.
    /// </summary>
    public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec)
    {
        float x = FilterMouseAxis(rawX, weight, nowSec,
            ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec);
        // Reset timestamp before the Y call so the within-window check still uses the original `nowSec - prev`
        // (no — we want both axes to share the same timestamp; X already advanced it. That's fine: Y sees
        // nowSec - nowSec = 0 < windowSec, so it blends. To keep Y honest we explicitly skip the timestamp
        // overwrite on the second call by passing a throwaway `_yTimeShadow`.)
        float yTimeShadow = _lastFilterTimeSec - 1f;  // ensure within-window path is taken
        float y = FilterMouseAxis(rawY, weight, nowSec,
            ref _lastMouseDeltaY, ref yTimeShadow, CameraDiagnostics.MouseLowPassWindowSec);
        return (x, y);
    }
  • Step 4: Run tests, verify pass
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"

Expected: PASS (23 tests).

  • Step 5: Verify full solution builds
dotnet build

Expected: 0 errors.

  • Step 6: Commit
git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
git commit -m "$(cat <<'EOF'
feat(camera): wire RetailChaseCamera Update() + tunables + state

Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint
that composes the math primitives into a renderable View matrix +
PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward
unit vector, first-frame snap flag, mouse-filter shared state.
Public surface: Distance/Pitch/YawOffset/PivotHeight tunables,
AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View +
Position + PlayerTranslucency outputs. 5 new integration tests, all
pass; total RetailChaseCamera test count 23.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: CameraController carries both cameras

Files:

  • Modify: src/AcDream.App/Rendering/CameraController.cs

  • Modify: tests/AcDream.App.Tests/Rendering/ — new CameraControllerTests.cs if no existing CameraController test exists; otherwise extend.

  • Step 1: Search for existing CameraController tests

dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~CameraController" --list-tests

If no results: create tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs. If existing: append to the existing file.

  • Step 2: Write the failing tests
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using Xunit;

namespace AcDream.App.Tests.Rendering;

public class CameraControllerTests
{
    private static (CameraController ctl, ChaseCamera legacy, RetailChaseCamera retail) MakeChaseFixture()
    {
        var orbit  = new OrbitCamera();
        var fly    = new FlyCamera();
        var ctl    = new CameraController(orbit, fly);
        var legacy = new ChaseCamera();
        var retail = new RetailChaseCamera();
        ctl.EnterChaseMode(legacy, retail);
        return (ctl, legacy, retail);
    }

    [Fact]
    public void ChaseMode_WhenFlagOff_ActiveIsLegacy()
    {
        CameraDiagnostics.UseRetailChaseCamera = false;
        var (ctl, legacy, _) = MakeChaseFixture();
        Assert.Same(legacy, ctl.Active);
        Assert.True(ctl.IsChaseMode);
    }

    [Fact]
    public void ChaseMode_WhenFlagOn_ActiveIsRetail()
    {
        CameraDiagnostics.UseRetailChaseCamera = true;
        var (ctl, _, retail) = MakeChaseFixture();
        Assert.Same(retail, ctl.Active);
        Assert.True(ctl.IsChaseMode);

        // Reset.
        CameraDiagnostics.UseRetailChaseCamera = false;
    }

    [Fact]
    public void ChaseMode_FlagFlipped_ActiveSwaps()
    {
        CameraDiagnostics.UseRetailChaseCamera = false;
        var (ctl, legacy, retail) = MakeChaseFixture();
        Assert.Same(legacy, ctl.Active);

        CameraDiagnostics.UseRetailChaseCamera = true;
        Assert.Same(retail, ctl.Active);

        CameraDiagnostics.UseRetailChaseCamera = false;
        Assert.Same(legacy, ctl.Active);
    }

    [Fact]
    public void ExitChaseMode_ClearsBothCameras()
    {
        CameraDiagnostics.UseRetailChaseCamera = false;
        var (ctl, _, _) = MakeChaseFixture();
        ctl.ExitChaseMode();

        Assert.Null(ctl.Chase);
        Assert.Null(ctl.RetailChase);
        Assert.False(ctl.IsChaseMode);
    }
}
  • Step 3: Run tests, verify failure
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~CameraControllerTests"

Expected: compile errors — RetailChase property + EnterChaseMode(ChaseCamera, RetailChaseCamera) overload missing.

  • Step 4: Modify CameraController.cs

Replace the existing class body with:

using AcDream.Core.Rendering;

namespace AcDream.App.Rendering;

public sealed class CameraController
{
    public OrbitCamera        Orbit       { get; }
    public FlyCamera          Fly         { get; }
    public ChaseCamera?       Chase       { get; private set; }
    public RetailChaseCamera? RetailChase { get; private set; }

    /// <summary>
    /// The renderer-facing active camera. In chase mode, returns
    /// <see cref="RetailChase"/> when
    /// <see cref="CameraDiagnostics.UseRetailChaseCamera"/> is true,
    /// otherwise <see cref="Chase"/>. In fly mode returns
    /// <see cref="Fly"/>; default is <see cref="Orbit"/>.
    /// </summary>
    public ICamera Active
    {
        get
        {
            if (_mode == Mode.Fly) return Fly;
            if (_mode == Mode.Chase)
            {
                if (CameraDiagnostics.UseRetailChaseCamera && RetailChase is not null)
                    return RetailChase;
                if (Chase is not null) return Chase;
            }
            return Orbit;
        }
    }

    public bool IsFlyMode    => _mode == Mode.Fly;
    public bool IsChaseMode  => _mode == Mode.Chase;

    public event Action<bool>? ModeChanged;

    private enum Mode { Orbit, Fly, Chase }
    private Mode _mode = Mode.Orbit;

    public CameraController(OrbitCamera orbit, FlyCamera fly)
    {
        Orbit = orbit;
        Fly   = fly;
    }

    public void ToggleFly()
    {
        _mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
        ModeChanged?.Invoke(IsFlyMode);
    }

    /// <summary>
    /// Enter chase mode with both candidate cameras. Both are held;
    /// <see cref="Active"/> picks based on
    /// <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.
    /// </summary>
    public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
    {
        Chase       = legacy;
        RetailChase = retail;
        _mode       = Mode.Chase;
        ModeChanged?.Invoke(IsChaseMode);
    }

    public void ExitChaseMode()
    {
        Chase       = null;
        RetailChase = null;
        _mode       = Mode.Fly;
        ModeChanged?.Invoke(IsFlyMode);
    }

    public void SetAspect(float aspect)
    {
        Orbit.Aspect = aspect;
        Fly.Aspect   = aspect;
    }
}

Caller-update note: This breaks CameraController.EnterChaseMode(ChaseCamera) callers — search for them and update. From earlier exploration there are exactly two: GameWindow.cs:9857 and GameWindow.cs:9965. We update them in Task 7 (GameWindow wiring). For this task to compile, change those two call sites to a temporary stub:

In GameWindow.cs, find the two EnterChaseMode(_chaseCamera) calls and replace with:

_cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect });

(Task 7 replaces this with proper construction + per-frame updates.)

  • Step 5: Run tests, verify pass
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~CameraControllerTests"

Expected: PASS (4 tests).

  • Step 6: Verify full solution builds
dotnet build

Expected: 0 errors.

  • Step 7: Run the full test suite to confirm nothing else broke
dotnet test

Expected: all tests pass (a handful of legacy chase-cam tests may exist; the temporary GameWindow stub should keep them green).

  • Step 8: Commit
git add src/AcDream.App/Rendering/CameraController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
git commit -m "$(cat <<'EOF'
feat(camera): CameraController carries both legacy + retail chase cams

EnterChaseMode now takes (ChaseCamera, RetailChaseCamera); Active
consults CameraDiagnostics.UseRetailChaseCamera to pick which to
expose. Flag flip at runtime swaps cameras instantly (both are kept
warm). GameWindow's two EnterChaseMode call sites get a temporary
stub RetailChaseCamera; Task 7 wires proper construction +
per-frame updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: InputAction additions + DebugVM mirror properties

Files:

  • Modify: src/AcDream.UI.Abstractions/Input/InputAction.cs

  • Modify: src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs

  • Step 1: Add four new enum entries

Open src/AcDream.UI.Abstractions/Input/InputAction.cs. Find the existing Acdream* section near the bottom (or the last block of entries). Insert these four entries at the end of the enum, before the closing brace:

    // ── AcdreamCameraCommands ─────────────────────────────
    /// <summary>Camera zoom in (held key, integrates Distance= adjSpeed·dt). Default unbound.</summary>
    CameraZoomIn,
    /// <summary>Camera zoom out (held key, integrates Distance+= adjSpeed·dt). Default unbound.</summary>
    CameraZoomOut,
    /// <summary>Camera raise (held key, integrates Pitch+= adjSpeed·dt·0.02). Default unbound.</summary>
    CameraRaise,
    /// <summary>Camera lower (held key, integrates Pitch= adjSpeed·dt·0.02). Default unbound.</summary>
    CameraLower,
  • Step 2: Add CameraDiagnostics mirror properties to DebugVM

Open src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs. Add a using at the top if not present:

using AcDream.Core.Rendering;

Locate the existing ProbeAutoWalk property (last of the Probe* group). Below it (still inside the class), add a new "Chase camera tunables" section:

    // ── Chase camera tunables (forward to CameraDiagnostics) ──────────

    /// <summary>Runtime mirror of <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.</summary>
    public bool UseRetailChaseCamera
    {
        get => CameraDiagnostics.UseRetailChaseCamera;
        set => CameraDiagnostics.UseRetailChaseCamera = value;
    }

    /// <summary>Runtime mirror of <see cref="CameraDiagnostics.AlignToSlope"/>.</summary>
    public bool CameraAlignToSlope
    {
        get => CameraDiagnostics.AlignToSlope;
        set => CameraDiagnostics.AlignToSlope = value;
    }

    /// <summary>Runtime mirror of <see cref="CameraDiagnostics.TranslationStiffness"/>.</summary>
    public float CameraTranslationStiffness
    {
        get => CameraDiagnostics.TranslationStiffness;
        set => CameraDiagnostics.TranslationStiffness = value;
    }

    /// <summary>Runtime mirror of <see cref="CameraDiagnostics.RotationStiffness"/>.</summary>
    public float CameraRotationStiffness
    {
        get => CameraDiagnostics.RotationStiffness;
        set => CameraDiagnostics.RotationStiffness = value;
    }

    /// <summary>Runtime mirror of <see cref="CameraDiagnostics.MouseLowPassWindowSec"/>.</summary>
    public float CameraMouseLowPassWindowSec
    {
        get => CameraDiagnostics.MouseLowPassWindowSec;
        set => CameraDiagnostics.MouseLowPassWindowSec = value;
    }

    /// <summary>Runtime mirror of <see cref="CameraDiagnostics.CameraAdjustmentSpeed"/>.</summary>
    public float CameraAdjustmentSpeed
    {
        get => CameraDiagnostics.CameraAdjustmentSpeed;
        set => CameraDiagnostics.CameraAdjustmentSpeed = value;
    }
  • Step 3: Verify build
dotnet build

Expected: 0 errors. (No tests for this task — the mirror properties are trivial passthroughs already covered by Task 1's CameraDiagnostics tests + the integration verification in Task 7.)

  • Step 4: Commit
git add src/AcDream.UI.Abstractions/Input/InputAction.cs src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
git commit -m "$(cat <<'EOF'
feat(camera): InputAction + DebugVM surface for retail chase camera

Four new InputAction entries for held-key offset integration
(CameraZoomIn/Out, CameraRaise/Lower; default unbound). Six new
DebugVM mirror properties forwarding to CameraDiagnostics so the
upcoming "Chase camera" DebugPanel section can drive them live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: DebugPanel "Chase camera" section

Files:

  • Modify: src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs

  • Step 1: Add the section call in DebugPanel.Render

Find Render(...) in DebugPanel.cs (around line 74). Insert a new DrawChaseCamera(renderer) call between DrawPlayerInfo(renderer) and DrawPerformance(renderer):

    public void Render(PanelContext ctx, IPanelRenderer renderer)
    {
        if (!renderer.Begin(Title))
        {
            renderer.End();
            return;
        }

        DrawPlayerInfo(renderer);
        DrawChaseCamera(renderer);     // ← NEW
        DrawPerformance(renderer);
        DrawCompass(renderer);
        DrawHelp(renderer);
        DrawCombatEvents(renderer);
        DrawRecentToasts(renderer);
        DrawDiagnostics(renderer);

        renderer.End();
    }
  • Step 2: Add the DrawChaseCamera method — insert below DrawPlayerInfo:
    private void DrawChaseCamera(IPanelRenderer r)
    {
        if (!r.CollapsingHeader("Chase camera", defaultOpen: true)) return;

        bool  useRetail  = _vm.UseRetailChaseCamera;
        bool  alignSlope = _vm.CameraAlignToSlope;
        float tStiff     = _vm.CameraTranslationStiffness;
        float rStiff     = _vm.CameraRotationStiffness;
        float lpWindow   = _vm.CameraMouseLowPassWindowSec;
        float adjSpeed   = _vm.CameraAdjustmentSpeed;

        if (r.Checkbox("Use retail chase camera (env: ACDREAM_RETAIL_CHASE)", ref useRetail))
            _vm.UseRetailChaseCamera = useRetail;

        if (r.Checkbox("Align to slope (env: ACDREAM_CAMERA_ALIGN_SLOPE)", ref alignSlope))
            _vm.CameraAlignToSlope = alignSlope;

        if (r.SliderFloat("Translation stiffness", ref tStiff, 0.05f, 1.0f))
            _vm.CameraTranslationStiffness = tStiff;
        if (r.SliderFloat("Rotation stiffness", ref rStiff, 0.05f, 1.0f))
            _vm.CameraRotationStiffness = rStiff;
        if (r.SliderFloat("Mouse low-pass window (s)", ref lpWindow, 0.0f, 0.5f))
            _vm.CameraMouseLowPassWindowSec = lpWindow;
        if (r.SliderFloat("Adjustment speed (units/s)", ref adjSpeed, 10f, 80f))
            _vm.CameraAdjustmentSpeed = adjSpeed;
    }
  • Step 3: Verify build
dotnet build

Expected: 0 errors.

  • Step 4: Run the existing DebugPanel tests (if any) to confirm no regression:
dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj

Expected: all pass.

  • Step 5: Commit
git add src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
git commit -m "$(cat <<'EOF'
feat(camera): DebugPanel "Chase camera" section with live tunables

New CollapsingHeader between Player Info and Performance: toggle +
slope-align checkbox + four sliders (translation stiffness, rotation
stiffness, mouse low-pass window, adjustment speed). All controls
write through DebugVM mirror properties to CameraDiagnostics statics;
changes take effect on the next frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: GameWindow wiring — construct both, update both, route inputs

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (4 sites)

  • Step 1: Add the retail chase field alongside the existing chase camera field

Find private AcDream.App.Rendering.ChaseCamera? _chaseCamera; (around line 629). Add directly below:

    private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
  • Step 2: Construct both cameras at chase-mode entry

Find the _chaseCamera = new AcDream.App.Rendering.ChaseCamera { ... } block (around line 9956). Replace the block + the immediately-following _cameraController?.EnterChaseMode(_chaseCamera); line with:

        _chaseCamera = new AcDream.App.Rendering.ChaseCamera
        {
            Aspect = _window!.Size.X / (float)_window.Size.Y,
        };
        _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
        {
            Aspect = _window!.Size.X / (float)_window.Size.Y,
        };
        // K.1b: _playerMouseDeltaX is no longer consumed by
        // MovementInput, but we still reset it here so any stale
        // accumulated value from a previous session doesn't leak
        // into a future code path that re-enables mouse-yaw.
        _playerMouseDeltaX = 0f;
        _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
  • Step 3: Update the chase camera each frame — both cameras get the same inputs

Find the existing _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, isOnGround: result.IsOnGround, dt: (float)dt); call (around line 6390). Replace with both updates:

            // Update chase camera(s). The CameraController exposes whichever
            // is currently selected via CameraDiagnostics.UseRetailChaseCamera;
            // both update every frame so toggling the flag swaps instantly
            // with the new camera already warm.
            //
            // Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during
            // jumps as a workaround for the visual feel retail gets from
            // low-stiffness damping).
            _chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
                isOnGround: result.IsOnGround,
                dt: (float)dt);
            // RetailChaseCamera: takes world velocity for slope-aligned heading;
            // jump-feedback falls out of damping naturally, no isOnGround needed.
            _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
                playerVelocity: _playerController.BodyVelocity,
                dt: (float)dt);
  • Step 4: Route mouse-Y (RMB-orbit pitch) through the low-pass filter

Find the existing mouse-move handler (around line 996 — the _chaseCamera.AdjustPitch(dy * 0.003f * sens); call). Replace the two AdjustPitch call sites (one at ~line 996 in the non-RMB branch, one at ~1007-1008 in the RMB branch) with conditional dispatch:

                        // Filter mouse Y through the retail camera's low-pass
                        // filter only when retail is the active mode; legacy
                        // path passes through raw (no behavior change for
                        // legacy users).
                        if (CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
                        {
                            float nowSec = (float)_runtimeClock.ElapsedSeconds;
                            var (_, filteredDy) = _retailChaseCamera.FilterMouseDelta(
                                rawX: 0f, rawY: dy, weight: 0.5f, nowSec: nowSec);
                            _retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
                        }
                        else
                        {
                            _chaseCamera.AdjustPitch(dy * 0.003f * sens);
                        }

(Make the same swap in both branches — the second site additionally has the YawOffset -= dx * 0.004f * sens; line; apply that to whichever chase camera is active. The legacy _chaseCamera.YawOffset and _retailChaseCamera!.YawOffset are both float setters with the same semantics.)

Note: the file may not have _runtimeClock.ElapsedSeconds accessible directly. If it doesn't, use (float)(_lastFrameTimestamp - _firstFrameTimestamp) or whatever the existing wall-clock plumbing is. Grep for an existing ElapsedSeconds / Stopwatch.GetElapsedTime pattern in GameWindow.cs; if none, use (float)Environment.TickCount64 / 1000f as a sufficient monotonic source for the 0.25 s window comparison.

  • Step 5: Mouse wheel zoom — same conditional routing

Find the _chaseCamera.AdjustDistance(-dir * 0.8f); call (around line 10112). Replace with:

        if (_playerMode && _cameraController.IsChaseMode)
        {
            if (CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
                _retailChaseCamera.AdjustDistance(-dir * 0.8f);
            else if (_chaseCamera is not null)
                _chaseCamera.AdjustDistance(-dir * 0.8f);
        }
  • Step 6: Held-key offset integration — in the player-mode tick loop (the same place where movement input is polled, around line 6314), add at the top of the _playerMode && _playerController is not null && _chaseCamera is not null branch:
            // Retail-style held-key offset integration. Only active when
            // retail chase is selected; legacy camera ignores these.
            if (CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null
                && _inputDispatcher is not null)
            {
                float adj = CameraDiagnostics.CameraAdjustmentSpeed * (float)dt;
                if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
                    _retailChaseCamera.AdjustDistance(-adj);
                if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
                    _retailChaseCamera.AdjustDistance(+adj);
                if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise))
                    _retailChaseCamera.AdjustPitch(+adj * 0.02f);
                if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower))
                    _retailChaseCamera.AdjustPitch(-adj * 0.02f);
            }
  • Step 7: Null-out the retail chase reference in the cleanup path

Find the existing _chaseCamera = null; line (around line 8965). Add directly below:

                _retailChaseCamera = null;

Same for line 9803 if there's another cleanup site.

  • Step 8: Remove the temporary stub added in Task 4

Search GameWindow.cs for _cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera. There should be zero results after Step 2 (the stub is replaced by the proper construction). Confirm clean.

  • Step 9: Verify build
dotnet build

Expected: 0 errors. If there's a missing using for CameraDiagnostics in GameWindow.cs, add using AcDream.Core.Rendering; to the top of the file.

  • Step 10: Run the full test suite
dotnet test

Expected: all pass (the existing chase-camera-using-legacy tests continue to work; the retail camera tests added in Tasks 2-3 still pass; the controller tests from Task 4 still pass).

  • Step 11: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(camera): wire RetailChaseCamera through GameWindow

GameWindow now constructs both ChaseCamera + RetailChaseCamera at
player-mode entry, updates both per frame (legacy with isOnGround,
retail with BodyVelocity), and routes mouse/wheel/held-key input to
whichever the CameraDiagnostics flag selects. Mouse-Y goes through
RetailChaseCamera.FilterMouseDelta before AdjustPitch when retail is
active; legacy path is unchanged. Held-key bindings (CameraZoomIn/Out,
CameraRaise/Lower; default-unbound) integrate distance/pitch at
CameraDiagnostics.CameraAdjustmentSpeed per second.

Default behavior: ACDREAM_RETAIL_CHASE unset → legacy camera as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: Build, test, visual verification handoff

  • Step 1: Clean rebuild from scratch
dotnet build --no-incremental

Expected: 0 errors, 0 new warnings.

  • Step 2: Run the full test suite
dotnet test

Expected: all tests pass, including the 27 new tests (2 CameraDiagnosticsTests + 23 RetailChaseCameraTests + 4 CameraControllerTests).

  • Step 3: Launch the client (only after the user is ready — they have ACE running)

Run the canonical launch command from CLAUDE.md §"Running the client":

$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS  = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
    Tee-Object -FilePath "launch.log"
  • Step 4: Manual visual verification — confirm with the user

Ask the user to verify (from CLAUDE.md acceptance criteria):

  1. With Ctrl+F1 (debug panel) → "Chase camera" → toggle OFF (default): camera feels identical to before this branch.
  2. Toggle ON: camera now visibly lags behind a turn, coasts after a stop, and tilts with the slope when crossing a hill crest.
  3. Toggling at runtime swaps instantly with no snap or crash.
  4. Jumping in retail mode shows the player rise above the camera without the legacy _trackedZ hack (which still exists in the legacy camera but is bypassed when retail is active).
  5. Sliders work: bumping Translation stiffness to 1.0 turns the camera into the legacy rigid follow; dropping to 0.10 produces ultra-floaty motion.
  • Step 5: If acceptance criteria pass — close handoff

No further commit. Spec + plan are committed; the implementation commits are the deliverable. Update the todos in the executing session: mark all tasks done.

If the user reports a behavioral issue: file as an issue in docs/ISSUES.md (e.g. "retail chase camera: tilt-with-terrain too aggressive on N-grade hills"), do NOT patch in this session — that's a separate investigation.


Self-review

Spec coverage:

  • Architecture (CameraDiagnostics, RetailChaseCamera, dual-controller, DebugVM mirrors, DebugPanel section, GameWindow wiring) → all covered by Tasks 1-7.
  • Math (8 steps in the update loop, including velocity ring, basis, target pose, damping, view matrix, mouse low-pass, auto-fade) → covered by Tasks 2-3.
  • Continuous-key offset integration → covered by Task 5 (enum) + Task 7 (held-key wiring).
  • Tests (5 groups in spec) → covered by Tasks 1-4 with explicit test bodies.
  • Acceptance criteria → covered by Task 8 visual handoff.
  • Out of scope (in-head, look-down, map mode, camera-vs-wall, default-flip, legacy deletion) → not in any task. ✓ matches spec.
  • Q1 (PlayerTranslucency application): computation lands in Task 3; application is explicitly deferred to a follow-up issue. ✓ matches spec escape clause.
  • Q2 (player velocity source): confirmed _playerController.BodyVelocity exists at PlayerMovementController.cs:155; used in Task 7 Step 3.
  • Q3 (IPanelRenderer.SliderFloat): confirmed present (IPanelRenderer.cs:100) — Task 6 uses it directly.

Placeholder scan:

  • Step 4 of Task 7 has a fallback note for _runtimeClock.ElapsedSeconds if it doesn't exist — concrete fallback options are given. Not a placeholder; an implementation pivot.
  • No TBD, TODO, or "implement later" markers.

Type consistency:

  • CameraDiagnostics.UseRetailChaseCamera used in Tasks 1, 4, 5, 6, 7 — same name throughout.
  • RetailChaseCamera.Distance / Pitch / YawOffset / PivotHeight / PlayerTranslucency / Aspect / Position / View / Projection — all defined in Task 3, used consistently in Tasks 4 + 7.
  • Update(playerPosition, playerYaw, playerVelocity, dt) signature defined in Task 3, called with the same arg order in Task 7.
  • EnterChaseMode(ChaseCamera, RetailChaseCamera) signature defined in Task 4, called in Task 7.
  • FilterMouseDelta(rawX, rawY, weight, nowSec) → (outX, outY) defined in Task 3, called in Task 7.
  • Math helpers: ComputeHeading, BuildBasis, PushVelocity, AverageVelocity, ComputeDampingAlpha, FilterMouseAxis, ComputeTranslucency — same names in Tasks 2 and 3.

Plan is internally consistent.