# A8.F Swept-Sphere Camera Collision — 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:** Stop the 3rd-person camera eye from clipping through walls by sweeping a 0.3 m collision sphere from the head-pivot to the desired eye and publishing the stopped position — porting retail's `SmartBox::update_viewer` spring arm. This stabilizes the A8.F indoor-visibility decisions (which key off the eye) and fixes the flap / missing-wall symptoms. **Architecture:** A narrow `ICameraCollisionProbe` is injected into `RetailChaseCamera`. After the camera damps the desired eye and before it publishes, it asks the probe to sweep `pivot→eye`. The concrete `PhysicsCameraCollisionProbe` wraps the existing `PhysicsEngine.ResolveWithTransition`, which already collides against both indoor cell walls (`FindEnvCollisions`) and outdoor/baked GfxObj shells (`FindObjCollisions`). Gated by `CameraDiagnostics.CollideCamera` (default ON). **Tech Stack:** C# / .NET 10, Silk.NET, xUnit. Spec: `docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md`. **Reference (read before starting):** - Spec: `docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md` - Camera: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (eye `:113`, damp `:131`, publish `:136`, fade `:367`) - Engine: `src/AcDream.Core/Physics/PhysicsEngine.cs:589` (`ResolveWithTransition`, returns `sp.CheckPos` as `.Position` at `:846`/`:865`) - Sphere convention: `src/AcDream.Core/Physics/TransitionTypes.cs:517-547` (`InitPath` sets `LocalSphere[0].Origin = (0,0,radius)`) - Player self-skip: `src/AcDream.App/Input/PlayerMovementController.cs` (`CellId` `:133`, `LocalEntityId` `:144`) --- ## Task 1: Add `CameraDiagnostics.CollideCamera` flag **Files:** - Modify: `src/AcDream.Core/Rendering/CameraDiagnostics.cs` - Test: `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs` - [ ] **Step 1: Write the failing test** Add to `CameraDiagnosticsTests.cs` (inside the `CameraDiagnosticsTests` class): ```csharp [Fact] public void CollideCamera_DefaultOn_AndPersistsRuntimeChanges() { CameraDiagnostics.CollideCamera = true; Assert.True(CameraDiagnostics.CollideCamera); CameraDiagnostics.CollideCamera = false; Assert.False(CameraDiagnostics.CollideCamera); CameraDiagnostics.CollideCamera = true; // reset so other tests aren't poisoned } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests.CollideCamera_DefaultOn"` Expected: FAIL — compile error, `CollideCamera` does not exist. - [ ] **Step 3: Add the property** In `CameraDiagnostics.cs`, add after the `UseRetailChaseCamera` property (after line 28): ```csharp /// /// When true (default), the chase camera sweeps a 0.3 m collision /// sphere from the head-pivot to the desired eye and stops it at the /// first wall (retail SmartBox::update_viewer spring arm), so /// the eye never sits behind/inside geometry. Initial state from /// ACDREAM_CAMERA_COLLIDE; default-on if unset, off only when /// explicitly set to "0". /// public static bool CollideCamera { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_CAMERA_COLLIDE") != "0"; ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests.CollideCamera_DefaultOn"` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/AcDream.Core/Rendering/CameraDiagnostics.cs tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs git commit -m "feat(render): Phase A8.F — add CameraDiagnostics.CollideCamera flag (default on)" ``` --- ## Task 2: Camera-collision probe interface + `PhysicsCameraCollisionProbe` **Files:** - Create: `src/AcDream.App/Rendering/ICameraCollisionProbe.cs` - Create: `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs` - Test: `tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs` - [ ] **Step 1: Write the failing test** Create `tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs`: ```csharp using System.Numerics; using AcDream.App.Rendering; using AcDream.Core.Physics; using Xunit; namespace AcDream.App.Tests.Rendering; public class PhysicsCameraCollisionProbeTests { // The probe must convert the desired eye path (where the SPHERE CENTER // should travel) into the foot-capsule path InitPath expects (which offsets // sphere0 up by radius), then invert it on the result. Verify the round trip. [Fact] public void SpherePathOffset_RoundTrips() { var p = new Vector3(10f, 20f, 30f); const float r = 0.3f; var path = PhysicsCameraCollisionProbe.ToSpherePath(p, r); Assert.Equal(p.Z - r, path.Z, 5); Assert.Equal(p.X, path.X, 5); Assert.Equal(p.Y, path.Y, 5); var back = PhysicsCameraCollisionProbe.FromSpherePath(path, r); Assert.Equal(p.X, back.X, 5); Assert.Equal(p.Y, back.Y, 5); Assert.Equal(p.Z, back.Z, 5); } // cellId == 0 means "no starting cell" — the probe must short-circuit and // return the desired eye without touching the engine. [Fact] public void SweepEye_NoStartingCell_ReturnsDesiredEyeUnchanged() { var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine()); var pivot = new Vector3(0f, 0f, 1.5f); var eye = new Vector3(-2f, 0f, 2.2f); var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0); Assert.Equal(eye, result); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PhysicsCameraCollisionProbeTests"` Expected: FAIL — `ICameraCollisionProbe` / `PhysicsCameraCollisionProbe` do not exist. - [ ] **Step 3: Create the interface** Create `src/AcDream.App/Rendering/ICameraCollisionProbe.cs`: ```csharp using System.Numerics; namespace AcDream.App.Rendering; /// /// Sweeps a small sphere from the camera pivot (player head) toward the /// desired eye and returns the stopped (non-penetrating) eye. The seam that /// lets collide its eye without depending on /// the physics engine directly (and stay unit-testable with a fake). /// public interface ICameraCollisionProbe { /// /// Roll a collision sphere from to /// ; return the position it reaches without /// penetrating geometry. Returns unchanged /// when nothing blocks the path or when is 0. /// Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId); } ``` - [ ] **Step 4: Create the implementation** Create `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs`: ```csharp using System.Numerics; using AcDream.Core.Physics; namespace AcDream.App.Rendering; /// /// backed by the player's swept-sphere /// engine. Ports retail's SmartBox::update_viewer (0x00453ce0): sweep /// the 0.3 m viewer_sphere from the head-pivot to the desired eye via a /// CTransition and use the stopped position. Reusing /// collides against indoor /// cell walls (FindEnvCollisions) AND outdoor/baked GfxObj shells /// (FindObjCollisions) in one faithful path. /// public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe { /// Retail viewer_sphere radius (acclient :93314). public const float ViewerSphereRadius = 0.3f; private readonly PhysicsEngine _physics; public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics; public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) { // No starting cell → nothing to sweep against; keep the desired eye. if (cellId == 0) return desiredEye; // SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) // (the player foot-capsule convention). Retail's viewer_sphere center is // (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER // travel pivot→eye, then add it back to the swept stop position. Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius); Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius); var r = _physics.ResolveWithTransition( currentPos: begin, targetPos: end, cellId: cellId, sphereRadius: ViewerSphereRadius, sphereHeight: 0f, // single sphere (no head sphere) stepUpHeight: 0f, // no step-up for a camera stepDownHeight: 0f, // no step-down / ground snap isOnGround: false, // no contact-plane / walkable semantics body: null, // no cross-frame persistence // Retail init_object(player, 0x5c) = IsViewer|PathClipped|FreeRotate| // PerfectClip (pseudo-C :92864). PathClipped = hard-stop at first contact // (the spring arm, not edge-slide); IsViewer = eye passes through creatures, // colliding only with world geometry. NOT IsPlayer -> stays out of the #98 // capture filter. (Updated from ObjectInfoState.None during implementation // per the Task-10 code-quality review; shipped in fcea05f / spec §5.1.) moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped | ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip, movingEntityId: selfEntityId); // skip the player's own ShadowEntry return FromSpherePath(r.Position, ViewerSphereRadius); } /// Eye/pivot point → InitPath path point (subtract the sphere-center offset). internal static Vector3 ToSpherePath(Vector3 spherePoint, float radius) => spherePoint - new Vector3(0f, 0f, radius); /// InitPath path point → eye point (add the sphere-center offset back). internal static Vector3 FromSpherePath(Vector3 pathPoint, float radius) => pathPoint + new Vector3(0f, 0f, radius); } ``` - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PhysicsCameraCollisionProbeTests"` Expected: PASS (both tests). - [ ] **Step 6: Commit** ```bash git add src/AcDream.App/Rendering/ICameraCollisionProbe.cs src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs git commit -m "feat(render): Phase A8.F — PhysicsCameraCollisionProbe (swept-sphere eye via ResolveWithTransition)" ``` --- ## Task 3: Wire the probe into `RetailChaseCamera` **Files:** - Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (property, `Update` signature, sweep call) - Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` - [ ] **Step 1: Write the failing tests** Add to `RetailChaseCameraTests.cs` (inside the class). These need a fake probe and exercise `Update`: ```csharp // ── Camera collision (A8.F) ─────────────────────────────────────── private sealed class FakeProbe : ICameraCollisionProbe { public int Calls; public Vector3 ReturnEye; public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) { Calls++; return ReturnEye; } } [Fact] public void Update_WithProbeAndFlagOn_PublishesCollidedEye() { CameraDiagnostics.CollideCamera = true; var collided = new Vector3(1f, 2f, 3f); var probe = new FakeProbe { ReturnEye = collided }; var cam = new RetailChaseCamera { CollisionProbe = probe }; cam.Update( playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, cellId: 0x100, selfEntityId: 0x5); Assert.True(probe.Calls >= 1); Assert.Equal(collided, cam.Position); } [Fact] public void Update_FlagOff_DoesNotConsultProbe() { CameraDiagnostics.CollideCamera = false; var probe = new FakeProbe { ReturnEye = new Vector3(99f, 99f, 99f) }; var cam = new RetailChaseCamera { CollisionProbe = probe }; cam.Update( playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, cellId: 0x100, selfEntityId: 0x5); Assert.Equal(0, probe.Calls); Assert.NotEqual(new Vector3(99f, 99f, 99f), cam.Position); CameraDiagnostics.CollideCamera = true; // reset } [Fact] public void Update_NullProbe_DoesNotThrow() { CameraDiagnostics.CollideCamera = true; var cam = new RetailChaseCamera { CollisionProbe = null }; // Should run with no collision and publish a valid view. cam.Update( playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, cellId: 0x100, selfEntityId: 0x5); Assert.NotEqual(default, cam.View); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests.Update_"` Expected: FAIL — `CollisionProbe` property and the `cellId`/`selfEntityId` `Update` parameters do not exist. - [ ] **Step 3: Add the `CollisionProbe` property** In `RetailChaseCamera.cs`, add to the public tunables region (after the `PivotHeight` property, around line 53): ```csharp /// /// Optional spring-arm collision probe. When set (and /// is true), the damped eye /// is swept from the head-pivot and stopped at the first wall. Null leaves /// the eye uncollided (the default for tests and the legacy path). /// public ICameraCollisionProbe? CollisionProbe { get; init; } ``` - [ ] **Step 4: Extend the `Update` signature** In `RetailChaseCamera.cs`, change the `Update` signature (line 86-92) to add two optional params at the end: ```csharp public void Update( Vector3 playerPosition, float playerYaw, Vector3 playerVelocity, bool isOnGround, Vector3 contactPlaneNormal, float dt, uint cellId = 0, uint selfEntityId = 0) ``` - [ ] **Step 5: Insert the sweep between damp and publish** In `RetailChaseCamera.cs`, between the end of the damping block (line 133 `}`) and the `// 6. Publish renderer surface.` comment (line 135), insert: ```csharp // 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer // (0x00453ce0) sweeps viewer_sphere from the head-pivot to the // desired eye and uses the stopped position. Keeps the eye out of // walls so the A8.F camera-cell + portal side-tests stay stable. // A null probe or disabled flag leaves the eye unchanged. if (CameraDiagnostics.CollideCamera && CollisionProbe is not null) _dampedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId); ``` (The fade at step 7, line 140, already reads `_dampedEye`, so it now uses the collided eye automatically.) - [ ] **Step 6: Run tests to verify they pass** Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"` Expected: PASS (the new `Update_*` tests plus all existing `Heading_*` / `BuildBasis_*` tests). - [ ] **Step 7: Commit** ```bash git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs git commit -m "feat(render): Phase A8.F — RetailChaseCamera consumes the camera-collision probe" ``` --- ## Task 4: Wire the probe in `GameWindow` **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (two camera constructions, one `Update` call) No unit test — `GameWindow` wiring is verified by build + the visual acceptance in Task 7. - [ ] **Step 1: Inject the probe at the first construction site** In `GameWindow.cs`, the construction around line 10693 currently reads: ```csharp _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera { Aspect = _chaseCamera.Aspect, }; ``` Change it to: ```csharp _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera { Aspect = _chaseCamera.Aspect, CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine), }; ``` - [ ] **Step 2: Inject the probe at the second construction site** In `GameWindow.cs`, the construction around line 10826 currently reads: ```csharp _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera { Aspect = _window!.Size.X / (float)_window.Size.Y, }; ``` Change it to: ```csharp _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera { Aspect = _window!.Size.X / (float)_window.Size.Y, CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine), }; ``` - [ ] **Step 3: Pass cell + self-entity into the per-frame `Update`** In `GameWindow.cs`, the camera update around line 6862 currently ends with `dt: (float)dt);`. Change the call to: ```csharp _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw, playerVelocity: _playerController.BodyVelocity, isOnGround: result.IsOnGround, contactPlaneNormal: _playerController.ContactPlane.Normal, dt: (float)dt, cellId: _playerController.CellId, selfEntityId: _playerController.LocalEntityId); ``` - [ ] **Step 4: Build to verify the wiring compiles** Run: `dotnet build` Expected: build succeeds (0 errors). - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(render): Phase A8.F — wire camera-collision probe + cell/self id into GameWindow" ``` --- ## Task 5: Add the live-toggle menu item **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `Camera` ImGui menu, ~line 7934) - [ ] **Step 1: Add the checkbox menu item** In `GameWindow.cs`, the `Camera` menu (around line 7934) currently reads: ```csharp if (ImGuiNET.ImGui.BeginMenu("Camera")) { if (_cameraController is not null) { string flyLabel = _cameraController.IsFlyMode ? "Exit Free-Fly Mode" : "Enter Free-Fly Mode"; if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F")) ToggleFlyOrChase(); } ImGuiNET.ImGui.EndMenu(); } ``` Insert the toggle before `ImGuiNET.ImGui.EndMenu();`: ```csharp if (ImGuiNET.ImGui.BeginMenu("Camera")) { if (_cameraController is not null) { string flyLabel = _cameraController.IsFlyMode ? "Exit Free-Fly Mode" : "Enter Free-Fly Mode"; if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F")) ToggleFlyOrChase(); } // A8.F: spring-arm camera collision (live A/B toggle). if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "", AcDream.Core.Rendering.CameraDiagnostics.CollideCamera)) AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = !AcDream.Core.Rendering.CameraDiagnostics.CollideCamera; ImGuiNET.ImGui.EndMenu(); } ``` - [ ] **Step 2: Build to verify** Run: `dotnet build` Expected: build succeeds (0 errors). - [ ] **Step 3: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(render): Phase A8.F — Camera menu toggle for spring-arm collision" ``` --- ## Task 6: Correct the prior camera spec's collision note **Files:** - Modify: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md` (lines 454-457) - [ ] **Step 1: Mark the stale note as superseded** In `2026-05-18-retail-chase-camera-design.md`, replace the bullet at lines 454-457: ```markdown - **Camera-vs-world collision.** Retail's per-frame update doesn't raycast world geometry (see investigation report 2026-05-18 in chat). The auto-fade handles "camera passes through player"; we don't attempt "camera collides with wall" — same as retail. ``` with: ```markdown - **Camera-vs-world collision.** ~~Retail's per-frame update doesn't raycast world geometry; we don't attempt "camera collides with wall" — same as retail.~~ **SUPERSEDED 2026-05-29:** this was a research error — retail DOES collide the camera in `SmartBox::update_viewer` (0x00453ce0), which the earlier pass missed by tracing only the desired-eye producer. Implemented as a swept-sphere spring arm; see `docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md`. ``` - [ ] **Step 2: Commit** ```bash git add docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md git commit -m "docs(render): Phase A8.F — supersede the old 'no camera collision' note" ``` --- ## Task 7: Full verification + acceptance **Files:** none (verification only). - [ ] **Step 1: Full build** Run: `dotnet build` Expected: 0 errors. - [ ] **Step 2: Full test suite** Run: `dotnet test` Expected: green. Note the App.Tests baseline should increase by the new camera tests; no regressions in Core/Net. - [ ] **Step 3: Visual verification (the real acceptance — requires the user)** Launch against the live ACE server with the A8.F branch on (PowerShell): ```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_A8_INDOOR_BRANCH="1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f-cameracollide.log" ``` Walk `+Acdream` into a Holtburg cottage and down into its cellar, panning the camera through walls and crossing the doorway inside↔outside. Confirm: - the flap is gone — walls/ground stay solid while panning and while crossing the doorway; - back walls no longer go missing when looking through a window from outside; - the player fades (rather than the camera sitting inside the player mesh) when backed into a corner. Then toggle `Collide Camera (spring arm)` off via the Camera menu (or relaunch with `ACDREAM_CAMERA_COLLIDE=0`) and confirm the flap returns — proving the fix is what closed it. - [ ] **Step 4: Update the roadmap / milestones on visual pass** After the user confirms the visual result, update the A8.F entry in `CLAUDE.md` (the M1.5 "currently working toward" block) and `docs/plans/2026-04-11-roadmap.md` shipped table to note the swept-sphere camera collision shipped + visual-verified, and move/close the related A8.F flap notes. Commit: ```bash git add CLAUDE.md docs/plans/2026-04-11-roadmap.md git commit -m "docs(render): Phase A8.F — camera collision shipped + visual-verified" ``` --- ## Notes for the implementer - **Do not** re-implement collision in the probe. The whole point of reusing `ResolveWithTransition` is that the env+obj sweep is already tested. The probe is param-marshalling + the z-offset round trip. - **Self-skip is load-bearing.** The sweep starts at the player's head, inside the player's own 0.48 m collision sphere / ShadowEntry. Passing `selfEntityId` (= `LocalEntityId`) is what stops the eye from snapping onto the head every frame. If the eye appears glued to the player, this is the first thing to check. - **Slide vs hard-stop (open question).** Reusing the transition gives the player path's edge-slide (the eye glides along a wall, no jitter). If visual verification shows the eye behaving oddly, read retail's `find_valid_position` and match its stop/slide semantics — but do not change the architecture for it. - **If the eye hugs/penetrates in a tight room**, the spec's optional `AdjustPosition` fallback (spec §7) is the escalation; add it only if needed.