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

1706 lines
66 KiB
Markdown
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.

# 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`](../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`:
```csharp
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`:
```csharp
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**
```bash
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:
```xml
<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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
// ── 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:
```csharp
// ── 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:
```csharp
/// <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**
```bash
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**
```csharp
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:
```csharp
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:
```csharp
_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**
```bash
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:
```csharp
// ── 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:
```csharp
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:
```csharp
// ── 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**
```bash
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)`:
```csharp
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`:
```csharp
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**
```bash
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:
```csharp
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:
```csharp
_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:
```csharp
// 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:
```csharp
// 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:
```csharp
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:
```csharp
// 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:
```csharp
_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**
```bash
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":
```powershell
$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.