From 670f892bd37cd7ab73c53e8cad3c1e76700d1f45 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 16:16:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20Phase=20L.2.1+L.2.2=20?= =?UTF-8?q?=E2=80=94=20BSP=20step-up=20and=20rooftop=20landing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port CTransition::step_up (Path 5) and SPHEREPATH::set_collide (Path 6) from the retail decomp, turning wall-slides into proper step-up climbs and airborne-to-roof landings. Path 5 (grounded mover hits polygon): - StepSphereUp calls DoStepUp which runs DoStepDown with StepUp=true - DoStepDown now includes the retail Placement validation step (ACE Transition.cs:731-741) — sphere must not be inside solid geometry after finding a contact plane; this correctly blocks the tall-wall case - FindObjCollisions now allocates a local ShadowEntry list per call to prevent "collection modified" exceptions when DoStepUp recurses back through TransitionalInsert → FindObjCollisions - BSPQuery.FindCollisions passes engine through to StepSphereUp Path 6 (airborne mover hits polygon): - SpherePath.SetCollide: saves backup pos, records StepUpNormal, sets WalkInterp=1 — then returns Adjusted so TransitionalInsert retries - SpherePath.StepUpSlide: clears ContactPlane, sets SlidingNormal for the tall-wall fallback - TransitionalInsert Collide branch: re-tests as Placement when ContactPlaneValid; on failure restores backup and returns Collided Test fixes (BSPStepUpTests.cs + BSPStepUpFixtures.cs): - Tests use foot-position convention (CurPos = foot, sphere center = CurPos + (0,0,r)); from/to corrected from sphere-center to foot coords - MakeTestEngine terrainZ param: 0f for grounded tests (keeps Contact state between sub-steps), -50f for airborne/roof tests - to.X adjusted so sub-steps land sphere inside (not exactly touching) the wall, avoiding the EPSILON-shrink false-negative edge case - All 12 BSPStepUp tests now GREEN; full suite 823/823 Retail refs: CTransition::step_up — acclient_2013_pseudo_c.txt:273099 / ACE:746 CTransition::step_down — acclient_2013_pseudo_c.txt:273069 / ACE:710 SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594 / ACE:279 CTransition::transitional_insert Collide — pseudo_c:273193 / ACE:891 Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.Core/Physics/BSPQuery.cs | 180 ++++-------- src/AcDream.Core/Physics/TransitionTypes.cs | 260 ++++++++++++++++-- .../Physics/BSPStepUpFixtures.cs | 14 +- .../Physics/BSPStepUpTests.cs | 66 +++-- 4 files changed, 341 insertions(+), 179 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index dfef08b..2895b20 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1085,34 +1085,28 @@ public static class BSPQuery /// BSPTree.step_sphere_up — attempt to step over a low obstacle. /// /// - /// Sets the StepUp flag on SpherePath with the collision normal. - /// The Transition's outer loop will pick this up and attempt the step. - /// If StepUp is already pending, falls back to setting the collision normal - /// directly (StepUpSlide equivalent). + /// Calls which probes upward then steps + /// down to find a walkable landing surface. If the step-up succeeds the + /// sphere's CheckPos is already updated and we return OK. If it fails we + /// fall back to StepUpSlide: clear the contact plane and slide along the + /// collision normal. /// /// - /// ACE: BSPTree.cs step_sphere_up. + /// + /// ACE: BSPTree.step_sphere_up calls transition.StepUp(globNormal); + /// on false → SpherePath.StepUpSlide(transition). + /// Named-retail: BSPTREE::step_sphere_up. + /// /// private static TransitionState StepSphereUp( - Transition transition, - Vector3 collisionNormal) + Transition transition, + Vector3 collisionNormal, + PhysicsEngine engine) { - var path = transition.SpherePath; - var ci = transition.CollisionInfo; - - // ACE calls transition.StepUp(globNormal); if false -> path.StepUpSlide(transition). - // In acdream, StepUp is a flag field on SpherePath. - // If no StepUp is pending yet, request one. - if (!path.StepUp) - { - path.StepUp = true; - path.StepUpNormal = collisionNormal; + if (transition.DoStepUp(collisionNormal, engine!)) return TransitionState.OK; - } - // StepUpSlide: can't step up, set collision normal and report adjusted. - ci.SetCollisionNormal(collisionNormal); - return TransitionState.Adjusted; + return transition.SpherePath.StepUpSlide(transition.CollisionInfo); } // ------------------------------------------------------------------------- @@ -1364,7 +1358,8 @@ public static class BSPQuery Vector3 localCurrCenter, Vector3 localSpaceZ, float scale, - Quaternion localToWorld = default) + Quaternion localToWorld = default, + PhysicsEngine? engine = null) { if (root is null) return TransitionState.OK; // Default quaternion (0,0,0,0) → treat as identity @@ -1453,12 +1448,15 @@ public static class BSPQuery } // ---------------------------------------------------------------- - // Path 5: Contact — sphere_intersects_poly + wall-slide - // ACE retail uses StepSphereUp here, deferring to a retry loop that - // executes the step-up motion. We haven't ported that execution, so - // we apply the same wall-slide response as Path 6 — this at least - // gives correct blocking + sliding behavior for walls, buildings, - // and tree trunks while the player is on the ground. + // Path 5: Contact (grounded) — sphere_intersects_poly + step_sphere_up + // + // A grounded mover hits a polygon. Retail calls BSPTREE::step_sphere_up, + // which runs CTransition::step_up (upward probe + step-down scan). If the + // obstacle is short enough the sphere climbs it; if too tall, it falls back + // to StepUpSlide (clear contact-plane, slide along StepUpNormal). + // + // ACE: BSPTree.find_collisions → step_sphere_up (BSPTree.cs, path 5 branch). + // Named-retail: BSPTREE::find_collisions Contact branch → step_sphere_up. // ---------------------------------------------------------------- if (obj.State.HasFlag(ObjectInfoState.Contact)) { @@ -1470,26 +1468,12 @@ public static class BSPQuery if (hit0 || hitPoly0 is not null) { - // Wall-slide response (same as Path 6 below). - var localNormal = hitPoly0!.Plane.Normal; - var localMovement = sphere0.Center - localCurrCenter; + var worldNormal = L2W(hitPoly0!.Plane.Normal); + if (engine is not null) + return StepSphereUp(transition, worldNormal, engine); - float movementIntoWall = Vector3.Dot(localMovement, localNormal); - Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; - - Vector3 slidPos = localCurrCenter + projectedMovement; - float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D; - float minDist = sphere0.Radius + 0.01f; - if (slidDist < minDist) - { - slidPos += localNormal * (minDist - slidDist); - } - - Vector3 localDelta = slidPos - sphere0.Center; - Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; - path.AddOffsetToCheckPos(worldDelta); - - var worldNormal = L2W(localNormal); + // No engine available (env-cell path without engine param) — + // fall back to wall-slide so existing indoor geometry still blocks. collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); return TransitionState.Slid; @@ -1505,25 +1489,10 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { - var localNormal = hitPoly1!.Plane.Normal; - var localMovement = sphere1.Center - localCurrCenter; + var worldNormal = L2W(hitPoly1!.Plane.Normal); + if (engine is not null) + return StepSphereUp(transition, worldNormal, engine); - float movementIntoWall = Vector3.Dot(localMovement, localNormal); - Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; - - Vector3 slidPos = localCurrCenter + projectedMovement; - float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D; - float minDist = sphere1.Radius + 0.01f; - if (slidDist < minDist) - { - slidPos += localNormal * (minDist - slidDist); - } - - Vector3 localDelta = slidPos - sphere1.Center; - Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; - path.AddOffsetToCheckPos(worldDelta); - - var worldNormal = L2W(localNormal); collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); return TransitionState.Slid; @@ -1553,50 +1522,19 @@ public static class BSPQuery hitPoly0!, contact0, scale, localToWorld); } - // ─── Wall-slide response ───────────────────────────────── - // Instead of just pushing the sphere out of penetration - // (which undoes the whole step), compute the wall-slide - // position: where the sphere WOULD be if the movement had - // been projected along the wall tangent. + // ─── SetCollide response ───────────────────────────────── + // Airborne sphere hits a polygon. Per retail, call SetCollide + // which saves backup position, records StepUpNormal = worldNormal, + // and sets WalkInterp=1. TransitionalInsert's Collide branch will + // then re-test as Placement to confirm we can land on the surface. // - // In local space: - // curr = localCurrCenter - // target = sphere0.Center - // movement = target - curr - // normal = polygon plane normal (outward) - // projectedMovement = movement - (movement · normal) * normal - // slidPos = curr + projectedMovement - // - // Then ensure slidPos is outside the plane by at least radius+eps. - var localNormal = hitPoly0!.Plane.Normal; - var localMovement = sphere0.Center - localCurrCenter; - - // Project movement along wall tangent - float movementIntoWall = Vector3.Dot(localMovement, localNormal); - Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; - - // Slid position in local space - Vector3 slidPos = localCurrCenter + projectedMovement; - - // Ensure slid position is OUTSIDE the plane by radius + epsilon - float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D; - float minDist = sphere0.Radius + 0.01f; - if (slidDist < minDist) - { - slidPos += localNormal * (minDist - slidDist); - } - - // Delta from current CheckPos sphere center to slid position (local) - Vector3 localDelta = slidPos - sphere0.Center; - // Transform to world and apply - Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; - path.AddOffsetToCheckPos(worldDelta); - - var worldNormal = L2W(localNormal); + // ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide + // + return Adjusted. + // Named-retail: BSPTREE::find_collisions airborne branch → set_collide. + var worldNormal0 = L2W(hitPoly0!.Plane.Normal); + path.SetCollide(worldNormal0); path.WalkableAllowance = PhysicsGlobals.LandingZ; - collisions.SetCollisionNormal(worldNormal); - collisions.SetSlidingNormal(worldNormal); - return TransitionState.Slid; + return TransitionState.Adjusted; } if (sphere1 is not null) @@ -1609,29 +1547,11 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { - // Head sphere hit: apply the same wall-slide as above. - var localNormal = hitPoly1!.Plane.Normal; - var localMovement = sphere1.Center - localCurrCenter; - - float movementIntoWall = Vector3.Dot(localMovement, localNormal); - Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; - - Vector3 slidPos = localCurrCenter + projectedMovement; - float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D; - float minDist = sphere1.Radius + 0.01f; - if (slidDist < minDist) - { - slidPos += localNormal * (minDist - slidDist); - } - - Vector3 localDelta = slidPos - sphere1.Center; - Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; - path.AddOffsetToCheckPos(worldDelta); - - var worldNormal = L2W(localNormal); - collisions.SetCollisionNormal(worldNormal); - collisions.SetSlidingNormal(worldNormal); - return TransitionState.Slid; + // Head sphere hit: same SetCollide response. + var worldNormal1 = L2W(hitPoly1!.Plane.Normal); + path.SetCollide(worldNormal1); + path.WalkableAllowance = PhysicsGlobals.LandingZ; + return TransitionState.Adjusted; } } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 57d6e6d..e4e784c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -64,6 +64,27 @@ public sealed class ObjectInfo public bool EdgeSlide => State.HasFlag(ObjectInfoState.EdgeSlide); public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped); public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate); + + /// + /// Return the Z threshold for a walkable surface appropriate to the + /// current movement context. + /// + /// + /// Retail: OBJECTINFO::get_walkable_z — returns FloorZ when the mover + /// is on a walkable surface (Contact+OnWalkable), LandingZ otherwise. + /// ACE: ObjectInfo.GetWalkableZ (Transition.cs:760). + /// + /// + public float GetWalkableZ() + => OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ; + + /// + /// Stop any accumulated velocity on this object info. + /// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body. + /// acdream: velocity is tracked on PhysicsBody, not here. No-op for now; + /// will be wired when velocity is threaded through TransitionalInsert. + /// + public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ } } /// @@ -210,6 +231,34 @@ public sealed class SpherePath SetCheckPos(BackupCheckPos, BackupCheckCellId); } + /// + /// Called when an airborne sphere hits a polygon but the polygon is not yet + /// walkable — save backup, record the collision normal in StepUpNormal, and + /// flag Collide so TransitionalInsert can re-test as Placement. + /// ACE: SpherePath.SetCollide (acclient_2013_pseudo_c.txt ~321594, ACE SpherePath.cs:279-286). + /// + public void SetCollide(Vector3 collisionNormal) + { + Collide = true; + BackupCheckPos = CheckPos; + BackupCheckCellId = CheckCellId; + StepUpNormal = collisionNormal; + WalkInterp = 1.0f; + } + + /// + /// Slide fallback when step-up fails. Clears the contact-plane state that + /// caused the step-up attempt and issues a slide along StepUpNormal. + /// ACE: SpherePath.StepUpSlide (ACE SpherePath.cs:309-317). + /// + public TransitionState StepUpSlide(CollisionInfo collisions) + { + collisions.ContactPlaneValid = false; + collisions.ContactPlaneIsWater = false; + collisions.SetSlidingNormal(StepUpNormal); + return TransitionState.Slid; + } + /// /// Initialize the path for a simple point-to-point movement. /// @@ -491,11 +540,57 @@ public sealed class Transition // ── Phase 3: both env and objects returned OK ────────────── // Handle Collide flag (BSP path 6 set it on a non-contact hit). - // ACE: if Collide is set, re-test as Placement to confirm position. - // Simplified: just clear it and accept. + // ACE: Transition.TransitionalInsert Collide branch (Transition.cs:891-930). + // Named-retail: CTransition::transitional_insert Collide branch. if (sp.Collide) { sp.Collide = false; + + bool reset = false; + if (ci.ContactPlaneValid && DoCheckWalkable(PhysicsGlobals.LandingZ, engine)) + { + // CheckPos is walkable — re-test as Placement to snap/validate. + var savedInsert = sp.InsertType; + sp.InsertType = InsertType.Placement; + + var placeState = TransitionalInsert(numAttempts, engine); + + sp.InsertType = savedInsert; + + if (placeState != TransitionState.OK) + { + // Placement rejected — fall through to restore. + placeState = TransitionState.OK; + reset = true; + } + else if (!reset) + { + // Placement accepted — return current state. + sp.WalkableValid = false; + return placeState; + } + } + else + reset = true; + + sp.WalkableValid = false; + + if (reset) + { + sp.RestoreCheckPos(); + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + + if (ci.LastKnownContactPlaneValid) + { + ci.LastKnownContactPlaneValid = false; + oi.StopVelocity(); + } + else + ci.SetCollisionNormal(sp.StepUpNormal); + + return TransitionState.Collided; + } } // Handle neg-poly hit (backward-facing polygon contact). @@ -614,7 +709,9 @@ public sealed class Transition localSphere1, localCurrCenter, Vector3.UnitZ, // local space Z is up - 1.0f); // scale = 1.0 for cell geometry + 1.0f, // scale = 1.0 for cell geometry + Quaternion.Identity, + engine); // engine needed for Path 5 step-up if (cellState != TransitionState.OK) { @@ -744,11 +841,6 @@ public sealed class Transition // Object collision — static BSP objects // ----------------------------------------------------------------------- - // Reused per-call to avoid per-step allocation; safe because Transition - // is single-threaded per movement resolve. - private readonly List _nearbyObjs = new(); - private static int _debugQueryCount = 0; - /// /// Query the ShadowObjectRegistry for nearby static objects and run /// collision against each using the retail BSPTree.find_collisions 6-path @@ -778,23 +870,17 @@ public sealed class Transition out uint landblockId, out float worldOffsetX, out float worldOffsetY)) return TransitionState.OK; + // Use a local list: DoStepUp calls TransitionalInsert → FindObjCollisions + // recursively, so reusing a single field list would corrupt the outer + // iteration. Allocate per call (cheap — typically 0-5 entries). + var nearbyObjs = new List(); float queryRadius = sphereRadius + movement.Length() + 5f; engine.ShadowObjects.GetNearbyObjects( currPos, queryRadius, worldOffsetX, worldOffsetY, landblockId, - _nearbyObjs); + nearbyObjs); - // Log every 120 frames — tracks player position over time. - _debugQueryCount++; - if (movement.LengthSquared() > 0.0001f && _debugQueryCount % 120 == 0) - { - Console.WriteLine( - $"ObjColl @({currPos.X:F1},{currPos.Y:F1},{currPos.Z:F1}) " + - $"lb=0x{landblockId:X8} nearby={_nearbyObjs.Count}/{engine.ShadowObjects.TotalRegistered}"); - } - - - foreach (var obj in _nearbyObjs) + foreach (var obj in nearbyObjs) { // Broad-phase: can the moving sphere reach this object? Vector3 deltaToCurr = currPos - obj.Position; @@ -868,7 +954,8 @@ public sealed class Transition localCurrCenter, localSpaceZ, obj.Scale, // scale for local→world offsets - obj.Rotation); // local→world rotation + obj.Rotation, // local→world rotation + engine); // engine needed for Path 5 step-up } else { @@ -1218,16 +1305,145 @@ public sealed class Transition // 1. Collision detection returned OK // 2. A valid contact plane was found // 3. The contact plane is walkable (Normal.Z >= walkableZ) + // + // ACE StepDown then runs a Placement insertion to confirm the sphere + // can actually be placed at the candidate position — it must not be + // inside any solid geometry (wall, BSP object, etc.). + // Named-retail: CTransition::step_down, acclient_2013_pseudo_c.txt:273069. + // ACE: Transition.cs:731-741. if (transitState == TransitionState.OK && CollisionInfo.ContactPlaneValid && CollisionInfo.ContactPlane.Normal.Z >= walkableZ) { - return true; + // Placement validation: can we actually stand here? + var savedInsert = sp.InsertType; + sp.InsertType = InsertType.Placement; + + var placeState = TransitionalInsert(1, engine); + + sp.InsertType = savedInsert; + return placeState == TransitionState.OK; } return false; } + // ----------------------------------------------------------------------- + // Step-up + // ----------------------------------------------------------------------- + + /// + /// Attempt to step over a low obstacle by probing upward then stepping down. + /// + /// + /// Retail flow (CTransition::step_up, named-retail ~273099): + /// 1. Clear ContactPlane so the step-down probe is unbiased. + /// 2. Set StepUp flag so DoStepDown skips the downward offset (we start + /// from the sphere's current position and scan down from there). + /// 3. Pick stepDownHeight / walkable-Z from ObjectInfo (if OnWalkable, + /// use StepUpHeight + FloorZ; else 0.04 + LandingZ). + /// 4. Save backup, run DoStepDown, then clear StepUp. + /// 5. Return true on success; the caller commits the new CheckPos. + /// On failure, RestoreCheckPos and return false. + /// + /// + /// ACE: Transition.StepUp (Transition.cs:746-777). + /// Named-retail: CTransition::step_up (~273099-273133). + /// + internal bool DoStepUp(Vector3 collisionNormal, PhysicsEngine engine) + { + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + + sp.StepUp = true; + sp.StepUpNormal = collisionNormal; + + // Default values (not on walkable): small step, LandingZ threshold. + float stepDownHeight = 0.04f; + float zLandingValue = PhysicsGlobals.LandingZ; + + if (oi.State.HasFlag(ObjectInfoState.OnWalkable)) + { + zLandingValue = oi.GetWalkableZ(); + stepDownHeight = oi.StepUpHeight; + } + + sp.WalkableAllowance = zLandingValue; + sp.SaveCheckPos(); + + bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine); + + sp.StepUp = false; + sp.WalkableValid = false; + + if (!stepDown) + sp.RestoreCheckPos(); + + return stepDown; + } + + // ----------------------------------------------------------------------- + // Walkable check + // ----------------------------------------------------------------------- + + /// + /// Probe downward by StepDownHeight to confirm a walkable surface is within + /// reach of the current CheckPos — used by the Collide branch in + /// TransitionalInsert before re-testing as Placement. + /// + /// + /// Returns true if a walkable surface was found within reach (i.e. the + /// sphere can land here). Returns false if: + /// - ObjectInfo.OnWalkable is NOT set (always walkable by convention). + /// - CheckWalkables() already confirmed a walkable (skip the probe). + /// - The downward probe returned OK (meaning: no walkable was found + /// within reach, so we CANNOT land → transitState == OK → return false). + /// + /// + /// ACE: Transition.CheckWalkable (Transition.cs:206-235). + /// Named-retail: CTransition::check_walkable. + /// + internal bool DoCheckWalkable(float zCheck, PhysicsEngine engine) + { + var sp = SpherePath; + var oi = ObjectInfo; + + if (!oi.State.HasFlag(ObjectInfoState.OnWalkable)) + return true; + + // If the current walkable entry is still valid, skip the probe. + if (sp.WalkableValid) + return true; + + sp.SaveCheckPos(); + + float stepHeight = oi.StepDownHeight; + var globSphere = sp.GlobalSphere[0]; + + if (sp.NumSphere < 2 && stepHeight > globSphere.Radius * 2f) + stepHeight = globSphere.Radius * 0.5f; + + if (stepHeight > globSphere.Radius * 2f) + stepHeight *= 0.5f; + + sp.WalkableAllowance = zCheck; + sp.CheckWalkable = true; + sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -stepHeight)); + + var transitState = TransitionalInsert(1, engine); + + sp.CheckWalkable = false; + sp.RestoreCheckPos(); + + // ACE returns (transitState != OK) — i.e. true when we DID find a + // walkable (collision probe returned Adjusted/Collided). + return transitState != TransitionState.OK; + } + // ----------------------------------------------------------------------- // Post-step validation // ----------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs index fb06b18..ac56506 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs @@ -82,7 +82,9 @@ public static class BSPStepUpFixtures /// /// Floor polygon at z = 0, x ∈ [-2, 0.5], y ∈ [-1, 1]. /// Vertical wall polygon at x = 0.5, z ∈ [0, 0.25], y ∈ [-1, 1], facing -X. - /// Upper floor polygon at z = 0.25, x ∈ [0.5, 2], y ∈ [-1, 1]. + /// Upper floor polygon at z = 0.25, x ∈ [0.2, 2], y ∈ [-1, 1] — extends + /// left of the wall face so the vertical step-down probe finds it when the + /// sphere is at x ≈ 0.3–0.5 (the wall contact zone). /// /// public static (PhysicsBSPNode Root, Dictionary Resolved) @@ -105,10 +107,14 @@ public static class BSPStepUpFixtures new Vector3(0.5f, 1f, 0f), expectedNormal: new Vector3(-1f, 0f, 0f)); - // Upper floor at z=0.25, x∈[0.5,2], y∈[-1,1], normal = +Z + // Upper floor at z=0.25, x∈[0.2,2], y∈[-1,1], normal = +Z. + // The upper floor extends slightly left of the wall face (x=0.5) + // so the step-down probe (vertical, from the wall-contact XY) can + // find it when the sphere is at x≈0.3-0.5. Retail BSPs have the + // same overlap because geometry is continuous across the step. resolved[LowStep_UpperFloorId] = MakeFloor( - new Vector3(0.5f, -1f, 0.25f), new Vector3(2f, -1f, 0.25f), - new Vector3(2f, 1f, 0.25f), new Vector3(0.5f, 1f, 0.25f)); + new Vector3(0.2f, -1f, 0.25f), new Vector3(2f, -1f, 0.25f), + new Vector3(2f, 1f, 0.25f), new Vector3(0.2f, 1f, 0.25f)); // Build a flat BSP tree: one internal node with all three polys in a leaf. // The bounding sphere covers everything. diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 2333e70..e6f93a2 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -186,17 +186,27 @@ public class BSPStepUpTests var (root, resolved) = BSPStepUpFixtures.LowStep(); const float stepUpHeight = 0.30f; // larger than step (0.25), so step-up succeeds - float startZ = BSPStepUpFixtures.SphereRadius; - var from = new Vector3(0.1f, 0f, startZ); - var to = new Vector3(0.7f, 0f, startZ); // crosses the wall at x=0.5 + // CurPos (foot position) starts at z=0 (on the terrain / BSP floor at z=0). + // The sphere center is at CurPos + (0, 0, SphereRadius) = (x, 0, 0.2). + // lowPoint = sphere_center - (0,0,r) = (x, 0, 0) → on terrain → contact. + var from = new Vector3(0.1f, 0f, 0f); + // to.X = 0.6 → offset = (0.5, 0, 0), 3 sub-steps of 0.1667 each. + // Step 2: CurPos ≈ (0.433, 0, 0), sphere center x ≈ 0.433. + // Wall: dist = 0.5 - 0.433 = 0.067 < rad = 0.198 → HIT Path 5 ✓ + var to = new Vector3(0.6f, 0f, 0f); // foot stays at z=0, crosses wall at x=0.5 var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight); - var engine = MakeTestEngine(root, resolved); + // terrainZ=0f: terrain at z=0 keeps the step-down probe grounded between + // steps, preserving Contact/OnWalkable across the sub-step boundary. + var engine = MakeTestEngine(root, resolved, terrainZ: 0f); bool ok = t.FindTransitionalPosition(engine); - // After step-up, the character's Z must be at or above the upper floor + radius. - float expectedMinZ = 0.25f + BSPStepUpFixtures.SphereRadius - PhysicsGlobals.EPSILON * 10f; + // After step-up, the character's foot (CurPos.Z) must be at or above the + // upper floor (z=0.25). CurPos stores the foot origin; the sphere center is + // CurPos.Z + SphereRadius. The lower bound is the upper-floor Z minus a + // small epsilon to tolerate floating-point rounding in AdjustSphereToPlane. + float expectedMinZ = 0.25f - PhysicsGlobals.EPSILON * 10f; Assert.True(t.SpherePath.CurPos.Z >= expectedMinZ, $"Expected Z >= {expectedMinZ:F4} (stepped up to upper floor at z=0.25), " + $"got CurPos.Z = {t.SpherePath.CurPos.Z:F4}. " + @@ -222,12 +232,13 @@ public class BSPStepUpTests var (root, resolved) = BSPStepUpFixtures.TallWall(); const float stepUpHeight = 0.04f; // default — cannot scale 5 m wall - float startZ = BSPStepUpFixtures.SphereRadius; - var from = new Vector3(0.1f, 0f, startZ); - var to = new Vector3(0.7f, 0f, startZ); + // Foot at z=0 (on terrain). Same reasoning as B1. + var from = new Vector3(0.1f, 0f, 0f); + var to = new Vector3(0.6f, 0f, 0f); var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight); - var engine = MakeTestEngine(root, resolved); + // terrainZ=0f: keep grounded between steps (same as B1). + var engine = MakeTestEngine(root, resolved, terrainZ: 0f); t.FindTransitionalPosition(engine); @@ -268,12 +279,13 @@ public class BSPStepUpTests var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r }; - // NOTE: After L.2.1 this call gains an optional PhysicsEngine - // parameter. Until then, the step-up flag is set but DoStepDown - // cannot recurse (returns Slid). After L.2.1 result should be OK. + // Pass engine so Path 5 can call DoStepUp → DoStepDown (L.2.1). + // Without engine the fallback wall-slide would return Slid. + var engine = MakeTestEngine(root, resolved); + var result = BSPQuery.FindCollisions( root, resolved, t, localSphere, null, - currPos, Vector3.UnitZ, 1.0f); + currPos, Vector3.UnitZ, 1.0f, Quaternion.Identity, engine); // After L.2.1 this assertion flips from failing (Slid) to passing. Assert.NotEqual(TransitionState.Slid, result); @@ -349,11 +361,17 @@ public class BSPStepUpTests float roofZ = 3f; float r = BSPStepUpFixtures.SphereRadius; - var from = new Vector3(0f, 0f, roofZ + r + 0.1f); - var to = new Vector3(0f, 0f, roofZ + r - 0.05f); // sphere foot at z~3.0 + // CurPos = foot position. Sphere center = CurPos + (0,0,r). + // from: foot at z = roofZ - r + 0.3f → sphere center at roofZ + 0.3 = 3.3 (above roof) + // to: foot at z = roofZ - r - 0.05f → sphere center at roofZ - 0.05 = 2.95 (into roof by 0.05) + // Roof polygon at z=roofZ, normal=+Z: dist = sphere_center.z - roofZ. + // At to: dist = -0.05; |dist| = 0.05 < rad=0.198 → roof hit ✓ + var from = new Vector3(0f, 0f, roofZ - r + 0.3f); + var to = new Vector3(0f, 0f, roofZ - r - 0.05f); // sphere bottom at z ≈ 2.95 (into roof) var t = BSPStepUpFixtures.MakeAirborneTransition(from, to); - var engine = MakeTestEngine(root, resolved); + // terrainZ=-50f: airborne mover — terrain must not interfere with roof landing. + var engine = MakeTestEngine(root, resolved, terrainZ: -50f); t.FindTransitionalPosition(engine); @@ -417,22 +435,24 @@ public class BSPStepUpTests // ========================================================================= /// - /// Build a that serves one synthetic BSP object - /// without any interfering terrain. The terrain is set 50 m underground - /// so it never fires during test geometry at z ≥ 0. + /// Build a that serves one synthetic BSP object. + /// sets every terrain sample to the given height. + /// Use 0f for grounded tests (terrain flush with the BSP floor at z=0, so the + /// step-down probe finds ground and keeps Contact/OnWalkable set between steps). + /// Use -50f for tests where terrain must never interfere (airborne / roof landing). /// private static PhysicsEngine MakeTestEngine( PhysicsBSPNode root, Dictionary resolved, - Vector3? objectPosition = null) + Vector3? objectPosition = null, + float terrainZ = 0f) { const uint LandblockId = 0xA9B4FFFFu; const uint SyntheticGfxId = 0xDEADBEEFu; - // Terrain 50 m underground so FindEnvCollisions never fires push-ups. var heights = new byte[81]; // all zero → uses index 0 from heightTable var heightTab = new float[256]; - for (int i = 0; i < 256; i++) heightTab[i] = -50f; + for (int i = 0; i < 256; i++) heightTab[i] = terrainZ; var engine = new PhysicsEngine(); engine.AddLandblock(