Brainstormed spec for resolving the cellar-descent / 2nd-floor /
invisible-obstacle indoor collision regressions reported post-Phase 2.
Root cause: Phase 2 commit eb0f772 introduced TryFindIndoorWalkablePlane
as a stop-gap walkable-plane synthesis when the indoor BSP returns OK.
Its body does a linear first-match XY scan over cellPhysics.Resolved with
no Z-proximity test, so multi-Z indoor geometry (cellars, 2nd floors,
balconies) collapses to wrong-floor selection. Walking UP stairs works
because step_up routes through DoStepDown → TransitionalInsert(5) →
BSPQuery.FindCollisions Path 3 (StepSphereDown) which already uses
FindWalkableInternal — the retail-faithful BSP walkable-finder. The
linear scan only fires in the OK-no-wall branch.
Fix: route TryFindIndoorWalkablePlane through the existing
FindWalkableInternal via a thin new BSPQuery.FindWalkableSphere wrapper.
Extends FindWalkableInternal's signature to expose the hit polyId
(dictionary key, since ResolvedPolygon doesn't carry its own id). Threads
the foot-sphere radius through TryFindIndoorWalkablePlane's signature
(was hardcoded to nothing — used the localFootCenter alone). Deletes
the now-dead PointInPolygonXY helper.
Awaiting user spec review before plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
25 KiB
Indoor Walkable-Plane BSP Port — Design
Status: Brainstormed 2026-05-19. Awaiting user spec review before plan.
Scope: Replace TryFindIndoorWalkablePlane's linear first-match scan with a thin wrapper over the existing retail-faithful BSP walkable-finder (BSPQuery.FindWalkableInternal). Restores closest-walkable-poly-along-up-vector semantics for indoor cells with multiple floors at different Z (cellars, 2nd floors, balconies).
Predecessor: Indoor walking Phase 2 — Portal-based cell tracking (docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md) shipped 2026-05-19. Phase 2's commit 6 (eb0f772) introduced TryFindIndoorWalkablePlane as a stop-gap walkable-plane synthesis when the indoor BSP returns OK; it uses a linear first-match XY scan that ignores Z, which collapses to wrong-floor selection on any multi-Z indoor geometry.
Retail oracle: docs/research/named-retail/acclient_2013_pseudo_c.txt:
BSPLEAF::find_walkableat 326793 — the leaf-level walkable test.BSPNODE::find_walkableat 326211 — the BSP-traversal internal-node version.CPolygon::walkable_hits_sphereat 323006 —N·up > walkableAllowanceAND XY-overlap test.CPolygon::adjust_sphere_to_planeat 322032 — slides sphere along the movement vector to rest on the polygon's plane;walk_interpis ratcheted down to the earliest hit.
1. What we know
The retail walkable-finder is already ported. BSPQuery.FindWalkableInternal implements the full retail algorithm — BSP traversal, leaf-level polygon scan, WalkableHitsSphere + AdjustSphereToPlane. It is used by:
BSPQuery.StepSphereDown(Path 3) at BSPQuery.cs:1107 — the step-down branch invoked fromDoStepDown→TransitionalInsert(5).BSPQuery.FindCollisionsPath 4 (Collide) at BSPQuery.cs:1492 — landing on a surface after free-fall.
The new helper that doesn't use it. Transition.TryFindIndoorWalkablePlane at TransitionTypes.cs:1192 was added in Phase 2 commit 6 (eb0f772) to synthesize an indoor walkable plane in the case where the indoor BSP returns OK (no wall collision). Its body iterates cellPhysics.Resolved in dictionary order, returns the first polygon with Normal.Z >= 0.6664 whose XY contains the foot. There is no Z-proximity test.
Visual evidence (user-reported, 2026-05-19, post-Phase-2-merge):
- Walking UP stairs in houses works (step_up → DoStepDown routes through Path 3 =
FindWalkableInternal, which already picks the closest walkable). - Walking DOWN into cellars is broken (player can't descend; standing on upper floor with cellar floor beneath → linear scan picks upper floor → ValidateWalkable can't drop player below it).
- Walking on 2nd floor is broken (linear scan picks 1st-floor poly → player snaps to 1st floor or is reported airborne above 2nd floor).
- "Invisible obstacles at certain spots" — suspected cascade effect of wrong-Z ContactPlane (resolver flags body as airborne / submerged → next-frame collision misroutes). Not a separate root cause hypothesis.
2. Goal
Route indoor walkable-plane synthesis through BSPQuery.FindWalkableInternal so the synthesized ContactPlane is the polygon the player is actually standing on (closest walkable surface below the foot, along the local up vector), not the first walkable polygon in dictionary order.
Expected to fix:
- Cellar descent (player can step off the upper floor and descend through the stairwell onto the cellar floor).
- 2nd-floor walking (player stays on the upper floor without snapping back to ground level).
Possibly fixes (cascade):
- "Invisible obstacles at certain spots" — if this is downstream of wrong-Z ContactPlane causing the resolver to misclassify the body's grounded/airborne state. If after the fix these persist, that's a separate phase.
Possibly fixes (related):
- ISSUES.md #88 (indoor static objects vibrate). If the per-frame ContactPlane Z flickers between two overlapping floors, dependent state may re-fire (
EntityScriptActivatorOnCreate/OnRemove, per-part transforms). Not the primary target; user re-verifies #88 after the fix lands.
Out of scope:
- Outdoor terrain walkable selection (
PhysicsEngine.SampleTerrainWalkable) — unchanged. - BSP collision dispatcher (
BSPQuery.FindCollisionsPaths 1–6) — unchanged. - Step-up / step-down flow (
DoStepUp/DoStepDown) — unchanged. Stairs going UP already work; we don't risk regressing that path. - Cell transit / portal traversal (Phase 2
CellTransit) — unchanged. - Building-shell outdoor→indoor entry (
CheckBuildingTransit) — unchanged. - Issue #89 (
SphereIntersectsCellBspretail-faithful port) — unchanged; separate phase.
3. Architecture
One change, two callers.
-
New public entry point in
BSPQuery— a thin wrapper over the existing privateFindWalkableInternal. -
Refactor of
Transition.TryFindIndoorWalkablePlane— replace the linear scan body with a call to the new entry point. Public signature unchanged. -
Removal of
Transition.PointInPolygonXY— only callsite was the linear-scan body; becomes dead code.
Movement tick → resolver substep
│
▼
Transition.FindEnvCollisions (TransitionTypes.cs:1262) [UNCHANGED]
│
├─ Transform foot sphere to cell-local space [UNCHANGED]
├─ BSPQuery.FindCollisions(cellPhysics.BSP, ...) [UNCHANGED]
│ └─ Wall collision: returns Slid / Adjusted / Collided
│ → early-return; never reaches walkable synthesis
├─ If result != OK → early-return [UNCHANGED]
└─ If result == OK:
▼
Transition.TryFindIndoorWalkablePlane [REFACTORED]
│
├─ Save path.WalkableAllowance
├─ Set path.WalkableAllowance = PhysicsGlobals.FloorZ
├─ Build localMovement = -localUp * PROBE_DISTANCE
├─ Build localSphere from localFootCenter + radius
│
├─ ╔══════════════════════════════════════════════╗
│ ║ BSPQuery.FindWalkableSphere ║ [NEW WRAPPER]
│ ║ wraps the existing FindWalkableInternal ║
│ ║ (BSPNode.find_walkable + BSPLeaf.find_walkable
│ ║ port — retail at acclient_2013_pseudo_c.txt
│ ║ 326211 + 326793) ║
│ ╚══════════════════════════════════════════════╝
│ │
│ ▼
│ ResolvedPolygon? hitPoly + Vector3 adjustedCenter
│
├─ Restore path.WalkableAllowance (try/finally)
├─ If hitPoly == null → return false (caller falls
│ through to outdoor-terrain backstop, unchanged)
└─ Transform hitPoly plane + vertices to world space
(existing helper logic, kept verbatim)
→ return true with (worldPlane, worldVertices, hitPolyId)
▼
Transition.ValidateWalkable(worldPlane, ...) [UNCHANGED]
The refactor is surgical: one helper body changes, one wrapper is added, one dead helper deleted. No call-site changes. No new types. No new flags. No new env vars.
4. Implementation surface
4.1 BSPQuery.FindWalkableSphere (new public entry point) + small extension to FindWalkableInternal
ResolvedPolygon does not carry its own id — the polyId is the Dictionary<ushort, ResolvedPolygon> key. FindWalkableInternal iterates foreach (ushort polyId in node.Polygons) in the leaf branch (BSPQuery.cs:665), so the key IS available internally — we just need to expose it. Two coupled changes:
(a) Extend FindWalkableInternal signature with a ref ushort hitPolyId param. Update the leaf branch's write to set both hitPoly AND hitPolyId together. Update all internal recursion sites (BSPQuery.cs:688, :695, :701, :703) to thread the new ref. Update the two existing callers (StepSphereDown at :1107 and Path 4 at :1492) to declare a local ushort _ if they don't care about the id (existing behavior preserved — they only use hitPoly).
(b) Add the public wrapper. Place in src/AcDream.Core/Physics/BSPQuery.cs adjacent to StepSphereDown (around line 1085) since they share the same call shape into FindWalkableInternal.
/// <summary>
/// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf
/// find_walkable BSP traversal. Probes downward by <paramref name="probeDistance"/>
/// along <paramref name="up"/> and returns the closest walkable polygon the
/// sphere would rest on, with the sphere's center adjusted to lie on that plane.
///
/// <para>
/// Wraps the existing private <see cref="FindWalkableInternal"/> — which already
/// implements the retail-faithful walkable-finder
/// (BSPNODE::find_walkable + BSPLEAF::find_walkable +
/// CPolygon::walkable_hits_sphere + CPolygon::adjust_sphere_to_plane,
/// acclient_2013_pseudo_c.txt:326211, :326793, :323006, :322032).
/// </para>
///
/// <para>
/// Intended call site: indoor walkable-plane synthesis in
/// <see cref="Transition.TryFindIndoorWalkablePlane"/> when the indoor cell-BSP
/// collision returns OK (no wall hit) and the resolver still needs a
/// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
/// (<see cref="PhysicsEngine.SampleTerrainWalkable"/>) and does not use this.
/// </para>
///
/// <para>
/// The caller is responsible for setting <c>transition.SpherePath.WalkableAllowance</c>
/// to the desired walkability threshold (typically <see cref="PhysicsGlobals.FloorZ"/>)
/// before calling, and restoring it after. Cheapest pattern: try/finally with
/// save→set→call→restore.
/// </para>
/// </summary>
/// <param name="root">Root of the cell's PhysicsBSP.</param>
/// <param name="resolved">Pre-resolved polygon dictionary from PhysicsDataCache.</param>
/// <param name="transition">Current transition (read for WalkableAllowance / walk_interp).</param>
/// <param name="sphere">Foot sphere in cell-local space.</param>
/// <param name="probeDistance">Downward probe distance in meters. Typical: 0.5f.</param>
/// <param name="up">Up vector in cell-local space (typically Vector3.UnitZ).</param>
/// <param name="hitPoly">Output: the walkable polygon found, or null on miss.</param>
/// <param name="hitPolyId">Output: polygon id (dictionary key) of the hit. Zero on miss.</param>
/// <param name="adjustedCenter">
/// Output: sphere center adjusted onto the polygon plane. Equal to input
/// <c>sphere.Origin</c> on miss.
/// </param>
/// <returns>True if a walkable polygon was found; false otherwise.</returns>
public static bool FindWalkableSphere(
PhysicsBSPNode? root,
Dictionary<ushort, ResolvedPolygon> resolved,
Transition transition,
DatReaderWriter.Types.Sphere sphere,
float probeDistance,
Vector3 up,
out ResolvedPolygon? hitPoly,
out ushort hitPolyId,
out Vector3 adjustedCenter)
{
adjustedCenter = sphere.Origin;
hitPoly = null;
hitPolyId = 0;
if (root is null) return false;
var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
var movement = -up * probeDistance;
bool changed = false;
ushort polyId = 0;
FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
movement, up, ref hitPoly, ref polyId, ref changed);
if (changed && hitPoly is not null)
{
adjustedCenter = validPos.Center;
hitPolyId = polyId;
return true;
}
hitPoly = null;
hitPolyId = 0;
return false;
}
Notes on the wrapper:
- Pure, no side effects on call args other than
outparams. FindWalkableInternalmutatesvalidPosandpath.WalkInterp(viaAdjustSphereToPlane); the wrapper isolatesvalidPosto a local.path.WalkInterpmutation is intentional and matches retail'swalk_interpratcheting — caller's responsibility to save/restore if needed.- No new dependencies. All types already in scope.
4.2 Transition.TryFindIndoorWalkablePlane (refactored body + extended signature)
File: src/AcDream.Core/Physics/TransitionTypes.cs (around line 1192). Signature gains a sphereRadius parameter (rationale §4.3):
internal bool TryFindIndoorWalkablePlane(
CellPhysics cellPhysics,
Vector3 localFootCenter,
float sphereRadius,
out System.Numerics.Plane worldPlane,
out Vector3[] worldVertices,
out uint hitPolyId)
The helper changes from internal static to internal (instance method) so it can access this.SpherePath for the WalkableAllowance save/restore and pass this (Transition) to BSPQuery.FindWalkableSphere. The single callsite at TransitionTypes.cs:1358 is already inside a Transition instance method (FindEnvCollisions).
Body:
worldPlane = default;
worldVertices = System.Array.Empty<Vector3>();
hitPolyId = 0;
if (cellPhysics.BSP?.Root is null) return false;
// Build foot sphere in cell-local space. Caller passes localFootCenter already
// transformed into cell-local space and the resolver's foot-sphere radius.
var localSphere = new DatReaderWriter.Types.Sphere
{
Origin = localFootCenter,
Radius = sphereRadius,
};
// Save/restore WalkableAllowance: the BSP walkable test consumes
// path.WalkableAllowance (CPolygon::walkable_hits_sphere reads this field,
// acclient_2013_pseudo_c.txt:323010). For "standing here, find my floor" we
// want the walkability slope threshold FloorZ. The outer resolver may have
// set it to LandingZ (airborne→ground transition) or another value; we
// must not leak our change back to the resolver.
float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
ResolvedPolygon? hitPoly = null;
ushort hitId = 0;
Vector3 adjustedCenter;
bool found;
try
{
found = BSPQuery.FindWalkableSphere(
cellPhysics.BSP.Root,
cellPhysics.Resolved,
this,
localSphere,
INDOOR_WALKABLE_PROBE_DISTANCE, // see §4.3
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
out hitPoly,
out hitId,
out adjustedCenter);
}
finally
{
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
}
if (!found || hitPoly is null) return false;
// Transform hit polygon's plane + vertices to world space. This block is
// kept verbatim from the existing TryFindIndoorWalkablePlane implementation —
// the world-transform math is unchanged.
var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
worldNormal = Vector3.Normalize(worldNormal);
var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
float worldD = -Vector3.Dot(worldNormal, worldV0);
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
worldVertices = new Vector3[hitPoly.Vertices.Length];
for (int i = 0; i < hitPoly.Vertices.Length; i++)
worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
hitPolyId = hitId;
return true;
4.3 Constants and parameter rationale
| Symbol | Kind | Value / source | Rationale |
|---|---|---|---|
INDOOR_WALKABLE_PROBE_DISTANCE |
private const float in Transition |
0.5f |
50 cm. Larger than the +0.02f cell-origin Z-bump (25× headroom). Larger than any realistic step riser (~20 cm). Smaller than a full cell height (~3 m) so we don't reach through a thin floor into the cell above/below. |
sphereRadius |
method parameter | sourced from sp.GlobalSphere[0].Radius at the FindEnvCollisions call site (already bound at TransitionTypes.cs:1268) |
The foot sphere radius is per-entity (player ≠ creature). Hardcoding would be wrong; threading the parameter is one line at the callsite. |
4.4 Callsite update
At TransitionTypes.cs:1358, the existing call:
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
out var indoorPlane,
out var indoorVertices,
out uint _))
becomes:
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
out var indoorPlane,
out var indoorVertices,
out uint _))
sphereRadius is already in scope from line 1268.
4.5 Delete Transition.PointInPolygonXY
At TransitionTypes.cs:1238. Only call site was the deleted linear-scan loop. Remove the method entirely.
5. Diagnostics
Extend existing [indoor-bsp] probe surface, no new probe.
The existing [indoor-bsp] line in FindEnvCollisions (at TransitionTypes.cs:1334) already prints per-call BSP-collision state. Add a sibling line [indoor-walkable] printed when the OK-result branch calls TryFindIndoorWalkablePlane, gated on the same PhysicsDiagnostics.ProbeIndoorBspEnabled flag (no new flag).
Print format:
[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=HIT poly=0x0042 wn=(0.000,0.000,1.000) wD=-2.75 dz=+0.03
or
[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=MISS
Where:
wpos— world-space foot center.wn/wD— world-space plane normal + D (on HIT only).dz— signed Z gap between foot center and plane (positive = player above plane, negative = below).
Runtime-toggleable via the existing DebugPanel "Indoor BSP probe" checkbox. Cost when probe disabled: a single bool check (early-return).
For cellar descent, expect the trace to flip from "always picks upper floor (dz≈+3.0)" pre-fix to "picks cellar floor below (dz≈+0.03)" post-fix.
6. Testing
6.1 Unit tests (new)
Location: tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs (adjacent to existing BSPQuery tests).
Test 1: FindWalkableSphere_TwoOverlappingFloors_PicksClosestBelowFoot
Synthetic mini-BSP with two horizontal walkable polys at Z=0 and Z=3, both passing XY through (0,0). Foot sphere centered at (0,0,1) radius 0.48, probe 0.5. Assert returned poly is the Z=0 one; assert adjustedCenter.Z ≈ 0.48.
Test 2: FindWalkableSphere_TwoOverlappingFloors_FootAbove_PicksUpper
Same mini-BSP. Foot at (0,0,3.6), probe 0.5. Assert returned poly is the Z=3 one; assert adjustedCenter.Z ≈ 3.48.
Test 3: FindWalkableSphere_NoWalkableInRange_ReturnsFalse
Two polys at Z=0 and Z=3. Foot at (0,0,5), probe 0.5. Sphere is more than probe + radius above the Z=3 plane; nothing in range. Assert returns false; assert adjustedCenter == sphere.Origin.
Test 4: FindWalkableSphere_SteepPolyRejected
Mini-BSP with one poly whose normal Z = 0.5 (52° slope, below FloorZ=0.6664). Foot above it. Caller sets WalkableAllowance = FloorZ. Assert returns false (poly rejected as too steep).
Test 5: TryFindIndoorWalkablePlane_RoutesThroughBSPQuery_PreservesAllowance
Integration test with a real CellPhysics fixture (two-floor cell). Set path.WalkableAllowance to a sentinel value (e.g. 0.42f). Call TryFindIndoorWalkablePlane. Assert returned plane corresponds to the closest floor below the foot. Assert path.WalkableAllowance == 0.42f after the call (save/restore worked).
6.2 Existing test baselines
dotnet buildclean.dotnet testshows the same 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline). No new failures.- Phase 2 indoor-walking conformance unchanged (single-floor cottage cell remains correct — the new closest-below algorithm degenerates to the existing first-match behavior when only one walkable poly exists).
6.3 Visual verification (the real acceptance test)
User-driven, the only "milestone" verification the project uses (per CLAUDE.md "milestones are textual events").
Required scenarios:
| # | Scenario | Pre-fix behavior | Acceptance |
|---|---|---|---|
| 1 | Walk into Holtburg cottage, walk around single-floor interior | Works (Phase 2) | Still works — no regression |
| 2 | Walk between cottage rooms via doorways | Works (Phase 2) | Still works — no regression |
| 3 | Walk back outside through cottage door | Works (Phase 2) | Still works — no regression |
| 4 | Find any building with a cellar entry, walk to and descend the stairs | Stuck / bounces at top of stairs | Smooth descent onto cellar floor |
| 5 | Find any 2-story building, climb stairs to 2nd floor, walk around upper floor | Snaps back to 1st floor or "invisible obstacles" | Stays on 2nd floor, free movement |
| 6 | Walk near previously-reported "invisible obstacle" spots | Hits invisible wall | (Hypothesis check) — invisible obstacles gone if cascade theory correct |
| 7 | (Optional) Observe bookshelves/furnaces #88 vibration | Visible jitter | (Hypothesis check) — jitter reduced if cascade theory correct |
If scenario 4 or 5 still fails after this lands, that's an indicator the diagnosis is incomplete — file a follow-up phase. If scenario 6 still fails but 4/5 work, that's a separate bug (probably real BSP-classification issue, not walkable-plane).
7. Edge cases
Handled by the existing FindWalkableInternal (not new logic):
- Sphere doesn't intersect any BSP node → no recursion,
changedstays false, miss path runs. - BSP root is null → wrapper returns false before recursion.
- Multiple walkable polys in the same leaf → loop visits all,
AdjustSphereToPlaneratchetswalk_interpdown to the closest hit (retail-faithful behavior, see acclient_2013_pseudo_c.txt:322055). WalkableAllowance > 1.0(illegal) →WalkableHitsSpherereturns false for every poly, miss path runs (defensive).
Introduced by the wrapper:
WalkableAllowancesave/restore wrapped in try/finally so a thrown exception insideFindWalkableInternaldoesn't leak modified state to the resolver.
Cell-state edge cases (unchanged from Phase 2):
- Cell has no walkable polys (only walls + ceiling) → wrapper returns false →
FindEnvCollisionsfalls through to outdoor-terrain backstop (existing behavior at TransitionTypes.cs:1372). - Cell origin Z-bump (+0.02f) interaction — probe distance 0.5f is 25× the bump, so the bump is noise within the probe range.
8. Acceptance criteria
dotnet build -c Debugclean.dotnet testshows the same 8 pre-existing failures (no new failures from this work).- New unit tests 1–5 (§6.1) pass.
- Visual verification scenarios 1–5 (§6.3) all pass per user testing.
- Visual verification scenarios 6 and 7 reported as PASS/FAIL by user (cascade hypothesis confirmation, not gating).
- Roadmap shipped table updated.
- ISSUES.md #83 closed (Walking up stairs broken — bug now scoped to "walking DOWN in multi-floor cells").
- Phase memory updated if a durable lesson surfaces during implementation.
9. References
Retail oracle:
docs/research/named-retail/acclient_2013_pseudo_c.txt:326211—BSPNODE::find_walkabledocs/research/named-retail/acclient_2013_pseudo_c.txt:326793—BSPLEAF::find_walkabledocs/research/named-retail/acclient_2013_pseudo_c.txt:323006—CPolygon::walkable_hits_spheredocs/research/named-retail/acclient_2013_pseudo_c.txt:322032—CPolygon::adjust_sphere_to_planedocs/research/named-retail/acclient_2013_pseudo_c.txt:323565—BSPTREE::step_sphere_up(related; uses find_walkable via step_sphere_down)
acdream code:
src/AcDream.Core/Physics/BSPQuery.cs:647—FindWalkableInternal(existing retail port)src/AcDream.Core/Physics/BSPQuery.cs:1085—StepSphereDown(existing caller of FindWalkableInternal)src/AcDream.Core/Physics/TransitionTypes.cs:1192—TryFindIndoorWalkablePlane(the helper being refactored)src/AcDream.Core/Physics/TransitionTypes.cs:1262—FindEnvCollisions(the single callsite)src/AcDream.Core/Physics/PhysicsDiagnostics.cs— diagnostic flag pattern (existingProbeIndoorBspEnabled)
Predecessor specs:
docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md— Phase 1 (cell-BSP wall collision).docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md— Phase 2 (portal cell tracking).
Phase 2 ship handoff:
docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md— context for theTryFindIndoorWalkablePlaneintroduction in commiteb0f772.
Issue tracking:
docs/ISSUES.md#83 (Walking up stairs broken) — primary scope. Title is misleading per user: actual symptom is walking DOWN in multi-floor cells (cellars, descending stairs).docs/ISSUES.md#88 (Indoor static objects vibrate) — possibly downstream; user re-verifies after fix lands.docs/ISSUES.md#89 (PortSphereIntersectsCellBsp) — explicitly out of scope; separate phase.