fix(physics): remove per-frame indoor walkable-plane synthesis

The indoor branch of FindEnvCollisions called Transition.TryFindIndoorWalkablePlane
every frame to re-synthesize the ContactPlane after BSP returned OK.
The synthesis routed through BSPQuery.FindWalkableSphere ->
walkable_hits_sphere, which correctly rejects tangent contact via
|dist| > radius - epsilon. For a grounded player standing on or
brushing a floor, the foot sphere is tangent: 99.87% MISS rate per
the 2026-05-20 [cp-write] probe (3150 MISS / 3154 calls). Each MISS
fell through to outdoor terrain backstop, writing a ContactPlane
that's below the indoor floor by ~0.02m (the render Z-bump),
marking the player airborne and triggering the falling-animation
stuck symptom user-reported on 2nd-floor walks.

Fix: delete the synthesis + outdoor-fallthrough from the indoor OK
path. ContactPlane is retained from the prior tick's seed
(PhysicsEngine.ResolveWithTransition:583, init_contact_plane
equivalent) or refreshed by BSP Path 3 (step_sphere_down) / Path 4
(land-on-surface) during the same tick. Matches retail's
BSPTREE::find_collisions OK path (acclient_2013_pseudo_c.txt:323938).

Also deletes:
- Transition.TryFindIndoorWalkablePlane (~104 lines incl. doc-comment)
- INDOOR_WALKABLE_PROBE_DISTANCE constant
- [indoor-walkable] probe log line
- IndoorWalkablePlaneTests.cs (8 tests, the helper's coverage)
- TransitionTypesTests.cs (1 test, also tested the helper)

Net: -491 lines. BSPQuery.FindWalkableSphere + its 5 unit tests
retained as the underlying retail-faithful walkable-finder API
(reachable for spawn-placement / teleport-verification / future
debug needs; its doc-comment is updated to reflect the change).

Closes Bug A in the indoor ContactPlane retention phase.
Spec: docs/superpowers/specs/2026-05-20-indoor-walkable-synthesis-removal-design.md.
Plan: docs/superpowers/plans/2026-05-20-indoor-walkable-synthesis-removal.md.
Predecessor: de8ffde (Bug B, BSP world-origin fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-20 09:11:04 +02:00
parent 686f27f227
commit 9f874f4650
4 changed files with 23 additions and 573 deletions

View file

@ -1152,11 +1152,14 @@ public static class BSPQuery
/// </para>
///
/// <para>
/// Intended call site: indoor walkable-plane synthesis in
/// <c>Transition.TryFindIndoorWalkablePlane</c> 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.
/// Out-of-band "find a walkable plane indoors" entry point for callers
/// that genuinely need to query a cell's walkable floor (spawn-placement
/// validation, teleport-target verification, future debug overlays).
/// NOT called from the per-frame physics resolver — the original
/// per-frame caller (TryFindIndoorWalkablePlane) was deleted 2026-05-20
/// because retail's BSPTREE::find_collisions does NOT re-synthesize the
/// ContactPlane on the OK path. The wrapper is kept here as the
/// underlying retail-faithful walkable-finder API.
/// </para>
///
/// <para>

View file

@ -1266,120 +1266,6 @@ public sealed class Transition
// Environment collision — outdoor terrain
// -----------------------------------------------------------------------
/// <summary>
/// Synthesize the indoor walkable contact plane for the player's current
/// position when the cell BSP returns OK (no wall collision).
///
/// <para>
/// Routes through the retail-faithful BSP walkable-finder
/// (<see cref="BSPQuery.FindWalkableSphere"/>) — which traverses the cell
/// PhysicsBSP and picks the polygon closest to the foot along the up vector.
/// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
/// stop-gap; that scan picked the wrong floor whenever two polygons
/// overlapped in XY at different Z (cellars, 2nd floors, balconies).
/// </para>
///
/// <para>
/// Returns <c>false</c> if no walkable floor poly is found under the
/// player. The caller falls through to outdoor terrain in that case
/// (defensive backstop — should not normally happen inside a sealed cell).
/// </para>
///
/// <para>
/// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793),
/// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006),
/// CPolygon::adjust_sphere_to_plane (:322032).
/// </para>
/// </summary>
internal bool TryFindIndoorWalkablePlane(
CellPhysics cellPhysics,
Vector3 localFootCenter,
float sphereRadius,
out System.Numerics.Plane worldPlane,
out Vector3[] worldVertices,
out uint hitPolyId)
{
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: CPolygon::walkable_hits_sphere reads
// path.WalkableAllowance (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. try/finally so an exception inside
// FindWalkableSphere doesn't leak the modified state.
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,
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
out hitPoly,
out hitId,
out adjustedCenter);
}
finally
{
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
}
// adjustedCenter (sphere slid onto polygon plane) is intentionally
// discarded — ValidateWalkable recomputes contact geometry from the
// world-space plane + foot position, consistent with the outdoor terrain
// path (SampleTerrainWalkable returns only plane + vertices, no adjusted
// sphere). The local is held only to satisfy the out param.
if (!found || hitPoly is null) return false;
// Transform hit polygon's plane + vertices to world space. Math is
// unchanged from the previous TryFindIndoorWalkablePlane implementation.
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;
}
/// <summary>
/// Downward probe distance used by <see cref="TryFindIndoorWalkablePlane"/>
/// when scanning for the indoor walkable contact plane. 50 cm.
/// Larger than the +0.02f cell-origin Z-bump and larger than any realistic
/// step riser; smaller than a full cell height so we don't reach through
/// a thin floor into the cell above/below.
/// </summary>
private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
/// <summary>
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
/// Indoor BSP collision is deferred to Task 6c.
@ -1503,59 +1389,22 @@ public sealed class Transition
return cellState;
}
// ── Synthesize indoor walkable contact plane ──────────────
// Indoor walking Phase 2 follow-up (2026-05-19). When the BSP
// returns OK (no wall collision), the player is standing on a
// floor poly inside the cell. We must NOT fall through to
// outdoor terrain (SampleTerrainWalkable) — the outdoor terrain
// Z is below the indoor floor due to the +0.02f Z-bump applied
// for render z-fight prevention. ValidateWalkable would then see
// the player 0.5m above the outdoor plane → marks them as
// airborne → walkable=False → falling animation, never recovers.
// Indoor BSP returned OK — no wall collision. ContactPlane
// is RETAINED from the prior tick's seed
// (PhysicsEngine.ResolveWithTransition:583, the
// init_contact_plane equivalent) OR refreshed by Path 3
// step-down / Path 4 land if those fired this tick. Either
// way, no synthesis is needed here — matches retail's
// BSPTREE::find_collisions OK path
// (acclient_2013_pseudo_c.txt:323938).
//
// Retail: CEnvCell::find_env_collisions returns from the cell
// branch with the cell's walkable plane set — no fall-through
// to terrain.
bool walkableHit = TryFindIndoorWalkablePlane(
cellPhysics, localCenter, sphereRadius,
out var indoorPlane,
out var indoorVertices,
out uint hitPolyId);
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
if (walkableHit)
{
// dz = signed gap between foot and synthesized plane.
// Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes)
// gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z
float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z;
Console.WriteLine(System.FormattableString.Invariant(
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=HIT poly=0x{hitPolyId:X4} wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}"));
}
else
{
Console.WriteLine(System.FormattableString.Invariant(
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=MISS"));
}
}
if (walkableHit)
{
return ValidateWalkable(
footCenter,
sphereRadius,
indoorPlane,
isWater: false,
waterDepth: 0f,
cellId: sp.CheckCellId,
walkableVertices: indoorVertices);
}
// If no walkable floor was found under the player indoors
// (rare — cell with only walls/ceiling), fall through to
// outdoor terrain as a defensive backstop. Indoor walking
// will report walkable=False until the player moves over a
// cell with a proper floor poly.
// Do NOT fall through to outdoor terrain backstop: the
// player is in an indoor cell, and the outdoor terrain
// Z is below the indoor floor by ~0.02m (the render Z-bump),
// which would mark the player as airborne and trigger the
// falling-animation stuck symptom (the original Bug A).
// 2026-05-20 slice 2 of indoor ContactPlane retention.
return TransitionState.OK;
}
}