From 261322b48e85f6b4203fd4ae4843ca186893ada4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 08:04:37 +0200 Subject: [PATCH] fix(physics): #32 L.2c precipice edge-slide context Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping. Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide. Co-authored-by: Codex --- docs/ISSUES.md | 19 +- ...26-04-29-movement-collision-conformance.md | 7 + .../2026-04-30-precipice-slide-pseudocode.md | 110 ++++++++++ .../project_movement_collision_conformance.md | 12 +- src/AcDream.Core/Physics/BSPQuery.cs | 100 ++++++--- src/AcDream.Core/Physics/PhysicsEngine.cs | 52 +++++ src/AcDream.Core/Physics/TerrainSurface.cs | 72 +++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 203 ++++++++++++++++-- .../Physics/PhysicsEngineTests.cs | 30 +++ .../Physics/TerrainSurfaceTests.cs | 14 ++ 10 files changed, 559 insertions(+), 60 deletions(-) create mode 100644 docs/research/2026-04-30-precipice-slide-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7112f33..6033efe 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -189,19 +189,22 @@ step-down boundaries, retail often slides along the boundary. acdream still hard-blocks or accepts too much in several of these cases. **Root cause / status:** Tracked under Phase L.2c. Wall-adjacent -`step_up_slide` now feels acceptable in live testing. L.2c plumbing now passes -the retail-default `EdgeSlide` flag into local and remote movement and logs -failed step-down edge cases behind `ACDREAM_DUMP_EDGE_SLIDE=1`. Remaining gap: -preserve walkable polygon context for `precipice_slide` and finish -`cliff_slide` / `NegPolyHit` dispatch. Named retail anchors include -`CTransition::edge_slide`, `CTransition::cliff_slide`, -`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`. +`step_up_slide` now feels acceptable in live testing. Local/remote movement +passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now +preserves terrain/BSP walkable polygon vertices and runs the retail back-probe +before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports +whether a failed step-down had polygon context. Remaining gaps: real-DAT +building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` +dispatch. Named retail anchors include `CTransition::edge_slide`, +`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and +`SPHEREPATH::step_up_slide`. **Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`, `src/AcDream.Core/Physics/BSPQuery.cs`, `tests/AcDream.Core.Tests/`. -**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. +**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`, +`docs/research/2026-04-30-precipice-slide-pseudocode.md`. **Acceptance:** Synthetic and real-DAT tests cover wall-slide, roof-edge slide, cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case. diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index f31acca..90de943 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -118,6 +118,13 @@ precipices. edge, walkable, and collision rules; jumping clears `OnWalkable` and only succeeds when the airborne path actually clears geometry. +Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels +acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and +BSP step-down/find-walkable now preserve walkable polygon vertices; failed +step-down edge cases perform the retail back-probe before +`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge +fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch. + ### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects Goal: object collisions use retail shape semantics, not one simplified diff --git a/docs/research/2026-04-30-precipice-slide-pseudocode.md b/docs/research/2026-04-30-precipice-slide-pseudocode.md new file mode 100644 index 0000000..8ddc4c8 --- /dev/null +++ b/docs/research/2026-04-30-precipice-slide-pseudocode.md @@ -0,0 +1,110 @@ +# Precipice Slide Pseudocode + +Date: 2026-04-30 + +Phase: L.2c - Movement & Collision Conformance + +## Retail Anchors + +- Named retail: `CTransition::edge_slide`, `acclient_2013_pseudo_c.txt:273001` +- Named retail: `CTransition::cliff_slide`, `acclient_2013_pseudo_c.txt:272397` +- Named retail: `SPHEREPATH::precipice_slide`, `acclient_2013_pseudo_c.txt:274316` +- ACE cross-check: `Transition.EdgeSlide`, `Transition.CliffSlide`, + `SpherePath.PrecipiceSlide` +- ACE cross-check: `Polygon.find_crossed_edge` + +## Edge-Slide Flow + +When a grounded mover has contact state but the next candidate position has no +walkable surface within step-down reach, retail does not immediately accept the +fall or hard-stop. It enters `CTransition::edge_slide`. + +```text +edge_slide(transitionState, stepDownHeight, walkableZ): + if object is not OnWalkable or EdgeSlide is disabled: + clear walkable + restore candidate check position + clear current contact plane + mark cell array valid + transitionState = OK + return handled + + if current collision has a contact plane below walkableZ: + transitionState = cliff_slide(contact plane) + clear walkable and restore candidate check position + clear current contact plane + return not-final + + if sphere_path.walkable exists: + transitionState = precipice_slide() + clear current contact plane and restore candidate check position + return transitionState == Collided + + if current collision has any contact plane: + clear walkable + restore candidate check position + clear current contact plane + transitionState = OK + return handled + + move CheckPos back from failed candidate to the current sphere center + step_down(stepDownHeight, walkableZ) to rediscover the walkable polygon + clear current contact plane + restore the failed candidate check position + + if a walkable polygon was discovered: + set walkable_check_pos from the candidate sphere in walkable space + transitionState = precipice_slide() + return transitionState == Collided + + clear walkable + mark cell array valid + transitionState = Collided + return handled +``` + +## Precipice Slide + +`SPHEREPATH::precipice_slide` is the edge-normal half of edge-slide. The crucial +input is the walkable polygon that the mover just left; without that polygon, +there is no crossed edge to slide along. + +```text +precipice_slide(): + normal = zero + found = walkable.find_crossed_edge(walkable_check_pos, walkable_up, normal) + + if not found: + clear walkable + return Collided + + clear walkable + step_up = false + + normal = walkable_pos.frame.LocalToGlobalVec(normal) + + blockOffset = LandDefs.GetBlockOffset(curr cell, check cell) + movementOffset = global_sphere.center - global_curr_center.center + blockOffset + + if dot(normal, movementOffset) > 0: + normal = -normal + + return global_sphere.slide_sphere(transition, normal, global_curr_center.center) +``` + +## Porting Notes + +acdream already had the `Polygon.find_crossed_edge` math inside `BSPQuery`, but +the live diagnostic showed `walkableValid=False` at the failed step-down edge +branch. The port must therefore preserve or rediscover the walkable polygon, +not just pass the `EdgeSlide` flag. + +For the first L.2c slice: + +- terrain supplies the exact current triangle vertices alongside its plane; +- BSP step-down/find-walkable records world-space polygon vertices when the + caller supplies the object's world origin; +- the failed step-down edge branch performs the retail back-probe to current + position before calling precipice slide; +- `CELLARRAY`, full `cell_bsp` ownership, and cross-cell building portals remain + L.2e work. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 1fa54b8..5db6326 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -62,5 +62,13 @@ InputDispatcher / PlayerMovementController - 2026-04-30: L.2c edge-slide plumbing. User live-tested wall-adjacent slide as acceptable. Local player and remote dead-reckoning now pass retail-default `ObjectInfoState.EdgeSlide`; `ACDREAM_DUMP_EDGE_SLIDE=1` logs failed - step-down edge cases so the next slice can distinguish missing walkable - polygon context from cliff-slide/NegPolyHit gaps. + step-down edge cases and now reports whether walkable polygon context is + present before cliff/precipice handling. +- 2026-04-30: L.2c precipice-slide context. Named retail + `SPHEREPATH::precipice_slide` and ACE `Polygon.find_crossed_edge` are now + captured in `docs/research/2026-04-30-precipice-slide-pseudocode.md`. + Terrain supplies exact walkable triangle vertices, BSP step-down/find-walkable + stores world-space walkable vertices for static object tops, and failed + step-down edge cases run the retail back-probe before precipice slide. + `cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full + `cell_bsp`, and real-DAT building portal conformance remain open L.2 work. diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index a0947d0..b86ccc4 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -377,30 +377,33 @@ public static class BSPQuery /// /// ACE: Polygon.cs find_crossed_edge. /// - private static bool FindCrossedEdge( - ResolvedPolygon poly, - CollisionSphere sphere, - Vector3 up, - ref Vector3 normal) + internal static bool FindCrossedEdge( + Plane polyPlane, + ReadOnlySpan verts, + Vector3 sphereCenter, + Vector3 up, + out Vector3 normal) { - float angleUp = Vector3.Dot(poly.Plane.Normal, up); + normal = Vector3.Zero; + + float angleUp = Vector3.Dot(polyPlane.Normal, up); if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false; - float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp; - var center = sphere.Center - up * angle; + float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp; + var center = sphereCenter - up * angle; - int n = poly.Vertices.Length; + int n = verts.Length; int prevIdx = n - 1; for (int i = 0; i < n; i++) { - var v = poly.Vertices[i]; - var lv = poly.Vertices[prevIdx]; + var v = verts[i]; + var lv = verts[prevIdx]; prevIdx = i; var edge = v - lv; var disp = center - lv; - var cross = Vector3.Cross(poly.Plane.Normal, edge); + var cross = Vector3.Cross(polyPlane.Normal, edge); if (Vector3.Dot(disp, cross) < 0f) { @@ -412,6 +415,47 @@ public static class BSPQuery return false; } + private static bool FindCrossedEdge( + ResolvedPolygon poly, + CollisionSphere sphere, + Vector3 up, + ref Vector3 normal) + { + if (!FindCrossedEdge(poly.Plane, poly.Vertices, sphere.Center, up, out var crossedNormal)) + return false; + + normal = crossedNormal; + return true; + } + + private static Vector3 TransformNormal(Vector3 normal, Quaternion localToWorld) + { + var worldNormal = Vector3.Transform(normal, localToWorld); + return worldNormal.LengthSquared() > PhysicsGlobals.EpsilonSq + ? Vector3.Normalize(worldNormal) + : Vector3.UnitZ; + } + + private static Vector3[] TransformVertices( + ReadOnlySpan vertices, + Quaternion localToWorld, + float scale, + Vector3 worldOrigin) + { + var result = new Vector3[vertices.Length]; + for (int i = 0; i < vertices.Length; i++) + result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin; + return result; + } + + private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan worldVertices) + { + float d = worldVertices.Length > 0 + ? -Vector3.Dot(worldNormal, worldVertices[0]) + : 0f; + return new Plane(worldNormal, d); + } + // ------------------------------------------------------------------------- // adjust_to_placement_poly // ACE: Polygon.cs adjust_to_placement_poly @@ -1037,7 +1081,8 @@ public static class BSPQuery CollisionSphere checkPos, Vector3 up, float scale, - Quaternion localToWorld = default) + Quaternion localToWorld = default, + Vector3 worldOrigin = default) { if (localToWorld == default) localToWorld = Quaternion.Identity; @@ -1061,14 +1106,12 @@ public static class BSPQuery var offset = Vector3.Transform(adjusted, localToWorld) * scale; path.AddOffsetToCheckPos(offset); - var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld); - collisions.SetContactPlane( - new Plane(worldNormal, polyHit.Plane.D * scale), - path.CheckCellId, false); + var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld); + var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin); + var worldPlane = BuildWorldPlane(worldNormal, worldVertices); + collisions.SetContactPlane(worldPlane, path.CheckCellId, false); - path.WalkableValid = true; - path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale); - path.WalkableAllowance = PhysicsGlobals.FloorZ; + path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); return TransitionState.Adjusted; } @@ -1359,7 +1402,8 @@ public static class BSPQuery Vector3 localSpaceZ, float scale, Quaternion localToWorld = default, - PhysicsEngine? engine = null) + PhysicsEngine? engine = null, + Vector3 worldOrigin = default) { if (root is null) return TransitionState.OK; // Default quaternion (0,0,0,0) → treat as identity @@ -1410,7 +1454,7 @@ public static class BSPQuery // ---------------------------------------------------------------- if (path.StepDown) { - return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld); + return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld, worldOrigin); } // ---------------------------------------------------------------- @@ -1433,14 +1477,12 @@ public static class BSPQuery var worldOffset = L2W(localOffset) * scale; path.AddOffsetToCheckPos(worldOffset); - var worldNormal = L2W(hitPoly.Plane.Normal); - collisions.SetContactPlane( - new Plane(worldNormal, hitPoly.Plane.D * scale), - path.CheckCellId, false); + var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld); + var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin); + var worldPlane = BuildWorldPlane(worldNormal, worldVertices); + collisions.SetContactPlane(worldPlane, path.CheckCellId, false); - path.WalkableValid = true; - path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale); - path.WalkableAllowance = PhysicsGlobals.FloorZ; + path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); return TransitionState.Adjusted; } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index be2494b..22f48ef 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -4,6 +4,13 @@ using System.Numerics; namespace AcDream.Core.Physics; +internal readonly record struct TerrainWalkableSample( + System.Numerics.Plane Plane, + Vector3[] Vertices, + float WaterDepth, + bool IsWater, + uint CellId); + /// /// Top-level physics resolver that combines and /// to resolve entity movement with step-height @@ -162,6 +169,51 @@ public sealed class PhysicsEngine return null; } + /// + /// Sample the outdoor terrain walkable triangle at the given world-space + /// XY position. This carries the same plane as + /// plus world-space triangle vertices for retail precipice-slide. + /// + internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY) + { + foreach (var kvp in _landblocks) + { + var lb = kvp.Value; + float localX = worldX - lb.WorldOffsetX; + float localY = worldY - lb.WorldOffsetY; + if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) + { + var sample = lb.Terrain.SampleSurfacePolygon(localX, localY); + var vertices = new Vector3[sample.Vertices.Length]; + for (int i = 0; i < sample.Vertices.Length; i++) + { + var v = sample.Vertices[i]; + vertices[i] = new Vector3( + v.X + lb.WorldOffsetX, + v.Y + lb.WorldOffsetY, + v.Z); + } + + var normal = sample.Normal; + float d = -Vector3.Dot(normal, vertices[0]); + var plane = new System.Numerics.Plane(normal, d); + + float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY); + bool isWater = waterDepth >= 0.45f; + uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); + uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId; + + return new TerrainWalkableSample( + plane, + vertices, + waterDepth, + isWater, + fullCellId); + } + } + return null; + } + /// /// Resolve the outdoor cell id that owns a world-space position. /// Indoor ids are preserved because EnvCell ownership still comes from diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index 6a37506..caa5493 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -1,7 +1,13 @@ using System; +using System.Numerics; namespace AcDream.Core.Physics; +public readonly record struct TerrainSurfacePolygon( + float Z, + Vector3 Normal, + Vector3[] Vertices); + /// /// Outdoor terrain height resolver for a single landblock. Performs /// per-triangle barycentric Z interpolation matching the visual terrain @@ -250,6 +256,72 @@ public sealed class TerrainSurface return (z, normal); } + /// + /// Sample the terrain triangle at (localX, localY), including the three + /// local-space vertices that bound the sampled point. Edge-slide needs + /// these vertices so the retail crossed-edge test can identify which edge + /// the sphere left when a step-down probe fails. + /// + public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY) + { + float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); + float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); + int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1); + int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1); + + float tx = fx - cx; + float ty = fy - cy; + + float hBL = _z[cx, cy ]; + float hBR = _z[cx + 1, cy ]; + float hTR = _z[cx + 1, cy + 1]; + float hTL = _z[cx, cy + 1]; + + bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); + + Vector3 bl = new(cx * CellSize, cy * CellSize, hBL); + Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR); + Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR); + Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL); + + float z; + Vector3[] vertices; + + if (splitSWtoNE) + { + if (tx > ty) + { + z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; + vertices = new[] { bl, br, tr }; + } + else + { + z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; + vertices = new[] { bl, tr, tl }; + } + } + else + { + if (tx + ty <= 1f) + { + z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; + vertices = new[] { bl, br, tl }; + } + else + { + z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); + vertices = new[] { br, tr, tl }; + } + } + + var normal = Vector3.Normalize( + Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0])); + if (normal.Z < 0f) + normal = -normal; + + return new TerrainSurfacePolygon(z, normal, vertices); + } + /// /// Retail per-point water depth in meters — the amount the character's /// feet are allowed to sink below the contact plane before the diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 8bdaef6..d48a0a0 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -188,7 +188,10 @@ public sealed class SpherePath // Walkable tracking public bool WalkableValid; public Plane WalkablePlane; + public Vector3[]? WalkableVertices; + public Vector3 WalkableUp = Vector3.UnitZ; public float WalkableAllowance = PhysicsGlobals.FloorZ; + public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 }; // Backup for restore public Vector3 BackupCheckPos; @@ -246,6 +249,21 @@ public sealed class SpherePath WalkInterp = 1.0f; } + public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up) + { + WalkableValid = true; + WalkablePlane = plane; + WalkableVertices = (Vector3[])vertices.Clone(); + WalkableUp = up; + WalkableAllowance = PhysicsGlobals.FloorZ; + } + + public void ClearWalkable() + { + WalkableValid = false; + WalkableVertices = null; + } + /// /// Slide fallback when step-up fails. Clears the contact-plane state that /// caused the step-up attempt and runs the full sphere-slide computation @@ -273,6 +291,40 @@ public sealed class SpherePath return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin); } + /// + /// Slide along the edge of the walkable polygon the mover just left. + /// Retail anchor: SPHEREPATH::precipice_slide + /// (acclient_2013_pseudo_c.txt:274316). + /// + public TransitionState PrecipiceSlide(Transition transition) + { + if (!HasWalkablePolygon || WalkableVertices is null) + { + ClearWalkable(); + return TransitionState.Collided; + } + + if (!BSPQuery.FindCrossedEdge( + WalkablePlane, + WalkableVertices, + GlobalSphere[0].Origin, + WalkableUp, + out var collisionNormal)) + { + ClearWalkable(); + return TransitionState.Collided; + } + + ClearWalkable(); + StepUp = false; + + var offset = GlobalSphere[0].Origin - GlobalCurrCenter[0].Origin; + if (Vector3.Dot(collisionNormal, offset) > 0f) + collisionNormal = -collisionNormal; + + return transition.SlideSphereInternal(collisionNormal, GlobalCurrCenter[0].Origin); + } + /// /// Initialize the path for a simple point-to-point movement. /// @@ -583,14 +635,14 @@ public sealed class Transition else if (!reset) { // Placement accepted — return current state. - sp.WalkableValid = false; + sp.ClearWalkable(); return placeState; } } else reset = true; - sp.WalkableValid = false; + sp.ClearWalkable(); if (reset) { @@ -653,7 +705,7 @@ public sealed class Transition { if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) { - sp.WalkableValid = false; + sp.ClearWalkable(); return TransitionState.OK; } } @@ -663,7 +715,7 @@ public sealed class Transition if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false) || DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) { - sp.WalkableValid = false; + sp.ClearWalkable(); return TransitionState.OK; } } @@ -681,8 +733,7 @@ public sealed class Transition // we are missing precipice context, a steep contact plane, or // merely the EdgeSlide flag. DumpEdgeSlideStepDownFailed(stepDownHeight, zVal); - sp.RestoreCheckPos(); - return TransitionState.Collided; + return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal); } return TransitionState.OK; @@ -693,6 +744,105 @@ public sealed class Transition return TransitionState.Slid; } + private TransitionState EdgeSlideAfterStepDownFailed( + PhysicsEngine engine, + float stepDownHeight, + float zVal) + { + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + + // Retail lets non-EdgeSlide movers continue over the boundary. Player + // movement carries EdgeSlide, so the local avatar takes the slide path. + if (!oi.OnWalkable || !oi.EdgeSlide) + { + sp.ClearWalkable(); + sp.RestoreCheckPos(); + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return TransitionState.OK; + } + + if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) + { + var cliffPlane = ci.ContactPlane; + sp.ClearWalkable(); + sp.RestoreCheckPos(); + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return CliffSlide(cliffPlane); + } + + if (sp.HasWalkablePolygon) + { + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return sp.PrecipiceSlide(this); + } + + if (ci.ContactPlaneValid) + { + sp.ClearWalkable(); + sp.RestoreCheckPos(); + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return TransitionState.OK; + } + + // Retail back-probes from the current sphere center to rediscover the + // walkable polygon we just left, then restores the failed candidate and + // runs precipice_slide against that polygon. + Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin; + sp.AddOffsetToCheckPos(backToCurrent); + + _ = DoStepDown(stepDownHeight, zVal, engine, runPlacement: false); + + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + sp.RestoreCheckPos(); + + if (sp.HasWalkablePolygon) + return sp.PrecipiceSlide(this); + + sp.ClearWalkable(); + return TransitionState.Collided; + } + + private TransitionState CliffSlide(Plane contactPlane) + { + var sp = SpherePath; + var ci = CollisionInfo; + + if (!ci.LastKnownContactPlaneValid) + return TransitionState.OK; + + Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal); + contactNormal.Z = 0f; + + Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); + if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) + return TransitionState.OK; + + collideNormal = Vector3.Normalize(collideNormal); + + Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; + float angle = Vector3.Dot(collideNormal, offset); + + if (angle <= 0f) + { + sp.AddOffsetToCheckPos(collideNormal * angle); + ci.SetCollisionNormal(collideNormal); + } + else + { + sp.AddOffsetToCheckPos(collideNormal * -angle); + ci.SetCollisionNormal(-collideNormal); + } + + return TransitionState.Adjusted; + } + private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal) { if (!DumpEdgeSlideEnabled) return; @@ -703,7 +853,7 @@ public sealed class Transition Console.WriteLine( System.FormattableString.Invariant( - $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); + $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); } private static string Fmt(Vector3 value) => @@ -800,10 +950,10 @@ public sealed class Transition // // ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls // find_terrain_poly and uses walkable.Plane — the actual triangle's - // plane, not a reconstructed flat one. SampleTerrainPlane returns - // the same thing analytically from the triangle's corner heights. - var planeOpt = engine.SampleTerrainPlane(footCenter.X, footCenter.Y); - if (planeOpt is null) + // plane, not a reconstructed flat one. SampleTerrainWalkable returns + // that plane plus the triangle vertices needed by precipice slide. + var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); + if (terrainWalkable is null) return TransitionState.OK; // no terrain loaded here — allow pass-through // Per-point water depth: 0.9 on fully water cells, 0.45 on partial- @@ -813,12 +963,11 @@ public sealed class Transition // contact plane before the push-up fires. In retail, this is what // makes characters appear submerged in water — there is NO separate // water surface mesh; the character just sits lower than terrain. - float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y); - bool isWater = waterDepth >= 0.45f; - - return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value, - isWater, waterDepth, - cellId: sp.CheckCellId); + return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane, + terrainWalkable.Value.IsWater, + terrainWalkable.Value.WaterDepth, + cellId: terrainWalkable.Value.CellId, + walkableVertices: terrainWalkable.Value.Vertices); } /// @@ -829,12 +978,19 @@ public sealed class Transition /// private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius, System.Numerics.Plane contactPlane, - bool isWater, float waterDepth, uint cellId) + bool isWater, float waterDepth, uint cellId, + Vector3[]? walkableVertices = null) { var sp = SpherePath; var ci = CollisionInfo; var oi = ObjectInfo; + void CacheWalkableContext() + { + if (walkableVertices is not null && contactPlane.Normal.Z >= PhysicsGlobals.FloorZ) + sp.SetWalkable(contactPlane, walkableVertices, Vector3.UnitZ); + } + // Low point of the sphere. var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius); @@ -857,7 +1013,10 @@ public sealed class Transition // Resting on surface: record contact plane. bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance; if (sp.StepDown || !oi.OnWalkable || walkableNormal) + { ci.SetContactPlane(contactPlane, cellId, isWater); + CacheWalkableContext(); + } if (!oi.Contact && !sp.StepDown) { @@ -879,6 +1038,7 @@ public sealed class Transition if (sp.StepDown || !oi.OnWalkable || walkable) { ci.SetContactPlane(contactPlane, cellId, isWater); + CacheWalkableContext(); if (sp.StepDown) { @@ -1020,7 +1180,8 @@ public sealed class Transition localSpaceZ, obj.Scale, // scale for local→world offsets obj.Rotation, // local→world rotation - engine); // engine needed for Path 5 step-up + engine, + worldOrigin: obj.Position); } else { @@ -1499,8 +1660,8 @@ public sealed class Transition bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine); - sp.StepUp = false; - sp.WalkableValid = false; + sp.StepUp = false; + sp.ClearWalkable(); // L.2.3f: log the result + landing plane if step-up succeeded. // This is the actual surface the player ended up on, which may diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index d6f08cf..79d6ac8 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -231,6 +231,36 @@ public class PhysicsEngineTests Assert.Equal(0x0025u, result.CellId); } + [Fact] + public void ResolveWithTransition_EdgeSlideStopsAtLoadedTerrainBoundary() + { + var engine = MakeFlatEngine(terrainZ: 50f); + var body = new PhysicsBody + { + Position = new Vector3(191.25f, 96f, 50f), + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + ContactPlaneValid = true, + ContactPlane = new Plane(Vector3.UnitZ, -50f), + ContactPlaneCellId = 0x003Du, + }; + + var result = engine.ResolveWithTransition( + currentPos: new Vector3(191.25f, 96f, 50f), + targetPos: new Vector3(193f, 96f, 50f), + cellId: 0x003Du, + sphereRadius: 0.5f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.EdgeSlide); + + Assert.True(result.IsOnGround); + Assert.InRange(result.Position.X, 190.75f, 192.0001f); + Assert.Equal(50f, result.Position.Z, precision: 2); + } + [Fact] public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() { diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs index fb70cd4..7ceda8f 100644 --- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -67,6 +67,20 @@ public class TerrainSurfaceTests Assert.Equal(42f, surface.SampleZ(300f, 300f)); } + [Fact] + public void SampleSurfacePolygon_ReturnsContainingTriangleVertices() + { + var heights = FlatHeightmap(50); + var surface = new TerrainSurface(heights, LinearHeightTable(), landblockX: 0, landblockY: 0); + + var sample = surface.SampleSurfacePolygon(2f, 2f); + + Assert.Equal(3, sample.Vertices.Length); + Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z)); + Assert.Equal(1f, sample.Normal.Z, precision: 3); + Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f); + } + [Fact] public void ComputeOutdoorCellId_Origin_ReturnsFirst() {