# 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.