acdream/docs/superpowers/plans/2026-05-29-a8f-camera-collision.md
Erik 05161399de docs(render): Phase A8.F — sync plan Task 2 moverFlags to shipped 0x5c
The plan's Task 2 code block still showed moverFlags: ObjectInfoState.None; the
shipped code (fcea05f) and spec §5.1 use IsViewer|PathClipped|FreeRotate|
PerfectClip (retail init_object(player, 0x5c)). Update the stale snippet so the
plan matches reality (this stale block was the likely source of a re-report).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:42:03 +02:00

25 KiB

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):

    [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):

    /// <summary>
    /// 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 <c>SmartBox::update_viewer</c> spring arm), so
    /// the eye never sits behind/inside geometry. Initial state from
    /// <c>ACDREAM_CAMERA_COLLIDE</c>; default-on if unset, off only when
    /// explicitly set to <c>"0"</c>.
    /// </summary>
    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
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:

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:

using System.Numerics;

namespace AcDream.App.Rendering;

/// <summary>
/// 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 <see cref="RetailChaseCamera"/> collide its eye without depending on
/// the physics engine directly (and stay unit-testable with a fake).
/// </summary>
public interface ICameraCollisionProbe
{
    /// <summary>
    /// Roll a collision sphere from <paramref name="pivot"/> to
    /// <paramref name="desiredEye"/>; return the position it reaches without
    /// penetrating geometry. Returns <paramref name="desiredEye"/> unchanged
    /// when nothing blocks the path or when <paramref name="cellId"/> is 0.
    /// </summary>
    Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
}
  • Step 4: Create the implementation

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

using System.Numerics;
using AcDream.Core.Physics;

namespace AcDream.App.Rendering;

/// <summary>
/// <see cref="ICameraCollisionProbe"/> backed by the player's swept-sphere
/// engine. Ports retail's <c>SmartBox::update_viewer</c> (0x00453ce0): sweep
/// the 0.3 m <c>viewer_sphere</c> from the head-pivot to the desired eye via a
/// <c>CTransition</c> and use the stopped position. Reusing
/// <see cref="PhysicsEngine.ResolveWithTransition"/> collides against indoor
/// cell walls (<c>FindEnvCollisions</c>) AND outdoor/baked GfxObj shells
/// (<c>FindObjCollisions</c>) in one faithful path.
/// </summary>
public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
{
    /// <summary>Retail <c>viewer_sphere</c> radius (acclient :93314).</summary>
    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);
    }

    /// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>
    internal static Vector3 ToSpherePath(Vector3 spherePoint, float radius)
        => spherePoint - new Vector3(0f, 0f, radius);

    /// <summary>InitPath path point → eye point (add the sphere-center offset back).</summary>
    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
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:

    // ── 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):

    /// <summary>
    /// Optional spring-arm collision probe. When set (and
    /// <see cref="CameraDiagnostics.CollideCamera"/> 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).
    /// </summary>
    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:

    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:


        // 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
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:

                _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
                {
                    Aspect = _chaseCamera.Aspect,
                };

Change it to:

                _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:

        _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
        {
            Aspect = _window!.Size.X / (float)_window.Size.Y,
        };

Change it to:

        _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:

            _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
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:

                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();:

                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
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:

- **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:

- **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
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):

$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:

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.