acdream/docs/superpowers/plans/2026-05-20-indoor-bsp-worldorigin-fix.md
Erik 56816fcbe4 docs(plan): indoor BSP world-origin fix implementation plan
Five-task plan for Bug B slice (spec 2026-05-20). Tasks:
1. Regression test in BSPQueryTests.cs (BSPQuery API contract)
2. Apply Decompose + arg-pass at TransitionTypes.cs:1442
3. Run physics suite, confirm 8-failure baseline holds
4. Commit
5. User visual + probe-equivalence verification

Bug A explicitly deferred to a future slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:45:24 +02:00

21 KiB

Indoor BSP World-Origin Fix 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 indoor BSP step-down + Path-4 land from writing local-space ContactPlanes by passing the cell's actual world rotation + world origin to BSPQuery.FindCollisions.

Architecture: Single-call-site mechanical fix at TransitionTypes.cs:1442. Decompose cellPhysics.WorldTransform once, pass the rotation as localToWorld and the translation as worldOrigin. Mirrors the existing correct pattern at :1808 for object BSP queries.

Tech Stack: C# .NET 10, xUnit, System.Numerics.Matrix4x4.Decompose, existing BSPQuery / Transition / CellPhysics types.

Spec: docs/superpowers/specs/2026-05-20-indoor-bsp-worldorigin-fix-design.md


File Structure

Files to modify:

  • src/AcDream.Core/Physics/TransitionTypes.cs — Single call site at :1442. Add 8-10 lines for Matrix4x4.Decompose + updated arg list.

Files to extend:

  • tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — Add one regression test (~50 lines including fixture builder) at the end of the file. Verifies BSPQuery.FindCollisions with path.StepDown=true + non-identity localToWorld + non-zero worldOrigin writes a world-space ContactPlane.

No files created. No files deleted (Bug A's TryFindIndoorWalkablePlane removal is a separate slice — out of scope for this plan).


Task 1: Add regression test — BSPQuery.FindCollisions Path 3 with translated cell

This test verifies the BSPQuery API's correctness when called with non-identity world transforms — it documents the expected behavior and guards against future regressions in BSPQuery. It will already pass before the fix at :1442 (because BSPQuery itself is correct when called correctly), but it locks in the contract.

Files:

  • Modify: tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — append a new test method after the existing FindWalkableSphere_SteepPoly_RejectedByWalkableAllowance test (the last one in the file, around line 421).

  • Step 1: Write the failing test

Append to tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs (after the last test method, before the closing } of the class):

    // =========================================================================
    // FindCollisions — world-origin / world-rotation correctness (spec 2026-05-20)
    // =========================================================================

    /// <summary>
    /// Build a single-leaf BSP with one horizontal floor polygon at the given
    /// cell-local Z. Vertex array forms a 4x4 m square centered at cell-local
    /// (0, 0).
    /// </summary>
    private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved)
        BuildSingleFloorBsp(float floorZ)
    {
        var verts = new[]
        {
            new Vector3(-2f, -2f, floorZ),
            new Vector3( 2f, -2f, floorZ),
            new Vector3( 2f,  2f, floorZ),
            new Vector3(-2f,  2f, floorZ),
        };

        var root = new PhysicsBSPNode
        {
            Type = BSPNodeType.Leaf,
            BoundingSphere = new Sphere
            {
                Origin = new Vector3(0f, 0f, floorZ),
                Radius = 3f,
            },
        };
        root.Polygons.Add(0);

        var resolved = new Dictionary<ushort, ResolvedPolygon>
        {
            [0] = new ResolvedPolygon
            {
                Vertices  = verts,
                Plane     = new Plane(Vector3.UnitZ, -floorZ),
                NumPoints = 4,
                SidesType = CullMode.None,
            },
        };

        return (root, resolved);
    }

    [Fact]
    public void FindCollisions_StepDown_TranslatedWorldOrigin_WritesWorldSpacePlane()
    {
        // Cell is translated +94 m in world Z (i.e., the cell-local floor at
        // Z=0 sits at world Z=94). This mirrors the Holtburg cottage scenario
        // captured in launch-cp-probe.log (cell 0xA9B40150, floor world
        // Z≈94.02).
        var (root, resolved) = BuildSingleFloorBsp(floorZ: 0f);

        var transition = new Transition();
        transition.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
        transition.SpherePath.WalkInterp        = 1.0f;
        transition.SpherePath.StepDown          = true;
        transition.SpherePath.StepDownAmt       = 0.5f;

        // Foot sphere in cell-local space: just above the floor.
        var localSphere = new Sphere
        {
            Origin = new Vector3(0f, 0f, 0.48f),
            Radius = 0.48f,
        };

        var state = BSPQuery.FindCollisions(
            root,
            resolved,
            transition,
            localSphere,
            localSphere1: null,
            localCurrCenter: localSphere.Origin,
            localSpaceZ: Vector3.UnitZ,
            scale: 1.0f,
            localToWorld: Quaternion.Identity,
            engine: null,
            worldOrigin: new Vector3(0f, 0f, 94f));

        // Path 3 step-down should fire and adjust the sphere onto the floor.
        Assert.Equal(TransitionState.Adjusted, state);

        // ContactPlane must be in world space: normal stays (0,0,1) since the
        // cell rotation is identity; D = -world_floor_Z = -94.
        Assert.True(transition.CollisionInfo.ContactPlaneValid);
        Assert.Equal(1.0f, transition.CollisionInfo.ContactPlane.Normal.Z, precision: 3);
        Assert.Equal(-94.0f, transition.CollisionInfo.ContactPlane.D, precision: 2);
    }
  • Step 2: Run the test to verify it passes

