From 73dee43d14e1c8f1edae179ace9faee99cb9e18e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 18 May 2026 19:24:34 +0200 Subject: [PATCH] =?UTF-8?q?docs(camera):=20impl=20plan=20=E2=80=94=20retai?= =?UTF-8?q?l-faithful=20chase=20camera=20with=20dev-tools=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-05-18-retail-chase-camera.md | 1706 +++++++++++++++++ 1 file changed, 1706 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-retail-chase-camera.md diff --git a/docs/superpowers/plans/2026-05-18-retail-chase-camera.md b/docs/superpowers/plans/2026-05-18-retail-chase-camera.md new file mode 100644 index 0000000..1edcbdb --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-retail-chase-camera.md @@ -0,0 +1,1706 @@ +# 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; + +/// +/// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors +/// the pattern: +/// static fields seeded from env vars at process start, runtime-settable +/// via property setters that the DebugPanel writes to. +/// +/// +/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. +/// +/// +public static class CameraDiagnostics +{ + /// + /// Master toggle. When false (default) the legacy + /// AcDream.App.Rendering.ChaseCamera is the active camera; + /// when true, the retail-faithful RetailChaseCamera is. + /// Initial state from ACDREAM_RETAIL_CHASE=1. + /// + public static bool UseRetailChaseCamera { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") == "1"; + + /// + /// 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 ACDREAM_CAMERA_ALIGN_SLOPE; default-on if unset. + /// + public static bool AlignToSlope { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0"; + + /// + /// Per-frame translation damping rate. Retail default 0.45. Higher + /// (→ 1.0) snaps faster; lower (→ 0.0) lags more. Formula per frame: + /// alpha = clamp(TranslationStiffness * dt * 10, 0, 1). + /// + public static float TranslationStiffness { get; set; } = 0.45f; + + /// + /// 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. + /// + public static float RotationStiffness { get; set; } = 0.45f; + + /// + /// 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. + /// + public static float MouseLowPassWindowSec { get; set; } = 0.25f; + + /// + /// Per-second rate that held-key offset adjustments + /// (CameraZoomIn/Out, CameraRaise/Lower) integrate into the + /// camera's Distance / Pitch. Retail default 40.0. + /// + 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) +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 `` element or an `[assembly: InternalsVisibleTo("AcDream.App.Tests")]` attribute in an `AssemblyInfo.cs` / `GlobalUsings.cs` / similar. If missing, add to the csproj: + +```xml + + + +``` + +(Use the existing `` 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; + +/// +/// Retail-faithful chase camera. Ports the chase-cam behavior from the +/// 2013 acclient (CameraManager + CameraSet, decomp at +/// docs/research/named-retail/acclient_2013_pseudo_c.txt:95505): +/// exponential damping toward a target pose, 5-frame velocity-averaged +/// slope-aligned heading frame, mouse-input low-pass filter. +/// +/// +/// Sits behind +/// next to the legacy ; both update every +/// frame so toggling the flag swaps cameras instantly. Visible behavior +/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on +/// hills, jump-feedback without the legacy _trackedZ hack. +/// +/// +/// +/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. +/// +/// +public sealed class RetailChaseCamera : ICamera +{ + // ICamera surface — filled in by Task 3. + public Vector3 Position { get; private set; } + public float Aspect { get; set; } = 16f / 9f; + public float FovY { get; set; } = MathF.PI / 3f; + public Matrix4x4 View { get; private set; } = Matrix4x4.Identity; + public Matrix4x4 Projection => + Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); + + // Math primitives — pure, internal-static for unit-testability. + + /// + /// Pick the heading vector that drives the camera basis. Slope- + /// aligned when velocity is non-trivial and the toggle is on; flat + /// fallback otherwise. Matches retail's target_status & + /// ALIGN_WITH_PLANE path with the contact-plane branch + /// collapsed into the flat fallback. + /// + internal static Vector3 ComputeHeading(Vector3 avgVelocity, float yaw, bool alignToSlope) + { + if (alignToSlope && avgVelocity.LengthSquared() > 1e-4f) + return Vector3.Normalize(avgVelocity); + + return new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f); + } + + /// + /// Build an orthonormal basis with forward = heading. World + /// up is (0, 0, 1); if heading is near-parallel to it + /// the right axis falls back to world +X so the cross + /// product doesn't collapse. + /// + internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading) + { + Vector3 forward = Vector3.Normalize(heading); + Vector3 worldUp = new(0f, 0f, 1f); + + Vector3 right; + if (MathF.Abs(forward.Z) > 0.99f) + { + // Near-vertical forward — use world +X as the secondary axis. + right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f))); + } + else + { + right = Vector3.Normalize(Vector3.Cross(forward, worldUp)); + } + Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal) + return (forward, right, up); + } + + /// + /// FIFO-push a velocity sample into the 5-entry ring. Returns the + /// updated ring (mutates the input array; the return is for fluent + /// usage in tests). grows from 0 toward 5 + /// and stays at 5 once the ring is full. + /// + internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample) + { + if (ring.Length != 5) + throw new ArgumentException("velocity ring must have 5 entries", nameof(ring)); + + // Shift left by 1 (oldest is overwritten), append new sample at the tail. + for (int i = 0; i < 4; i++) ring[i] = ring[i + 1]; + ring[4] = sample; + if (count < 5) count++; + return ring; + } + + /// + /// Average the most-recent entries of the + /// ring (entries [5-count .. 5)). Returns + /// when count is zero. + /// + internal static Vector3 AverageVelocity(Vector3[] ring, int count) + { + if (count == 0) return Vector3.Zero; + Vector3 sum = Vector3.Zero; + for (int i = 5 - count; i < 5; i++) sum += ring[i]; + return sum / count; + } + + /// + /// Exponential-damping rate per frame. + /// alpha = clamp(stiffness * dt * 10, 0, 1). At + /// stiffness=0.45, dt=1/60~0.075 + /// (~150 ms half-life). Matches retail's + /// x_1 = stiffness * dt * 10 formulation. + /// + internal static float ComputeDampingAlpha(float stiffness, float dt) + { + float a = stiffness * dt * 10f; + if (a <= 0f) return 0f; + if (a >= 1f) return 1f; + return a; + } + + /// + /// Low-pass filter for a single mouse axis. Mirrors retail's + /// CameraSet::FilterMouseInput: if last sample was within + /// , blend output with the average of + /// (previous, raw); otherwise pass-through. Final output = + /// raw * (1 - weight) + blended * weight. Updates + /// and + /// to the new state. + /// + internal static float FilterMouseAxis( + float raw, + float weight, + float nowSec, + ref float lastDelta, + ref float lastTimeSec, + float windowSec) + { + float avg; + if (nowSec - lastTimeSec < windowSec) + avg = (lastDelta + raw) * 0.5f; + else + avg = raw; + + float output = raw * (1f - weight) + avg * weight; + lastDelta = output; + lastTimeSec = nowSec; + return output; + } + + /// + /// Player-mesh translucency as a function of camera-to-pivot + /// distance. 0 = fully opaque, 1 = fully transparent. + /// Opaque at and beyond 0.45 m; fully transparent at and within + /// 0.20 m; linear ramp between. Matches retail's CameraSet:: + /// UpdateCamera distance check (decomp :97703–97725). + /// + internal static float ComputeTranslucency(float distance) + { + const float Far = 0.45f; + const float Near = 0.20f; + + if (distance >= Far) return 0f; + if (distance <= Near) return 1f; + // Linear: t = 1 - (Near - distance) / (Near - Far) + return 1f - (Near - distance) / (Near - Far); + } +} +``` + +- [ ] **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) +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) ────────────────────────────── + + /// Length of the viewer_offset vector. Retail default ≈ 2.61. + public float Distance { get; set; } = 2.61f; + + /// Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°). + public float Pitch { get; set; } = 0.291f; + + /// + /// 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. + /// + public float YawOffset { get; set; } = 0f; + + /// Height of look-at anchor above the player's feet (m). Retail default 1.5. + public float PivotHeight { get; set; } = 1.5f; + + /// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow. + public float PlayerTranslucency { get; private set; } + + /// Clamp bounds carried over from legacy ChaseCamera. + 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 + /// + /// Advance the camera one frame. Caller passes the player's current + /// pose + velocity (in world space) + the frame's dt in + /// seconds. After this returns, , + /// , and reflect + /// the new state. + /// + 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); + } + + /// + /// Adjust the camera distance (zoom) by a delta, clamped to + /// ... Mirrors + /// legacy ChaseCamera.AdjustDistance. + /// + public void AdjustDistance(float delta) => + Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); + + /// + /// Adjust the camera pitch by a delta (radians), clamped to + /// ... Mirrors legacy + /// ChaseCamera.AdjustPitch. + /// + public void AdjustPitch(float delta) => + Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); + + /// + /// Public entry point for the mouse-input low-pass filter. Calls + /// on each axis with shared state. + /// + 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) +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; } + + /// + /// The renderer-facing active camera. In chase mode, returns + /// when + /// is true, + /// otherwise . In fly mode returns + /// ; default is . + /// + 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? 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); + } + + /// + /// Enter chase mode with both candidate cameras. Both are held; + /// picks based on + /// . + /// + 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) +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 ───────────────────────────── + /// Camera zoom in (held key, integrates Distance−= adjSpeed·dt). Default unbound. + CameraZoomIn, + /// Camera zoom out (held key, integrates Distance+= adjSpeed·dt). Default unbound. + CameraZoomOut, + /// Camera raise (held key, integrates Pitch+= adjSpeed·dt·0.02). Default unbound. + CameraRaise, + /// Camera lower (held key, integrates Pitch−= adjSpeed·dt·0.02). Default unbound. + 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) ────────── + + /// Runtime mirror of . + public bool UseRetailChaseCamera + { + get => CameraDiagnostics.UseRetailChaseCamera; + set => CameraDiagnostics.UseRetailChaseCamera = value; + } + + /// Runtime mirror of . + public bool CameraAlignToSlope + { + get => CameraDiagnostics.AlignToSlope; + set => CameraDiagnostics.AlignToSlope = value; + } + + /// Runtime mirror of . + public float CameraTranslationStiffness + { + get => CameraDiagnostics.TranslationStiffness; + set => CameraDiagnostics.TranslationStiffness = value; + } + + /// Runtime mirror of . + public float CameraRotationStiffness + { + get => CameraDiagnostics.RotationStiffness; + set => CameraDiagnostics.RotationStiffness = value; + } + + /// Runtime mirror of . + public float CameraMouseLowPassWindowSec + { + get => CameraDiagnostics.MouseLowPassWindowSec; + set => CameraDiagnostics.MouseLowPassWindowSec = value; + } + + /// Runtime mirror of . + 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) +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) +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) +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.