Run:

dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo --filter "FullyQualifiedName~FindCollisions_StepDown_TranslatedWorldOrigin"

Expected:

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1

If it fails, examine the assertion output. The most likely failure mode: TransitionState.OK instead of Adjusted — means the step-down didn't adjust onto the floor. Adjust StepDownAmt / sphere position to ensure the sphere center is within radius of the floor plane so find_walkable accepts the contact.

  • Step 3: Commit
git add tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
git commit -m "test(physics): BSPQuery.FindCollisions writes world-space plane with translated origin

Regression test for indoor BSP world-origin fix (Bug B). Verifies that
BSPQuery.FindCollisions with path.StepDown=true and a non-zero
worldOrigin parameter writes a world-space ContactPlane to
CollisionInfo (not a cell-local-space one).

Passes before the call-site fix at TransitionTypes.cs:1442 because
BSPQuery itself is correct when called with the right args — it's the
caller that was passing the defaults. This test locks in the BSPQuery
contract so the relationship between worldOrigin/localToWorld input and
ContactPlane.D output cannot regress silently.

Spec: docs/superpowers/specs/2026-05-20-indoor-bsp-worldorigin-fix-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: Apply the call-site fix

Decompose cellPhysics.WorldTransform and pass the rotation + translation to BSPQuery.FindCollisions. This is the actual bug fix.

Files:

  • Modify: src/AcDream.Core/Physics/TransitionTypes.cs:1442 — the BSP collision call inside the indoor branch of FindEnvCollisions. Replace the 10-arg invocation with a Decompose + 11-arg invocation including localToWorld + worldOrigin.

  • Step 1: Read the current call site for exact context match

Read src/AcDream.Core/Physics/TransitionTypes.cs lines 1430-1455 to confirm the exact whitespace and surrounding code. (We need the exact old_string for the Edit tool.)

  • Step 2: Apply the edit

Use Edit on src/AcDream.Core/Physics/TransitionTypes.cs:

old_string:

                // Use the full 6-path BSP dispatcher for retail-faithful collision.
                // Use pre-resolved polygons (vertices+planes computed at cache time).
                var cellState = BSPQuery.FindCollisions(
                    cellPhysics.BSP.Root,
                    cellPhysics.Resolved,
                    this,
                    localSphere,
                    localSphere1,
                    localCurrCenter,
                    Vector3.UnitZ,  // local space Z is up
                    1.0f,           // scale = 1.0 for cell geometry
                    Quaternion.Identity,
                    engine);        // engine needed for Path 5 step-up

new_string:

                // Use the full 6-path BSP dispatcher for retail-faithful collision.
                // Use pre-resolved polygons (vertices+planes computed at cache time).
                //
                // 2026-05-20 (Bug B fix): pass the cell's world rotation +
                // translation so the BSP's internal find_walkable /
                // step_sphere_down / Path-4 land write world-space ContactPlanes
                // (NOT cell-local). The prior call passed Quaternion.Identity
                // and defaulted worldOrigin = Vector3.Zero — which corrupted
                // every Path 3 + Path 4 CP write inside an indoor cell with
                // D ≈ 0 instead of D = -world_floor_Z. Mirrors the existing
                // correct pattern at the FindObjCollisions call site.
                //
                // Retail oracle: BSPTREE::find_collisions (decomp
                // acclient_2013_pseudo_c.txt:323924) calls Plane::localtoglobal
                // (:323921) before set_contact_plane. Our TransformNormal +
                // TransformVertices + BuildWorldPlane chain is the equivalent
                // — it just needs the right rotation + origin.
                Quaternion cellRotation;
                Vector3    cellOrigin;
                if (Matrix4x4.Decompose(cellPhysics.WorldTransform, out _, out cellRotation, out cellOrigin))
                {
                    // Clean decompose — use the extracted rotation + translation.
                }
                else
                {
                    // Matrix has shear or other non-decomposable parts. EnvCell
                    // WorldTransform is always rigid rotation + translation per
                    // the dat format, so this branch should never fire. Log a
                    // one-shot warning + fall back to identity rotation +
                    // .Translation so we degrade to "translation-only" instead
                    // of the original "both broken".
                    Console.WriteLine(System.FormattableString.Invariant(
                        $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{sp.CheckCellId:X8} — falling back to identity rotation"));
                    cellRotation = Quaternion.Identity;
                    cellOrigin   = cellPhysics.WorldTransform.Translation;
                }

                var cellState = BSPQuery.FindCollisions(
                    cellPhysics.BSP.Root,
                    cellPhysics.Resolved,
                    this,
                    localSphere,
                    localSphere1,
                    localCurrCenter,
                    Vector3.UnitZ,  // local space Z is up
                    1.0f,           // scale = 1.0 for cell geometry
                    cellRotation,
                    engine,         // engine needed for Path 5 step-up
                    worldOrigin: cellOrigin);
  • Step 3: Build to verify the change compiles

Run:

dotnet build -c Debug

Expected: Build succeeded. with 0 errors. Warnings unchanged from baseline (3 pre-existing xUnit warnings).

  • Step 4: Run the regression test (Task 1's) to verify it still passes

Run:

dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --no-build --nologo --filter "FullyQualifiedName~FindCollisions_StepDown_TranslatedWorldOrigin"

Expected:

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1

(The Task 1 test exercises BSPQuery directly; this fix is at the caller, so the test still passes.)


Task 3: Run the broader physics test suite — confirm no regressions

Verify the existing 8-failure baseline is unchanged (2 BSPStepUp + 6 MotionInterpreter pre-existing failures, all unrelated to this fix).

  • Step 1: Run all physics tests

Run:

dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --no-build --nologo --filter "FullyQualifiedName~Physics"

Expected:

Failed!  - Failed:     8, Passed:   ...(at least 405), Skipped:     0, Total:   ...

The 8 failures are the documented baseline. If you see 9+ failures or different failure names, the fix introduced a regression — investigate before continuing.

  • Step 2: Cross-check the failures match the baseline

Verify the failed test names match this set (no new failures, no new passes that were previously failing):

Expected failing tests:

AcDream.Core.Tests.Physics.MotionInterpreterTests.GetMaxSpeed_WalkForward_ReturnsWalkAnimSpeed
AcDream.Core.Tests.Physics.MotionInterpreterTests.GetMaxSpeed_Idle_ReturnsZero
AcDream.Core.Tests.Physics.MotionInterpreterTests.GetMaxSpeed_WalkBackward_ReturnsWalkAnimSpeedTimesBackwardsFactor
AcDream.Core.Tests.Physics.MotionInterpreterTests.GetMaxSpeed_*  (3 more)
AcDream.Core.Tests.Physics.BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide
AcDream.Core.Tests.Physics.BSPStepUpTests.*  (1 more)

If the failing-test list has changed, stop and investigate. The fix should not affect motion-interpreter or step-up behavior.


Task 4: Commit the fix

  • Step 1: Stage the modified file
git add src/AcDream.Core/Physics/TransitionTypes.cs
git status

Expected git status:

Changes to be committed:
  modified:   src/AcDream.Core/Physics/TransitionTypes.cs

(Only TransitionTypes.cs should be staged. If the test file appears, it was already committed in Task 1.)

  • Step 2: Commit with the spec's commit message
git commit -m "fix(physics): pass cell world-transform to indoor BSP collision

Indoor cell BSP queries at TransitionTypes.cs:1442 were calling
BSPQuery.FindCollisions with Quaternion.Identity + defaulted
Vector3.Zero worldOrigin. Inside the BSP, Path 3 (step_sphere_down)
and Path 4 (land-on-surface) use those params to build the
world-space ContactPlane. Result: planes written with D ~ 0 instead
of the cell's world floor Z (e.g. -94.02 for Holtburg cottages).
320 corrupt CP writes per Holtburg session per the [cp-write] probe.

Fix: decompose cellPhysics.WorldTransform once at the call site,
pass the rotation as localToWorld and the translation as
worldOrigin. Mirrors the existing correct pattern at :1808
(FindObjCollisions, passes obj.Rotation + obj.Position).

Retail oracle: BSPTREE::find_collisions (acclient_2013_pseudo_c.txt:323924)
calls Plane::localtoglobal at :323921 before set_contact_plane.
Our TransformNormal + TransformVertices + BuildWorldPlane chain is
the equivalent — it just needs the right rotation + origin.

Spec: docs/superpowers/specs/2026-05-20-indoor-bsp-worldorigin-fix-design.md.
Evidence: launch-cp-probe.log capture 2026-05-20, [cp-write] probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
  • Step 3: Verify commit landed cleanly
git log --oneline -3

Expected: top of log shows the fix commit, followed by the regression-test commit from Task 1, followed by 865634f docs(spec): indoor BSP world-origin / world-rotation fix (Bug B).


Task 5: Hand off to user for visual verification

The fix's acceptance test is visual + probe-equivalence. The user drives the client and confirms (a) [cp-write] caller=BSPQuery.StepSphereDown:1123 lines now show correct world-Z D values, and (b) indoor walking visual scenarios behave better.

  • Step 1: Confirm graceful-logout state

Before launching, check no stale acdream client is running (per CLAUDE.md's logout-before-reconnect rules). Run:

Get-Process -Name AcDream.App -ErrorAction SilentlyContinue

If a process is listed, close it gracefully first:

$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($proc) {
    $proc.CloseMainWindow() | Out-Null
    $proc.WaitForExit(5000) | Out-Null
}
Start-Sleep -Seconds 3
  • Step 2: Tell the user the launch command

Show the user (do NOT auto-launch — user retains control here):

Fix landed. Ready for visual verification. Launch with the probe flags on:

$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"
$env:ACDREAM_PROBE_INDOOR_BSP    = "1"
$env:ACDREAM_PROBE_CONTACT_PLANE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-cp-probe-postfix.log"

Walk these scenarios (~30 seconds each):

  1. Cottage entry (outdoor → indoor)
  2. Indoor standstill for ~10 seconds (stationary case — may still glitch; that's Bug A, expected)
  3. 2nd-floor walking (primary success criterion for Bug B)
  4. Cellar descent
  5. Single-floor cottage walk (regression check)

Then close the window (CloseMainWindow path for graceful logout). I'll grep the new log for the StepSphereDown:1123 lines and confirm the D values are now correct.

  • Step 3: Read + analyze the post-fix log

After the user closes the window, run:

Get-Content launch-cp-probe-postfix.log -Encoding Unicode | Out-File launch-cp-probe-postfix.utf8.log -Encoding utf8

Then use Bash:

echo "=== StepSphereDown D distribution (post-fix) ==="
grep -E 'caller=BSPQuery\.StepSphereDown' launch-cp-probe-postfix.utf8.log | grep -oE 'D=[-0-9.]+' | sort | uniq -c | sort -rn | head -10
echo "=== Caller distribution (compare to pre-fix) ==="
grep -oE 'caller=[A-Za-z_.:0-9]+' launch-cp-probe-postfix.utf8.log | sort | uniq -c | sort -rn | head -10
echo "=== indoor-walkable result distribution (should be ~same; Bug A still active) ==="
grep -oE '\[indoor-walkable\].*result=[A-Z]+' launch-cp-probe-postfix.utf8.log | grep -oE 'result=[A-Z]+' | sort | uniq -c

Expected:

  • StepSphereDown D values cluster around realistic floor Z values (-94.0, -94.02, etc., depending on the cell), NOT D=-0.000.

  • StepSphereDown caller count is similar to pre-fix (~320) OR much lower (the Plane-equality suppression in the property setter hides duplicate writes; if seed and BSP-write both produce the same world plane, only the seed shows up).

  • indoor-walkable MISS/HIT ratio still ~99% MISS (Bug A unchanged).

  • Step 4: Hand off to the user with the assessment

Tell the user one of:

On success (D values now correct):

Probe-equivalence verified — StepSphereDown D values are now world-Z (e.g. -94.02), not -0.000. Bug B closed. Visual report? If 2nd-floor walking is stabler but stationary cases (cellar entry, indoor standstill) still glitch, that's Bug A — next slice. If you saw the M2-critical indoor scenarios work end-to-end, we can decide whether to ship Bug A as a follow-up phase or punt to post-M2 polish.

On failure (D values still wrong):

StepSphereDown D values are still wrong (showing X / -0.000 / etc.). The Decompose path either didn't fire or extracted the wrong values. Investigate: check what cellPhysics.WorldTransform actually looks like for the affected cells (might need a one-shot [cell-transform] probe).


Self-review (run after writing this plan)

  • Spec coverage: Plan covers the spec's Fix section (Task 2), Verification section (Task 3 + Task 5), and the call-site change (Task 2). The risk R1 (cell scale) is acknowledged in-spec but no plan task — fine, deferred. Risk R2 (Decompose returns false) handled inline in Task 2's else branch. Risk R3 + R4 informational; no plan task needed.
  • Placeholder scan: No TBD/TODO. Every code block is complete. Commands have exact expected output strings.
  • Type consistency: cellRotation (Quaternion), cellOrigin (Vector3), cellPhysics.WorldTransform (Matrix4x4) — consistent across Task 2.
  • Test coverage: Task 1 covers BSPQuery API regression. Visual verification (Task 5) covers the call-site fix. Acceptable per project convention for one-line mechanical fixes with probe evidence.
  • Bug A is explicitly out of scope. Plan acknowledges it (Task 5 step 4) and defers to a future slice.