From b0c29454d0f71f6da5519568f71778c9fb248e89 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 15:44:16 +0200 Subject: [PATCH 01/17] test(physics): conformance fixtures for BSP step-up + roof-landing (Phase L.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two files under tests/: BSPStepUpFixtures.cs — synthetic PhysicsBSPNode trees for four canonical collision shapes: low step (25 cm), too-tall wall (5 m), flat roof (3 m), and steep slope (60deg). Pre-builds ResolvedPolygon dicts with correct polygon_hits_sphere_precise winding (CCW relative to outward normal). BSPStepUpTests.cs — 11 conformance tests: A1-A6: baselines that pass before and after implementation (no-hit, geometry fixture sanity checks). B1-B3: Phase L.2.1 targets, currently RED (Path 5 wall-slides). C1-C3: Phase L.2.2 targets, currently RED (Path 6 wall-slides). Retail refs in test docstrings: BSPTREE::find_collisions Path 5 acclient_2013_pseudo_c.txt:323849 / ACE BSPTree.cs:192-196. CTransition::step_up acclient_2013_pseudo_c.txt:273099-273133 / ACE Transition.cs:746-777. SPHEREPATH::set_collide acclient_2013_pseudo_c.txt:321594-321607 / ACE SpherePath.cs:279-286. CTransition::transitional_insert Collide branch acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930. Also adds PhysicsDataCache.RegisterGfxObjForTest() for test-only GfxObjPhysics injection without real DAT content. Test delta: 811 -> 823 (+12). 6 passing (A1-A6 + B2), 5 intentionally failing. Pre-flight: object-translation plane D is in object-local space. Bug is dormant for outdoor movement where terrain sets the world-space ContactPlane. Tagged TODO. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.Core/Physics/PhysicsDataCache.cs | 8 + .../Physics/BSPStepUpFixtures.cs | 363 +++++++++++++ .../Physics/BSPStepUpTests.cs | 475 ++++++++++++++++++ 3 files changed, 846 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs create mode 100644 tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 225bf3f6..771f208d 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -209,6 +209,14 @@ public sealed class PhysicsDataCache public int GfxObjCount => _gfxObj.Count; public int SetupCount => _setup.Count; public int CellStructCount => _cellStruct.Count; + + /// + /// Register a pre-built directly. + /// Intended for unit-test fixtures that construct synthetic BSP trees + /// without needing real DAT content. + /// + public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics) + => _gfxObj[gfxObjId] = physics; } /// diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs new file mode 100644 index 00000000..fb06b180 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs @@ -0,0 +1,363 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Synthetic BSP tree fixtures for step-up and roof-landing conformance tests. +/// +/// +/// These fixtures construct minimal trees plus +/// matching dictionaries that represent canonical +/// AC collision shapes without needing real DAT content. The shapes cover every +/// interesting branch in Path 5 and Path 6. +/// +/// +/// +/// Coordinate convention: +Z is up, all geometry is expressed in object-local +/// space (identity rotation, scale = 1.0) with objects at world origin so that +/// localSphere.Origin == worldPosition. +/// +/// +/// +/// Retail references: +/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 / +/// ACE BSPTree.cs:192-196. +/// BSPTREE::find_collisions Path 6 / set_collide — +/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219. +/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 / +/// ACE Transition.cs:746-777. +/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 / +/// ACE SpherePath.cs:279-286. +/// +/// +public static class BSPStepUpFixtures +{ + // ------------------------------------------------------------------------- + // Polygon ID constants — each fixture uses a distinct range so the + // resolved-polygon dictionary is unambiguous when fixtures are composed. + // ------------------------------------------------------------------------- + public const ushort LowStep_FloorId = 10; + public const ushort LowStep_WallId = 11; + public const ushort LowStep_UpperFloorId = 12; + + public const ushort TallWall_FloorId = 20; + public const ushort TallWall_WallId = 21; + + public const ushort FlatRoof_FloorId = 30; + public const ushort FlatRoof_RoofId = 31; + + public const ushort SlopedUnwalkable_FloorId = 40; + public const ushort SlopedUnwalkable_SlopeId = 41; + + // ------------------------------------------------------------------------- + // Sphere radius used in every test. + // ------------------------------------------------------------------------- + public const float SphereRadius = 0.2f; + + // ========================================================================= + // Fixture 1 — Low step (25 cm) + // + // Schema (side view, XZ plane): + // + // +X ──────────────────► + // Z + // 0.5 ┆ ┌─────── ← UpperFloor at z=0.25 (vert 8..11) + // 0.25├───────────┤ + // ┆ Wall ┆ (x=0.5, z=[0,0.25]) + // 0.0 ┆═══════════┘ + // ← Floor at z=0 (vert 0..3) + // + // The mover starts grounded at x=-0.5, z=SphereRadius and walks toward +X. + // Expected: step-up succeeds when Contact is set; sphere lifts to z=0.25+eps. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a 25 cm step. + /// + /// Geometry (object-local space): + /// + /// 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]. + /// + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + LowStep() + { + var resolved = new Dictionary(); + + // Lower floor: z=0, x∈[-2,0.5], y∈[-1,1], normal = +Z + resolved[LowStep_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Vertical wall facing -X at x=0.5, z∈[0,0.25], normal = -X + // For normal=(-1,0,0), the winding that makes cross(normal,edge)·disp > 0 + // for interior points is: (y=-1,z=0)→(y=-1,z=0.25)→(y=1,z=0.25)→(y=1,z=0). + resolved[LowStep_WallId] = MakeQuad( + new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, -1f, 0.25f), + new Vector3(0.5f, 1f, 0.25f), + 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 + 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)); + + // Build a flat BSP tree: one internal node with all three polys in a leaf. + // The bounding sphere covers everything. + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f }, + }; + leaf.Polygons.Add(LowStep_FloorId); + leaf.Polygons.Add(LowStep_WallId); + leaf.Polygons.Add(LowStep_UpperFloorId); + + return (leaf, resolved); + } + + // ========================================================================= + // Fixture 2 — Too-tall wall (5 m) + // + // A floor at z=0 and a 5 m wall at x=0.5 with no floor on the other side. + // Expected: step-up fails (wall too tall), mover slides along wall. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a wall that + /// is too tall to step over (5 m), so step-up should fail. + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + TallWall() + { + var resolved = new Dictionary(); + + // Floor at z=0 + resolved[TallWall_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Tall wall at x=0.5, z∈[0,5], normal = -X + // Winding for normal=(-1,0,0): (y=-1,z=0)→(y=-1,z=5)→(y=1,z=5)→(y=1,z=0). + resolved[TallWall_WallId] = MakeQuad( + new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, -1f, 5f), + new Vector3(0.5f, 1f, 5f), + new Vector3(0.5f, 1f, 0f), + expectedNormal: new Vector3(-1f, 0f, 0f)); + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f }, + }; + leaf.Polygons.Add(TallWall_FloorId); + leaf.Polygons.Add(TallWall_WallId); + + return (leaf, resolved); + } + + // ========================================================================= + // Fixture 3 — Flat roof (3 m) + // + // A horizontal polygon at z=3 representing a building rooftop. + // The mover is airborne (no Contact flag) descending toward the roof. + // Expected (after L.2.2): Path 6 sets Collide flag; the Collide-flag handler + // re-tests as Placement; ContactPlane is set; OnWalkable is established. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a 3 m flat roof. + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + FlatRoof() + { + var resolved = new Dictionary(); + + // Ground floor for reference (not involved in landing test) + resolved[FlatRoof_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f), + new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Roof at z=3.0, x∈[-2,2], y∈[-1,1], normal = +Z + resolved[FlatRoof_RoofId] = MakeFloor( + new Vector3(-2f, -1f, 3f), new Vector3(2f, -1f, 3f), + new Vector3(2f, 1f, 3f), new Vector3(-2f, 1f, 3f)); + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 1.5f), Radius = 10f }, + }; + leaf.Polygons.Add(FlatRoof_FloorId); + leaf.Polygons.Add(FlatRoof_RoofId); + + return (leaf, resolved); + } + + // ========================================================================= + // Fixture 4 — Sloped unwalkable surface (60°) + // + // A flat reference floor plus an angled slope at ~60° from horizontal. + // normal.Z = cos(60°) ≈ 0.5 < PhysicsGlobals.FloorZ (0.6642). + // Expected: no contact plane set; mover slides off. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a steep (60°) + /// slope whose normal.Z is below the walkable threshold. + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + SlopedUnwalkable() + { + var resolved = new Dictionary(); + + // Reference floor at z=0 + resolved[SlopedUnwalkable_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(0f, -1f, 0f), + new Vector3(0f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Steep slope: rises 2 m over 1 m horizontal run (63.4° from horizontal). + // Vertices: (0,-1,0), (1,-1,2), (1,1,2), (0,1,0) + // Normal direction: cross((1,0,2)-(0,0,0), (0,1,0)-(0,0,0)) ∝ (-2,0,1) normalised + // After normalisation: (-0.894, 0, 0.447) — normal.Z ≈ 0.447 < FloorZ. + // We point the normal outward (-X side) so it represents a wall-like slope. + var v0 = new Vector3(0f, -1f, 0f); + var v1 = new Vector3(1f, -1f, 2f); + var v2 = new Vector3(1f, 1f, 2f); + var v3 = new Vector3(0f, 1f, 0f); + var raw = Vector3.Cross(v1 - v0, v3 - v0); + var slopeNormal = Vector3.Normalize(raw); + // Ensure the normal faces away from the approach side (-X direction). + if (slopeNormal.X > 0) slopeNormal = -slopeNormal; + + var vertices = new[] { v0, v1, v2, v3 }; + float dotSum = 0f; + foreach (var v in vertices) dotSum += Vector3.Dot(slopeNormal, v); + float d = -(dotSum / vertices.Length); + + resolved[SlopedUnwalkable_SlopeId] = new ResolvedPolygon + { + Vertices = vertices, + Plane = new Plane(slopeNormal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0.5f, 0f, 1f), Radius = 10f }, + }; + leaf.Polygons.Add(SlopedUnwalkable_FloorId); + leaf.Polygons.Add(SlopedUnwalkable_SlopeId); + + return (leaf, resolved); + } + + // ========================================================================= + // Transition builder helpers + // ========================================================================= + + /// + /// Build a for a grounded mover (Contact + OnWalkable set). + /// + /// + /// The mover's foot sphere starts at and is headed + /// toward . is + /// set to so the test can control which step + /// heights succeed. + /// + /// + public static Transition MakeGroundedTransition( + Vector3 from, + Vector3 to, + float stepUpHeight = 0.30f, + uint cellId = 0xA9B40001u) + { + var t = new Transition(); + t.SpherePath.InitPath(from, to, cellId, SphereRadius); + t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + t.ObjectInfo.StepUpHeight = stepUpHeight; + t.ObjectInfo.StepDownHeight = 0.04f; + t.ObjectInfo.StepDown = true; + // Seed LastKnownContactPlane so the mover is "on the floor". + t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + t.CollisionInfo.LastKnownContactPlaneValid = true; + return t; + } + + /// + /// Build a for an airborne mover (no Contact, no OnWalkable). + /// + /// + /// Represents a character that has just jumped or fallen and is now moving + /// downward to land on a surface. + /// + /// + public static Transition MakeAirborneTransition( + Vector3 from, + Vector3 to, + uint cellId = 0xA9B40001u) + { + var t = new Transition(); + t.SpherePath.InitPath(from, to, cellId, SphereRadius); + t.ObjectInfo.State = ObjectInfoState.None; + t.ObjectInfo.StepUpHeight = 0.04f; + t.ObjectInfo.StepDownHeight = 0.04f; + t.ObjectInfo.StepDown = false; + return t; + } + + // ========================================================================= + // Internal polygon builders + // ========================================================================= + + // Build a horizontal floor polygon (normal = +Z) from four CCW vertices + // (as viewed from above). + private static ResolvedPolygon MakeFloor( + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3) + { + var verts = new[] { v0, v1, v2, v3 }; + var normal = Vector3.UnitZ; + float dotSum = 0f; + foreach (var v in verts) dotSum += Vector3.Dot(normal, v); + float d = -(dotSum / verts.Length); + return new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + } + + // Build a quad polygon with a specified outward normal. + // Vertices should be ordered so that the cross-product of two edges aligns + // with expectedNormal; we explicitly override the computed plane so the test + // is deterministic regardless of winding order. + private static ResolvedPolygon MakeQuad( + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, + Vector3 expectedNormal) + { + var verts = new[] { v0, v1, v2, v3 }; + float dotSum = 0f; + foreach (var v in verts) dotSum += Vector3.Dot(expectedNormal, v); + float d = -(dotSum / verts.Length); + return new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(expectedNormal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs new file mode 100644 index 00000000..2333e70d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in +/// . +/// +/// +/// Tests are organised in three groups corresponding to the three commits: +/// +/// +/// Group A — Baselines: behaviours that should pass both before +/// and after the implementation (no-hit returns OK, fixture geometry checks). +/// Group B — Phase L.2.1 (Path 5 step-up): tests that are RED +/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these +/// GREEN. +/// Group C — Phase L.2.2 (Path 6 SetCollide): tests that are RED +/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2 +/// flips these GREEN. +/// +/// +/// +/// Retail references: +/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 / +/// ACE BSPTree.cs:192-196. +/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 / +/// ACE Transition.cs:746-777. +/// BSPTREE::find_collisions Path 6 / SPHEREPATH::set_collide — +/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219. +/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 / +/// ACE SpherePath.cs:279-286. +/// CTransition::transitional_insert Collide branch — +/// acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930. +/// +/// +public class BSPStepUpTests +{ + // ========================================================================= + // Group A — Baselines (pass before AND after the implementation) + // ========================================================================= + + /// + /// No BSP geometry → FindCollisions returns OK with no state changes. + /// + [Fact] + public void A1_NullRoot_ReturnsOK() + { + var from = new Vector3(0f, 0f, BSPStepUpFixtures.SphereRadius); + var to = new Vector3(0.1f, 0f, BSPStepUpFixtures.SphereRadius); + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = to, + Radius = BSPStepUpFixtures.SphereRadius, + }; + + var result = BSPQuery.FindCollisions( + null, + new Dictionary(), + t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.Equal(TransitionState.OK, result); + } + + /// + /// Grounded mover far from the wall → no collision → OK. + /// + [Fact] + public void A2_GroundedMover_NoWallNear_ReturnsOK() + { + var (root, resolved) = BSPStepUpFixtures.LowStep(); + + // Moving in -X, away from the wall at x=0.5. + var from = new Vector3(-1f, 0f, BSPStepUpFixtures.SphereRadius); + var to = new Vector3(-1.5f, 0f, BSPStepUpFixtures.SphereRadius); + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.Equal(TransitionState.OK, result); + } + + /// + /// Airborne mover well above the roof → no collision → OK. + /// + [Fact] + public void A3_AirborneMover_AboveRoof_ReturnsOK() + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + + // Mover at z=6 (well above the roof at z=3) with tiny downward step. + float highZ = 6f; + var from = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius); + var to = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius - 0.01f); + var t = BSPStepUpFixtures.MakeAirborneTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.Equal(TransitionState.OK, result); + } + + /// + /// The slope fixture's polygon must have normal.Z below FloorZ (confirms + /// the fixture geometry is set up correctly as a non-walkable surface). + /// + [Fact] + public void A4_SlopedFixture_NormalBelowFloorZ() + { + var (_, resolved) = BSPStepUpFixtures.SlopedUnwalkable(); + var slope = resolved[BSPStepUpFixtures.SlopedUnwalkable_SlopeId]; + + Assert.True(slope.Plane.Normal.Z < PhysicsGlobals.FloorZ, + $"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be < FloorZ ({PhysicsGlobals.FloorZ:F4})"); + Assert.True(slope.Plane.Normal.Z > 0f, + $"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be > 0 (upward-facing)"); + } + + /// + /// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable). + /// + [Fact] + public void A5_LowStepUpperFloor_NormalAboveFloorZ() + { + var (_, resolved) = BSPStepUpFixtures.LowStep(); + var upper = resolved[BSPStepUpFixtures.LowStep_UpperFloorId]; + + Assert.True(upper.Plane.Normal.Z >= PhysicsGlobals.FloorZ, + $"Upper floor normal.Z ({upper.Plane.Normal.Z:F4}) must be >= FloorZ ({PhysicsGlobals.FloorZ:F4})"); + } + + /// + /// Roof polygon has normal.Z >= LandingZ (it can be landed on). + /// + [Fact] + public void A6_FlatRoofPolygon_NormalAboveLandingZ() + { + var (_, resolved) = BSPStepUpFixtures.FlatRoof(); + var roof = resolved[BSPStepUpFixtures.FlatRoof_RoofId]; + + Assert.True(roof.Plane.Normal.Z >= PhysicsGlobals.LandingZ, + $"Roof normal.Z ({roof.Plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})"); + } + + // ========================================================================= + // Group B — Phase L.2.1 (Path 5 step-up) + // + // RED before L.2.1, GREEN after. + // Each test documents the CURRENT wrong behaviour and EXPECTED correct one. + // ========================================================================= + + /// + /// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm): + /// should step up onto the upper floor, not slide sideways. + /// + /// + /// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall; + /// Z stays at floor level. + /// + /// + /// Expected after L.2.1: Path 5 calls StepUp → DoStepDown finds upper floor + /// → sphere lifts to z ≥ 0.25 + SphereRadius and X advances past the wall. + /// + /// + /// Retail: BSPTREE::step_sphere_up / CTransition::step_up + /// acclient_2013_pseudo_c.txt:323849, 273099. + /// + [Fact] + public void B1_GroundedMover_LowStep_StepsUp() + { + 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 + + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight); + var engine = MakeTestEngine(root, resolved); + + 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; + 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}. " + + "Path 5 must call StepUp (L.2.1) instead of wall-sliding."); + } + + /// + /// Grounded mover walking into the too-tall wall (5 m) should NOT step up — + /// the wall is taller than StepUpHeight. + /// + /// + /// Expected: StepUp is called, DoStepDown finds no walkable surface within + /// 0.04 m (no upper floor exists), StepUpSlide applies → mover stays + /// left of the wall. + /// + /// + /// Retail: SPHEREPATH::step_up_slide + /// ACE SpherePath.cs:309-316. + /// + [Fact] + public void B2_GroundedMover_TallWall_BlockedOrSlides() + { + 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); + + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight); + var engine = MakeTestEngine(root, resolved); + + t.FindTransitionalPosition(engine); + + // The mover should NOT have crossed the wall at x=0.5. + float wallFace = 0.5f - BSPStepUpFixtures.SphereRadius; + Assert.True(t.SpherePath.CurPos.X <= wallFace + PhysicsGlobals.EPSILON * 20f, + $"Expected mover blocked before wall (x <= {wallFace:F3}), " + + $"got CurPos.X = {t.SpherePath.CurPos.X:F4}"); + } + + /// + /// Direct Path 5 invocation: Contact mover sphere just overlapping the low + /// wall should NOT return Slid after L.2.1. + /// + /// + /// Current: returns Slid (wall-slide). + /// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted. + /// + /// + [Fact] + public void B3_Path5_DirectCall_ContactHitsLowWall_NotSlid() + { + var (root, resolved) = BSPStepUpFixtures.LowStep(); + + // Sphere center overlaps the wall (x=0.5) by half-radius. + float r = BSPStepUpFixtures.SphereRadius; + var checkPos = new Vector3(0.5f - r * 0.5f, 0f, r); + var currPos = new Vector3(0.1f, 0f, r); + + var t = new Transition(); + t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r); + t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u); + t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + t.ObjectInfo.StepUpHeight = 0.30f; + t.ObjectInfo.StepDownHeight = 0.04f; + t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + t.CollisionInfo.LastKnownContactPlaneValid = true; + + 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. + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + currPos, Vector3.UnitZ, 1.0f); + + // After L.2.1 this assertion flips from failing (Slid) to passing. + Assert.NotEqual(TransitionState.Slid, result); + } + + // ========================================================================= + // Group C — Phase L.2.2 (Path 6 SetCollide) + // + // RED before L.2.2, GREEN after. + // ========================================================================= + + /// + /// Airborne mover hitting the flat roof from above should set Collide flag + /// and return Adjusted (not Slid with wall-slide offset). + /// + /// + /// Current (wrong): Path 6 computes a wall-slide offset and returns Slid. + /// + /// + /// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets + /// WalkableAllowance = LandingZ, returns Adjusted. + /// + /// + /// Retail: SPHEREPATH::set_collide + /// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219. + /// + [Fact] + public void C1_Path6_AirborneMoverHitsRoof_SetsCollideFlagAndAdjusted() + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + + // Sphere center just penetrating the roof polygon (z=3) from above. + float r = BSPStepUpFixtures.SphereRadius; + var checkPos = new Vector3(0f, 0f, 3f + r * 0.5f); // half-radius above roof + var currPos = new Vector3(0f, 0f, 3f + r + 0.1f); // clearly above + + var t = new Transition(); + t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r); + t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u); + t.ObjectInfo.State = ObjectInfoState.None; // airborne — no Contact + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + currPos, Vector3.UnitZ, 1.0f); + + // After L.2.2: result = Adjusted, Collide = true, WalkableAllowance = LandingZ. + // Currently: result = Slid (wall-slide path). + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(t.SpherePath.Collide, + "Expected SpherePath.Collide = true after Path 6 hit (L.2.2)"); + Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance, + precision: 5); + } + + /// + /// Full integration: airborne mover drops onto the 3 m flat roof. + /// + /// + /// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement, + /// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1. + /// + /// + /// Current: mover slides sideways off the roof (never lands). + /// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ. + /// + /// + [Fact] + public void C2_AirborneMover_LandsOnFlatRoof_ContactPlaneSet() + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + + 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 + + var t = BSPStepUpFixtures.MakeAirborneTransition(from, to); + var engine = MakeTestEngine(root, resolved); + + t.FindTransitionalPosition(engine); + + // After L.2.2: at least one of ContactPlane / LastKnownContactPlane is set. + bool planeSet = t.CollisionInfo.ContactPlaneValid + || t.CollisionInfo.LastKnownContactPlaneValid; + + Assert.True(planeSet, + "Expected a contact plane after landing on roof (L.2.2). " + + "Currently Path 6 wall-slides and never sets ContactPlane."); + + if (planeSet) + { + var plane = t.CollisionInfo.ContactPlaneValid + ? t.CollisionInfo.ContactPlane + : t.CollisionInfo.LastKnownContactPlane; + + Assert.True(plane.Normal.Z >= PhysicsGlobals.LandingZ, + $"Contact plane normal.Z ({plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})"); + } + } + + /// + /// Airborne mover descending toward a steep slope (normal.Z < FloorZ): + /// Path 6 should still set the Collide flag (it fires for any polygon hit, + /// walkable or not). + /// + /// Retail: set_collide fires unconditionally when sphere_intersects_poly + /// hits; the walkable check happens later in the Collide-flag handler. + /// + [Fact] + public void C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide() + { + var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable(); + + float r = BSPStepUpFixtures.SphereRadius; + // Approach the slope mid-face from above. + var checkPos = new Vector3(0.5f, 0f, 1.0f + r * 0.5f); + var currPos = new Vector3(0.5f, 0f, 1.0f + r + 0.1f); + + var t = new Transition(); + t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r); + t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u); + t.ObjectInfo.State = ObjectInfoState.None; // airborne + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + currPos, Vector3.UnitZ, 1.0f); + + // After L.2.2: Collide flag set, Adjusted returned. + // Currently: Slid (wall-slide). + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(t.SpherePath.Collide, + "Expected Collide flag set when airborne sphere hits slope (L.2.2)"); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /// + /// 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. + /// + private static PhysicsEngine MakeTestEngine( + PhysicsBSPNode root, + Dictionary resolved, + Vector3? objectPosition = null) + { + 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; + + var engine = new PhysicsEngine(); + engine.AddLandblock( + LandblockId, + new TerrainSurface(heights, heightTab), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + // Register the BSP physics into the data cache. + var cache = new PhysicsDataCache(); + var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root }; + var physics = new GfxObjPhysics + { + BSP = bspTree, + PhysicsPolygons = new Dictionary(), + Vertices = new DatReaderWriter.Types.VertexArray(), + Resolved = resolved, + BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f }, + }; + cache.RegisterGfxObjForTest(SyntheticGfxId, physics); + engine.DataCache = cache; + + // Register the object in the shadow registry so FindObjCollisions picks it up. + Vector3 pos = objectPosition ?? Vector3.Zero; + engine.ShadowObjects.Register( + entityId: SyntheticGfxId, + gfxObjId: SyntheticGfxId, + worldPos: pos, + rotation: Quaternion.Identity, + radius: 15f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + scale: 1.0f); + + return engine; + } +} From 670f892bd37cd7ab73c53e8cad3c1e76700d1f45 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 16:16:39 +0200 Subject: [PATCH 02/17] =?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 dfef08bd..2895b209 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 57d6e6d2..e4e784c6 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 fb06b180..ac565061 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 2333e70d..e6f93a26 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( From b2aaac4e52a949a56516648ee18d5ff41ad54110 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 17:23:54 +0200 Subject: [PATCH 03/17] =?UTF-8?q?fix(physics):=20L.2.3a=20=E2=80=94=20reta?= =?UTF-8?q?il-realistic=20step=20heights=20(was=205m=20up,=204cm=20down)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two values were producing weird live-test behavior: - PlayerMovementController.StepUpHeight default = 5.0f (5 meters) and GameWindow's fallback = 2.0f. With these, walking horizontally into a steep slope let the step-up scan find walkable polygons up to 5m away, which often included a small building's flat top. The player visually "teleported" up onto the roof and then could walk on surfaces they should have just slid off. - stepDownHeight was hardcoded 0.04f (4 cm) in two ResolveWithTransition call sites. A typical stair step is 15–25 cm tall, so when the player walked off the top of a stair onto level ground, the step-down probe didn't reach the next surface. For one frame the contact plane was invalid → ValidateTransition cleared OnWalkable → animation flickered to falling → next frame gravity dropped + terrain found. Visible 1-frame flicker reported as "small falling animation when reaching stair top." Retail's Setup.step_up_height and Setup.step_down_height for human characters are both ~0.4 m. Sourcing them from the player's Setup (already cached in PhysicsDataCache) with a 0.4 m fallback when the field is missing. Files: - PlayerMovementController.cs:104 — StepUpHeight default 5.0 → 0.4 - PlayerMovementController.cs (new) — StepDownHeight property, default 0.4 - PlayerMovementController.cs:414 — pass StepDownHeight from controller - GameWindow.cs:7019-7036 — read Setup.StepDownHeight + reduce fallbacks - GameWindow.cs:5759 — remote dead-reckoning: 2.0/0.04 → 0.4/0.4 No test changes; existing 12 BSPStepUp tests still cover the value flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Input/PlayerMovementController.cs | 24 +++++++++++++++---- src/AcDream.App/Rendering/GameWindow.cs | 20 ++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 8084231a..2a832d0c 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -97,11 +97,25 @@ public sealed class PlayerMovementController /// /// Maximum Z increase per movement step before the move is rejected. - /// AC's default StepUpHeight for human characters is ~2 units. - /// Using 5 for the MVP to be forgiving — prevents walking up vertical - /// walls but allows stairs, ramps, and terrain slopes. + /// Retail's step_up_height for human characters is ~0.4 m (hip- + /// level). Setting this too high lets the player teleport up small + /// buildings via the step-up scan finding any walkable polygon within + /// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted + /// the building's flat top instead of sliding off the slope). + /// Authoritative source is the player's Setup.StepUpHeight set + /// in GameWindow.cs at world-entry time. /// - public float StepUpHeight { get; set; } = 5.0f; + public float StepUpHeight { get; set; } = 0.4f; + + /// + /// L.2.3a (2026-04-29): how far below the foot the step-down probe + /// reaches when transitioning between surfaces. Retail's + /// step_down_height for human characters is ~0.4 m. With the + /// previous 4 cm hardcoded value, walking off the top of a stair onto + /// the ground 25 cm below produced a one-frame contact-plane gap — the + /// animation system briefly flickered to falling. + /// + public float StepDownHeight { get; set; } = 0.4f; /// /// Current portal-space state. Set to PortalSpace when the server sends @@ -411,7 +425,7 @@ public sealed class PlayerMovementController sphereRadius: 0.48f, // human player radius from Setup sphereHeight: 1.2f, // human player height from Setup stepUpHeight: StepUpHeight, - stepDownHeight: 0.04f, // retail default + stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, body: _body, // persist ContactPlane across frames for slope tracking // Commit C 2026-04-29 — local player is always IsPlayer. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ba3c9781..d5ee4443 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5755,8 +5755,8 @@ public sealed class GameWindow : IDisposable preIntegratePos, postIntegratePos, rm.CellId, sphereRadius: 0.48f, sphereHeight: 1.2f, - stepUpHeight: 2.0f, // retail default for unknown remotes - stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight + stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f + stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f // K-fix9 (2026-04-26): mirror the K-fix7 gate — // airborne remotes must NOT pre-seed the // ContactPlane, otherwise AdjustOffset's snap-to-plane @@ -7016,7 +7016,13 @@ public sealed class GameWindow : IDisposable _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill); Console.WriteLine($"live: {loggingTag} — applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); } - // Read the real step height from the player's Setup dat. + // Read the real step heights from the player's Setup dat. + // L.2.3a (2026-04-29): retail's Setup.StepUpHeight for humans is + // ~0.4 m, NOT 2 m. With 2 m fallback the step-up scan reached + // small-building roofs and teleported the player onto them. Same + // for StepDownHeight — was hardcoded 0.04 m, causing stair-top + // contact-plane gaps. Both now come from Setup with retail-realistic + // 0.4 m fallbacks. if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); @@ -7024,11 +7030,15 @@ public sealed class GameWindow : IDisposable _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) ? playerSetup.StepUpHeight - : 2f; + : 0.4f; + _playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f) + ? playerSetup.StepDownHeight + : 0.4f; } else { - _playerController.StepUpHeight = 2f; + _playerController.StepUpHeight = 0.4f; + _playerController.StepDownHeight = 0.4f; } int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); From 37894913943f83d09d75049f6bc87edd2cddadd9 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 17:24:12 +0200 Subject: [PATCH 04/17] =?UTF-8?q?fix(physics):=20L.2.3b=20=E2=80=94=20Path?= =?UTF-8?q?=205=20step-up=20recursion=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path 5 (Contact mover hits BSP polygon) calls DoStepUp → DoStepDown → TransitionalInsert(5) → FindObjCollisions → which can hit the same wall again → Path 5 fires AGAIN → recursive DoStepUp. Bounded by the inner numAttempts=5 budget, but with significant per-step churn — every recursion clears and re-establishes the contact plane, finishing in an inconsistent state when the ranges decay. Also produced gratuitous slowdown against tall walls. Retail (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on `if (sp.step_up == 0 && sp.step_down == 0)`. acdream's port was missing this guard. Mid-recursion we now fall back to the wall-slide response that already exists for the no-engine path. Files: - BSPQuery.cs Path 5 (foot sphere): added `&& !path.StepUp && !path.StepDown` - BSPQuery.cs Path 5 (head sphere): same guard Live-test bug: walking into building walls intermittently locked the player in falling animation, hard to recover. After the guard, the single-shot wall-slide produces clean blocking + horizontal slide. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/BSPQuery.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 2895b209..eff02221 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1469,11 +1469,18 @@ public static class BSPQuery if (hit0 || hitPoly0 is not null) { var worldNormal = L2W(hitPoly0!.Plane.Normal); - if (engine is not null) + // L.2.3b (2026-04-29): recursion guard. Retail + // (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on + // `if (sp.step_up == 0 && sp.step_down == 0)`. Without this, + // the inner TransitionalInsert spawned by DoStepDown re-enters + // FindObjCollisions, hits the same wall, and recursively + // re-invokes step-up — churning the contact plane until + // numAttempts decays. Mid-recursion we fall back to wall-slide. + if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); - // No engine available (env-cell path without engine param) — - // fall back to wall-slide so existing indoor geometry still blocks. + // No engine OR step-up/step-down already in progress — fall + // back to wall-slide so the inner sphere doesn't recurse. collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); return TransitionState.Slid; @@ -1490,7 +1497,8 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { var worldNormal = L2W(hitPoly1!.Plane.Normal); - if (engine is not null) + // L.2.3b: same recursion guard as the foot-sphere branch. + if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); collisions.SetCollisionNormal(worldNormal); From d2f6067960319c17d4c39364a1049e77c6635923 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 17:24:49 +0200 Subject: [PATCH 05/17] =?UTF-8?q?fix(physics):=20L.2.3c=20=E2=80=94=20pres?= =?UTF-8?q?erve=20contact=20plane=20through=20failed=20step-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "stuck in falling animation against walls" live-test bug (intermittent, hard to recover from). Two compounding issues, fixed at both layers. (1) DoStepUp cleared CollisionInfo.ContactPlaneValid unconditionally at the start of step-up. On step-up FAILURE, RestoreCheckPos restored the position but the contact plane stayed cleared. Added a save/ restore around the clear so a failed step-up returns the mover to its pre-attempt grounded state. (2) ValidateTransition propagated the current frame's invalid contact state into LastKnownContactPlane via: ci.LastKnownContactPlaneValid = ci.ContactPlaneValid This destroyed the prior frame's ground memory whenever the current contact was momentarily lost (StepUpSlide clears ContactPlane). Changed to: only OVERWRITE LastKnown when current is valid. (3) The same ValidateTransition then set oi.State &= ~(Contact | OnWalkable) when ContactPlaneValid was false, even if LastKnown was still valid. Added an "else if (LastKnownContactPlaneValid)" branch that sets Contact + OnWalkable from LastKnown so the animation system sees the mover as grounded. Combined effect: walking into a too-tall wall now consistently slides along the wall without ever flickering to the falling animation. The mover's grounded state survives transient ContactPlane invalidation during the step-up retry cycle. Retail's `transitional_insert` has different upstream invariants that keep ContactPlane valid more often, so retail doesn't need the acdream-specific LastKnown fallback path. ACE has the same pattern as retail; acdream's per-frame Resolve architecture exposes the gap that this fix closes. Tests: - New D1 regression test: grounded mover into too-tall wall — must end frame with grounded state preserved. - New D2 regression test: same scenario — execution time bounded (<100ms) to catch any future recursion issues. Files: - TransitionTypes.cs DoStepUp: save+restore ContactPlane around step-up - TransitionTypes.cs ValidateTransition: preserve LastKnown + grounded state from last-known when current is invalid - BSPStepUpTests.cs: D1, D2 regression tests Test count 825 → 825 (D1+D2 added in L.2.3 patch series). Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 52 ++++++++++++- .../Physics/BSPStepUpTests.cs | 76 +++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index e4e784c6..bd34857c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1356,6 +1356,19 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; + // L.2.3c (2026-04-29): capture the existing contact plane BEFORE + // clearing it. On step-up failure (too-tall wall) we restore it so + // the mover stays grounded — without this, walking into a wall + // dropped OnWalkable and the animation system flickered to falling. + // Retail clears here too (acclient_2013_pseudo_c.txt:273099) but + // its outer transition state seeded the plane back via a different + // path (LastKnownContactPlane retention + check_contact). For + // acdream's per-frame Resolve we restore here directly. + bool savedCpValid = ci.ContactPlaneValid; + Plane savedCp = ci.ContactPlane; + uint savedCpCellId = ci.ContactPlaneCellId; + bool savedCpIsWater = ci.ContactPlaneIsWater; + ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; @@ -1381,8 +1394,21 @@ public sealed class Transition sp.WalkableValid = false; if (!stepDown) + { sp.RestoreCheckPos(); + // L.2.3c: restore the pre-step-up contact plane. The mover was + // grounded before the failed climb attempt; failing to climb + // a too-tall wall must not change that. + if (savedCpValid) + { + ci.ContactPlane = savedCp; + ci.ContactPlaneValid = true; + ci.ContactPlaneCellId = savedCpCellId; + ci.ContactPlaneIsWater = savedCpIsWater; + } + } + return stepDown; } @@ -1498,11 +1524,18 @@ public sealed class Transition ci.SetSlidingNormal(ci.CollisionNormal); // Preserve contact plane for next step. - ci.LastKnownContactPlaneValid = ci.ContactPlaneValid; + // L.2.3c (2026-04-29): only OVERWRITE LastKnown when current is valid. + // Previously: `LastKnownValid = ContactPlaneValid` cleared + // LastKnown whenever current was invalid — destroying the prior frame's + // contact memory. After StepUpSlide cleared ContactPlane mid-step + // (failed step-up against a too-tall wall), this propagated to + // LastKnown and the player went airborne for a frame, flickering the + // falling animation. Now LastKnown survives transient losses. if (ci.ContactPlaneValid) { - ci.LastKnownContactPlane = ci.ContactPlane; - ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId; + ci.LastKnownContactPlaneValid = true; + ci.LastKnownContactPlane = ci.ContactPlane; + ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId; ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater; oi.State |= ObjectInfoState.Contact; @@ -1511,6 +1544,19 @@ public sealed class Transition else oi.State &= ~ObjectInfoState.OnWalkable; } + else if (ci.LastKnownContactPlaneValid) + { + // L.2.3c: current contact lost transiently (e.g. StepUpSlide + // cleared it during a failed step-up) but the prior frame's + // contact is still valid — keep the mover grounded via the + // last-known plane. Without this, every wall bump dropped the + // player into the falling animation for one frame. + oi.State |= ObjectInfoState.Contact; + if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.LandingZ) + oi.State |= ObjectInfoState.OnWalkable; + else + oi.State &= ~ObjectInfoState.OnWalkable; + } else { oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable); diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index e6f93a26..258fd58b 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -430,6 +430,82 @@ public class BSPStepUpTests "Expected Collide flag set when airborne sphere hits slope (L.2.2)"); } + // ========================================================================= + // Group D — Phase L.2.3 regression tests + // + // Bugs caught by live testing 2026-04-29: + // D1 — walking into a too-tall wall must NOT clear ContactPlane (animation + // flickers to "falling" when contact is lost mid-step against a wall). + // D2 — Path 5 step-up must NOT recurse infinitely against a tall wall + // (retail guards step_sphere_up with `if (sp.step_up == 0)` per + // acclient_2013_pseudo_c.txt:272954). Without the guard, DoStepUp + // invokes DoStepDown which TransitionalInsert(5)'s into FindObjCollisions + // which hits the same wall AGAIN → recursive DoStepUp. + // ========================================================================= + + /// + /// L.2.3c regression: a grounded mover walking into a too-tall wall must + /// retain its ground contact across the failed step-up. Before the fix, + /// DoStepUp cleared + /// unconditionally; on failure, RestoreCheckPos restored the position but + /// the contact plane stayed cleared, causing OnWalkable to drop and the + /// animation system to interpret the stuck-against-wall state as "airborne". + /// + [Fact] + public void D1_GroundedMover_TooTallWall_PreservesContactPlane() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + + // Foot at z=0, walking into the wall. + var from = new Vector3(0.1f, 0f, 0f); + var to = new Vector3(0.6f, 0f, 0f); + + // StepUpHeight 0.04m — too small to climb the 5m wall. + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f); + var engine = MakeTestEngine(root, resolved, terrainZ: 0f); + + t.FindTransitionalPosition(engine); + + // After failed step-up + slide, the mover should still be considered + // grounded — either via the live contact plane, the last-known one, + // or the OnWalkable flag preserved by terrain re-detection. + bool stillGrounded = t.CollisionInfo.ContactPlaneValid + || t.CollisionInfo.LastKnownContactPlaneValid + || t.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); + Assert.True(stillGrounded, + "Expected mover to still be grounded after walking into a too-tall " + + "wall (failed step-up should preserve LastKnownContactPlane)."); + } + + /// + /// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while + /// a step-up is already in progress. Test runs FindTransitionalPosition + /// with a tight time budget and verifies it terminates cleanly. Without the + /// guard the recursive DoStepUp churns the contact plane until numAttempts + /// runs out — finishing in an inconsistent state. + /// + [Fact] + public void D2_GroundedMover_TallWall_DoesNotRecurseInfinitely() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + + var from = new Vector3(0.1f, 0f, 0f); + var to = new Vector3(0.6f, 0f, 0f); + + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f); + var engine = MakeTestEngine(root, resolved, terrainZ: 0f); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + t.FindTransitionalPosition(engine); + sw.Stop(); + + // Bounded execution: even with recursion, this is a 4-step movement. + // 100ms is generous; without the guard, recursion adds noticeable cost. + Assert.True(sw.ElapsedMilliseconds < 100, + $"Step-up against tall wall took {sw.ElapsedMilliseconds}ms — " + + "indicates Path 5 recursing through DoStepUp without guard."); + } + // ========================================================================= // Helpers // ========================================================================= From 8fe178ee5c4d46c9b217f4678abcdd5ddefe115f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 17:56:22 +0200 Subject: [PATCH 06/17] =?UTF-8?q?fix(physics):=20L.2.3d/e/f=20=E2=80=94=20?= =?UTF-8?q?wall=20slide,=20edge=20block,=20step-up=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes from live testing of the L.2.3 step-height pass. L.2.3d — StepUpSlide actually applies the slide Previously SpherePath.StepUpSlide only set ci.SlidingNormal as a flag and returned Slid; the CURRENT step's CheckPos was never adjusted, so the sphere stopped dead at the wall. ValidateTransition's "default to UnitZ" branch then propagated UnitZ into SlidingNormal, overwriting the wall normal entirely. Net effect: stop-at-wall, no horizontal slide. ACE's StepUpSlide (SpherePath.cs:309-317) calls Sphere.SlideSphere which computes the actual slide offset against the contact-plane / wall-normal crease and applies it to CheckPos. acdream already had the same logic in Transition.SlideSphere as a private helper. Exposed as internal SlideSphereInternal; routed StepUpSlide through it. L.2.3e — step-down failure returns Collided (always-on edge block) When walking forward off a balcony / cliff, the step-down probe in TransitionalInsert searches stepDownHeight below CheckPos for a walkable surface. On failure the previous code returned OK, which ValidateTransition accepted — the player walked off the edge anyway, with `RestoreCheckPos` reverting only to the position right after the outer step's offset (still post-edge). Per ACE Transition.cs:268-320 (EdgeSlide), retail's always-on default for OnWalkable + !EdgeSlide-flag movers is to reject the move. Returning Collided here makes ValidateTransition revert CheckPos to CurPos (pre-step), giving the retail-faithful "stop at edge" behavior — both on terrain cliffs and on building/balcony edges. L.2.3f — diagnostic instrumentation for steep-roof investigation GameWindow logs the player's actual StepUpHeight + StepDownHeight at world-entry (along with the raw Setup.* values for comparison) so we can confirm whether the dat-derived value matches retail's spec (~0.4m) or is overriding to something larger. Transition.DoStepUp logs the polygon's collision-normal Z (gated on ACDREAM_DUMP_STEPUP=1 to keep cold-path noise low) so we can tell whether step-up is being triggered against truly-walkable polygons (Z >= FloorZ ≈ 0.66) or whether something steeper is sneaking through. Tests: 825/825 still pass. The L.2 conformance fixtures cover the slide path; D1 + D2 regression tests still pass with the StepUpSlide port. Live verification needed for: - #2 Wall slide: running close to a wall should slide along it. - #4 Edge block: running off a balcony should stop at the edge. - #3 Steep roof: launch with ACDREAM_DUMP_STEPUP=1 and report the "stepup: normal=..." log lines when climbing the offending roof. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 11 ++++ src/AcDream.Core/Physics/BSPQuery.cs | 2 +- src/AcDream.Core/Physics/TransitionTypes.cs | 68 ++++++++++++++++++--- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d5ee4443..b2bd9677 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7034,11 +7034,22 @@ public sealed class GameWindow : IDisposable _playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f) ? playerSetup.StepDownHeight : 0.4f; + // L.2.3f (2026-04-29): diagnostic — confirm what the actual + // values from the player's Setup dat are. Retail's spec says ~0.4 m + // for humans, but we want to verify rather than guess. If the + // dat-derived value is large (e.g. 1.5 m+) it explains why the + // player can mount steep roofs via the step-up scan reach. + Console.WriteLine( + $"physics: player step heights — StepUp={_playerController.StepUpHeight:F3} m " + + $"(Setup.StepUpHeight={(playerSetup?.StepUpHeight ?? 0f):F3}), " + + $"StepDown={_playerController.StepDownHeight:F3} m " + + $"(Setup.StepDownHeight={(playerSetup?.StepDownHeight ?? 0f):F3})"); } else { _playerController.StepUpHeight = 0.4f; _playerController.StepDownHeight = 0.4f; + Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)"); } int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index eff02221..a0947d0f 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1106,7 +1106,7 @@ public static class BSPQuery if (transition.DoStepUp(collisionNormal, engine!)) return TransitionState.OK; - return transition.SpherePath.StepUpSlide(transition.CollisionInfo); + return transition.SpherePath.StepUpSlide(transition); } // ------------------------------------------------------------------------- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index bd34857c..2b5834c9 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -248,15 +248,29 @@ public sealed class SpherePath /// /// 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). + /// caused the step-up attempt and runs the full sphere-slide computation + /// to actually move the sphere along the wall. + /// + /// + /// L.2.3d (2026-04-29): the previous version only set + /// as a flag; it never applied a slide offset. The user observed "running + /// close to the wall now I stop" — the sphere stayed pinned at the wall + /// and the slide normal got overwritten by ValidateTransition's + /// default-to-UnitZ branch. ACE actually computes the slide offset and + /// applies it to via Sphere.SlideSphere; + /// we delegate to which does + /// the same thing. + /// + /// + /// ACE: SpherePath.StepUpSlide + Sphere.SlideSphere + /// (SpherePath.cs:309-317, Sphere.cs:558-604). /// - public TransitionState StepUpSlide(CollisionInfo collisions) + public TransitionState StepUpSlide(Transition transition) { - collisions.ContactPlaneValid = false; - collisions.ContactPlaneIsWater = false; - collisions.SetSlidingNormal(StepUpNormal); - return TransitionState.Slid; + var ci = transition.CollisionInfo; + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin); } /// @@ -635,9 +649,20 @@ public sealed class Transition } } - // Step-down failed: stay at current position. + // L.2.3e (2026-04-29): step-down failed — the move would put + // the player off an edge with no walkable surface within reach. + // Retail's EdgeSlide (ACE Transition.cs:268-320) maps this to + // SetEdgeSlide(true, true, OK) which restores CheckPos to the + // saved (post-step) position — but our outer ValidateTransition + // accepts CheckPos as the new CurPos, defeating the intent. + // + // Returning Collided here makes ValidateTransition revert to + // CurPos (pre-step) — the always-on retail "stop at edge" + // behavior. Confirmed against ACE Transition.cs:317 where + // EdgeSlide returns Collided when no walkable surface is + // found and the EdgeSlide flag is unset (player default). sp.RestoreCheckPos(); - return TransitionState.OK; + return TransitionState.Collided; } return TransitionState.OK; @@ -1082,6 +1107,14 @@ public sealed class Transition /// normal variant). ACE: Sphere.SlideSphere(Transition, ref Vector3, Vector3). /// Decompiled: FUN_00538180. /// + /// + /// L.2.3d: exposed as internal so + /// can apply the same slide computation ACE's Sphere.SlideSphere uses + /// for failed step-up. Mirror of ACE Sphere.cs:558-604 (Plane variant). + /// + internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos) + => SlideSphere(collisionNormal, currPos); + private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos) { var sp = SpherePath; @@ -1356,6 +1389,23 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; + // L.2.3f (2026-04-29): diagnostic for steep-roof bug. Log the + // collision normal Z so we can confirm whether the polygon being + // stepped up onto is "walkable" (normal.Z >= FloorZ ≈ 0.66) or + // not (≈ 0.087 LandingZ when not OnWalkable). If we see steep + // normals being accepted, the issue is in the find_walkable + // threshold rather than the StepUpHeight reach. + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1") + { + float floor = PhysicsGlobals.FloorZ; + string verdict = collisionNormal.Z >= floor ? "WALKABLE" : "STEEP"; + Console.WriteLine( + $"stepup: normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " + + $"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + + $"OnWalkable={oi.State.HasFlag(ObjectInfoState.OnWalkable)}, " + + $"StepUpHeight={oi.StepUpHeight:F3}"); + } + // L.2.3c (2026-04-29): capture the existing contact plane BEFORE // clearing it. On step-up failure (too-tall wall) we restore it so // the mover stays grounded — without this, walking into a wall From eed8e8ccaacc0db995b5e96361aa3af3ae10bf4d Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 18:13:32 +0200 Subject: [PATCH 07/17] =?UTF-8?q?diag(physics):=20L.2.3g=20=E2=80=94=20log?= =?UTF-8?q?=20step-up=20SUCCESS/FAIL=20+=20landing=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhances the ACDREAM_DUMP_STEPUP=1 diagnostic so we can characterize the steep-roof bug. The original log only showed the input collision normal of the polygon that triggered step-up; it didn't show what polygon the step-up actually LANDED on (which can differ — step-up scans for any walkable polygon within StepUpHeight reach, so it might ascend onto a flatter surface higher up than the polygon hit). New log lines: stepup: enter normal=(...) → WALKABLE/STEEP, OnWalkable=..., StepUpHeight=... stepup: SUCCESS — landed on plane normal=(...) → WALKABLE/STEEP, new CheckPos=... stepup: FAILED — sliding back along normal When user climbs the offending steep roof, the SUCCESS line will tell us whether the landing polygon is steeper than FloorZ=0.66 (then we have a threshold bug) or whether step-up scanned past the steep slope to land on a flatter polygon (then the StepUpHeight reach is too permissive). Also logs CurPos and final CheckPos so we can correlate to in-world location. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 42 ++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 2b5834c9..6f737712 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1389,21 +1389,23 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; - // L.2.3f (2026-04-29): diagnostic for steep-roof bug. Log the - // collision normal Z so we can confirm whether the polygon being - // stepped up onto is "walkable" (normal.Z >= FloorZ ≈ 0.66) or - // not (≈ 0.087 LandingZ when not OnWalkable). If we see steep - // normals being accepted, the issue is in the find_walkable - // threshold rather than the StepUpHeight reach. - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1") + // L.2.3f (2026-04-29): diagnostic for steep-roof bug. Logs the + // input polygon normal that triggered step-up. The verdict tells + // whether THIS polygon would pass FloorZ (≈ 0.66) — but actual + // step-up acceptance depends on the polygon found by step_sphere_down + // INSIDE the recursive TransitionalInsert, which may be different. + // The post-step "result=" line below logs that outcome. + bool diag = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1"; + if (diag) { float floor = PhysicsGlobals.FloorZ; string verdict = collisionNormal.Z >= floor ? "WALKABLE" : "STEEP"; Console.WriteLine( - $"stepup: normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " + + $"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " + $"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + $"OnWalkable={oi.State.HasFlag(ObjectInfoState.OnWalkable)}, " + - $"StepUpHeight={oi.StepUpHeight:F3}"); + $"StepUpHeight={oi.StepUpHeight:F3}, " + + $"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})"); } // L.2.3c (2026-04-29): capture the existing contact plane BEFORE @@ -1443,6 +1445,28 @@ public sealed class Transition sp.StepUp = false; sp.WalkableValid = false; + // L.2.3f: log the result + landing plane if step-up succeeded. + // This is the actual surface the player ended up on, which may + // differ from the input collision normal (e.g. step-up scanned + // past a steep slope and landed on a flatter polygon higher up). + if (diag) + { + if (stepDown && ci.ContactPlaneValid) + { + float floor = PhysicsGlobals.FloorZ; + string verdict = ci.ContactPlane.Normal.Z >= floor ? "WALKABLE" : "STEEP"; + Console.WriteLine( + $"stepup: SUCCESS — landed on plane normal=" + + $"({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3}) " + + $"|Z|={ci.ContactPlane.Normal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + + $"new CheckPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2})"); + } + else + { + Console.WriteLine($"stepup: FAILED — sliding back along normal"); + } + } + if (!stepDown) { sp.RestoreCheckPos(); From 4cbfe0a5f8cf60c19f926596db1c014894ef3fe4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 19:13:56 +0200 Subject: [PATCH 08/17] =?UTF-8?q?fix(physics):=20L.2.3h=20=E2=80=94=20skip?= =?UTF-8?q?=20Placement=20in=20step-down=20contact-recovery=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-test bug: player getting "super stuck" near walls without touching them. Diagnostic showed 0 step-up calls, so the issue wasn't in DoStepUp. Root cause: my subagent's L.2.1 commit added a Placement validation inside DoStepDown to prevent step-up-through-walls. That check is right for DoStepUp's call (the original use case). But DoStepDown is ALSO called from TransitionalInsert's contact-recovery branch when the per- sub-step contact plane is briefly lost (e.g., right after a wall-slide nudges the sphere slightly upward). For that "maintain contact during normal movement" use, the Placement check is over-strict. Wall-slide can leave the sphere with sub-EPSILON overlap of the wall's BSP solid; SphereIntersectsSolid returns Collided inside Placement; DoStepDown returns false; my L.2.3e then escalates that to TransitionState.Collided in the outer loop; ValidateTransition reverts the position to CurPos every frame. Result: player stuck near the wall without ever touching it. Fix: add a `bool runPlacement = true` parameter to DoStepDown. - DoStepUp passes the default (Placement runs — protects step-up). - TransitionalInsert's contact-recovery branch passes false (Placement skipped — accepts whatever walkable surface is found within reach). This preserves L.2.3e's edge-block (genuine edges return Collided because no walkable is found, not because Placement rejected) while unbreaking normal-walking-near-walls. ACE Transition.cs:731-741 runs Placement unconditionally, but ACE's pre-step-down state machine is cleaner — acdream's residual wall-slide artifacts make Placement misfire here. Test count 825/825 still pass. Build clean. Live verification needed: walk near a wall, should no longer get stuck. Walk off a tall (>1.5m) balcony, should still edge-block. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 33 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 6f737712..cc6944aa 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -630,9 +630,17 @@ public sealed class Transition float radsum = sp.GlobalSphere[0].Radius * 2f; + // L.2.3h (2026-04-29): pass runPlacement=false. This + // branch's job is to maintain ground contact during normal + // movement (e.g., walking over small bumps or near walls). + // The Placement check inside DoStepDown is too strict for + // this use — minor wall overlap from a prior wall-slide + // would fail Placement and trigger the L.2.3e edge-block, + // leaving the player stuck near walls. DoStepUp still runs + // Placement for the step-UP-through-walls protection. if (radsum >= stepDownHeight) { - if (DoStepDown(stepDownHeight, zVal, engine)) + if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) { sp.WalkableValid = false; return TransitionState.OK; @@ -641,8 +649,8 @@ public sealed class Transition else { stepDownHeight *= 0.5f; - if (DoStepDown(stepDownHeight, zVal, engine) - || DoStepDown(stepDownHeight, zVal, engine)) + if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false) + || DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)) { sp.WalkableValid = false; return TransitionState.OK; @@ -1314,7 +1322,8 @@ public sealed class Transition /// Ported from pseudocode section 5 (StepDown). /// ACE: Transition.StepDown(float stepDownHeight, float zVal). /// - private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine) + private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine, + bool runPlacement = true) { var sp = SpherePath; @@ -1348,6 +1357,22 @@ public sealed class Transition && CollisionInfo.ContactPlaneValid && CollisionInfo.ContactPlane.Normal.Z >= walkableZ) { + // L.2.3h (2026-04-29): Placement validation is for the + // DoStepUp use case (prevents climbing through walls by + // stepping up onto ground beyond a tall wall). For the + // "maintain contact during normal movement" use case (called + // from TransitionalInsert's contact-recovery branch), the + // Placement check is over-strict — slight wall overlap from + // a prior wall-slide makes Placement reject, then the caller + // returns Collided (L.2.3e) and the player gets stuck near + // walls without ever touching them. + // + // ACE Transition.cs:731-741 runs Placement here unconditionally, + // but ACE's pre-step-down state is cleaner — we have residual + // wall-slide artifacts that make Placement misfire. + if (!runPlacement) + return true; + // Placement validation: can we actually stand here? var savedInsert = sp.InsertType; sp.InsertType = InsertType.Placement; From e44d24cec687d820d66d5a6c1e4afa31d30f7185 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 19:28:30 +0200 Subject: [PATCH 09/17] =?UTF-8?q?fix(physics):=20L.2.3i=20=E2=80=94=20use?= =?UTF-8?q?=20FloorZ=20(not=20LandingZ)=20for=20OnWalkable=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel research agents converged on this bug. acdream's ValidateTransition was setting OnWalkable based on `Normal.Z >= LandingZ` (0.087, ~85° permissive) instead of `Normal.Z >= FloorZ` (0.664, ~49° strict). Effect: a 60° roof slope (normal.Z = 0.5) was being marked OnWalkable, letting the player walk freely up surfaces retail blocks. Per retail PhysicsObj::is_valid_walkable (acclient_2013_pseudo_c.txt:277180-277193) and ACE PhysicsObj.cs:2861, the canonical "walkable" predicate is FloorZ. LandingZ is the more permissive threshold used only in airborne→ground transitions (Path 6 Collide handler) where we want to accept a brief landing before the next frame's strict FloorZ check rejects the surface and CliffSlide kicks in. Three sites fixed: 1. Step-down branch's `zVal` initial value (was unconditional LandingZ; now `oi.GetWalkableZ()` returns FloorZ when OnWalkable, LandingZ otherwise — matches retail's transitional_insert step-down OK branch at acclient_2013_pseudo_c.txt:273258-273265). 2. ValidateTransition's live-contact OnWalkable test (LandingZ → FloorZ). 3. ValidateTransition's LastKnown-fallback OnWalkable test (LandingZ → FloorZ). After this commit: - Walking horizontally INTO a 60° slope: step-up's WalkableAllowance is FloorZ (when OnWalkable), find_walkable rejects the slope's polygon, step-up fails, StepUpSlide. Player blocked from climbing. - Jumping ONTO a 60° roof: Path 6 still uses LandingZ (correct, we want to land), so the player lands. Next frame: ValidateTransition sees Normal.Z=0.5 < FloorZ → OnWalkable cleared. Player is Contact but not OnWalkable. Currently this leaves them STUCK on the roof (no CliffSlide yet to push them off). That's still better than walking up the roof. Full slide-off-roof + edge-slide-along-balcony behaviors require porting CliffSlide + PrecipiceSlide + adding Walkable polygon reference — that's Phase L.4 (~12-20h, sketched out by both research agents). This commit unblocks the worst of the steep-walk-up behavior while the bigger port is being designed. Test count 825/825 still pass. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 22 ++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index cc6944aa..64947898 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -623,7 +623,15 @@ public sealed class Transition if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) { - float zVal = PhysicsGlobals.LandingZ; + // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable, + // LandingZ when not. acdream was unconditionally LandingZ — + // which let the step-down probe accept steep polygons + // (~85° permissive instead of ~49° strict) as the player's + // new contact, contributing to the "walks up steep roofs" + // bug. Per CTransition::transitional_insert step-down OK + // branch (acclient_2013_pseudo_c.txt:273258-273265) and + // ACE Transition.cs:849-856. + float zVal = oi.GetWalkableZ(); float stepDownHeight = oi.StepDownHeight; sp.WalkableAllowance = zVal; sp.SaveCheckPos(); @@ -1638,7 +1646,14 @@ public sealed class Transition ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater; oi.State |= ObjectInfoState.Contact; - if (ci.ContactPlane.Normal.Z >= PhysicsGlobals.LandingZ) + // L.2.3i (2026-04-29): use FloorZ (~49°) NOT LandingZ (~85°) + // for the OnWalkable test. The previous LandingZ check was + // far too permissive — a 60° roof (normal.Z=0.5) was being + // marked OnWalkable, letting the player walk up steep slopes + // they shouldn't reach. Retail's PhysicsObj::is_valid_walkable + // uses FloorZ unconditionally (acclient_2013_pseudo_c.txt:277180-277193, + // ACE PhysicsObj.cs:2861). + if (ci.ContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) oi.State |= ObjectInfoState.OnWalkable; else oi.State &= ~ObjectInfoState.OnWalkable; @@ -1651,7 +1666,8 @@ public sealed class Transition // last-known plane. Without this, every wall bump dropped the // player into the falling animation for one frame. oi.State |= ObjectInfoState.Contact; - if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.LandingZ) + // L.2.3i: same FloorZ correction as the live-contact branch. + if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) oi.State |= ObjectInfoState.OnWalkable; else oi.State &= ~ObjectInfoState.OnWalkable; From d4c3f947d2354e1b93ee2c58a8045257ba964ae6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 21:28:56 +0200 Subject: [PATCH 10/17] docs(physics): Phase L.2 movement collision conformance plan Formalize Phase L.2 as the active holistic movement/collision program, align the roadmap and architecture docs, file tactical physics follow-ups, and refresh collision memory away from rewrite-from-zero guidance. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 135 +++++++++ docs/architecture/acdream-architecture.md | 267 +++++++++--------- docs/plans/2026-04-11-roadmap.md | 69 ++++- ...26-04-29-movement-collision-conformance.md | 208 ++++++++++++++ memory/project_collision_port.md | 203 +++++++------ .../project_movement_collision_conformance.md | 49 ++++ 6 files changed, 689 insertions(+), 242 deletions(-) create mode 100644 docs/plans/2026-04-29-movement-collision-conformance.md create mode 100644 memory/project_movement_collision_conformance.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9b67b514..c47b778f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,6 +177,141 @@ missing is the plugin-API surface. --- +## #30 — AutonomousPosition contact byte is too often grounded + +**Status:** OPEN +**Severity:** HIGH +**Filed:** 2026-04-29 +**Component:** physics / net / movement + +**Description:** Outbound movement can claim grounded contact even when the +local resolver result is uncertain or airborne. `AutonomousPosition.Build` +defaults `lastContact` to 1, and the app path needs an audit to ensure +`ResolveResult.IsOnGround` is what reaches the wire. + +**Root cause / status:** Tracked under Phase L.2b. This can make ACE accept +movement that is not actually retail-valid and can hide edge/step-down bugs. + +**Files:** `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`, +`src/AcDream.Core.Net/Messages/MoveToState.cs`, +`src/AcDream.App/Input/PlayerMovementController.cs`. + +**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. + +**Acceptance:** Ground contact byte is derived from the current resolved +movement result for both autonomous heartbeat and movement-state sends. Tests +cover grounded, airborne, and failed-transition cases. + +--- + +## #31 — Low outdoor cell id can go stale after transition movement + +**Status:** OPEN +**Severity:** HIGH +**Filed:** 2026-04-29 +**Component:** physics / cells / movement + +**Description:** Local movement can cross 24m outdoor cell boundaries while +the low cell id used for outbound full cell id remains stale. This can combine +correct landblock high bits with the wrong outdoor-cell low byte. + +**Root cause / status:** Tracked under Phase L.2e. `CELLARRAY`, +`CObjCell::find_cell_list`, adjacent-cell checks, and low-cell ownership are +not fully ported. + +**Files:** `src/AcDream.Core/Physics/PhysicsEngine.cs`, +`src/AcDream.Core/Physics/TransitionTypes.cs`, +`src/AcDream.App/Input/PlayerMovementController.cs`. + +**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. + +**Acceptance:** Crossing a 24m outdoor-cell seam updates the local resolved +cell id and the outbound full cell id. Tests cover intra-landblock seams and +landblock-edge seams. + +--- + +## #32 — Retail edge-slide / cliff-slide / precipice-slide incomplete + +**Status:** OPEN +**Severity:** HIGH +**Filed:** 2026-04-29 +**Component:** physics / collision + +**Description:** When walking along walls, roof edges, cliff edges, or failed +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. 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`. + +**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. + +--- + +## #33 — Live entity collision shape collapses to one cylinder + +**Status:** OPEN +**Severity:** MEDIUM +**Filed:** 2026-04-29 +**Component:** physics / entities + +**Description:** Live world entities do not yet use exact retail +`CSphere` / `CCylSphere` shape semantics. Several paths collapse the entity to +a simplified root-centered cylinder or fallback radius, which is not enough for +retail object and creature collision parity. + +**Root cause / status:** Tracked under Phase L.2d. Requires auditing object +shape extraction, `Setup.Radius` fallback, building object identity, and live +entity broadphase records against named retail. + +**Files:** `src/AcDream.Core/Physics/CollisionPrimitives.cs`, +`src/AcDream.Core/Physics/ShadowObjectRegistry.cs`, +`src/AcDream.Core/Physics/PhysicsDataCache.cs`. + +**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. + +**Acceptance:** Live object collision uses the appropriate retail sphere or +cylsphere data where available. Tests prove at least one multi-shape object and +one live creature case no longer use the single-cylinder fallback. + +--- + +## #34 — Missing routine local/server correction diagnostic + +**Status:** OPEN +**Severity:** MEDIUM +**Filed:** 2026-04-29 +**Component:** physics / net / diagnostics + +**Description:** The client needs an opt-in diagnostic that logs local predicted +position/contact/cell, outbound movement fields, server `UpdatePosition` echo, +and correction delta. Current correction visibility is too focused on portal +arrival and not enough on normal walking. + +**Root cause / status:** Tracked under Phase L.2a/L.2b. Without this probe, +ACE's tolerance can hide local collision divergence. + +**Files:** `src/AcDream.Core.Net/WorldSession.cs`, +`src/AcDream.App/Input/PlayerMovementController.cs`, +`src/AcDream.App/Rendering/GameWindow.cs`. + +**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. + +**Acceptance:** With the diagnostic enabled, a walking session logs local +resolved placement, outbound cell/contact fields, server echo placement, and +correction delta in a grep-friendly format. + +--- + ## #2 — Lightning visual mismatch (sky PES path disproved) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 56b888d9..5f134da7 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -100,67 +100,129 @@ crib-sheet version. --- -## Project Structure (target) +## Project Structure (current + target) ``` src/ AcDream.Core/ Layer 2-4: no GL, no Silk.NET, pure logic Physics/ - PhysicsBody.cs ← ported from decompiled (done) - CollisionPrimitives.cs ← ported from decompiled (done) - MotionInterpreter.cs ← ported from decompiled (done) - AnimationSequencer.cs ← ported from decompiled (done) - CellBsp.cs ← TODO: port from decompiled - Transition.cs ← TODO: port from decompiled - TerrainSurface.cs ← verified against ACME (done) + PhysicsBody.cs -> body state / integration foundation (done) + CollisionPrimitives.cs -> retail primitive helpers (partial, active) + MotionInterpreter.cs -> motion state machine (done, still L.1 polish) + AnimationSequencer.cs -> animation playback + root-motion data (done, L.1 active) + TerrainSurface.cs -> triangle-aware terrain contact (done) + BSPQuery.cs -> partial retail BSP dispatcher (active in L.2) + TransitionTypes.cs -> SpherePath / CollisionInfo / transition helpers (active in L.2) + PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active) + ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active) + PhysicsEngine.cs -> ResolveWithTransition active player path + CellBsp.cs -> not a first-class runtime owner yet (L.2e) World/ - GameEntity.cs ← TODO: unified entity (replaces scattered state) - WorldState.cs ← TODO: owns all entities - CellTracker.cs ← TODO: per-entity cell management - SceneryGenerator.cs ← verified against decompiled (done) - LandblockLoader.cs ← done + GameEntity.cs -> target unified entity, not current reality + WorldState.cs -> target entity owner + CellTracker.cs -> target per-entity cell management + SceneryGenerator.cs -> verified against decompiled (done) + LandblockLoader.cs -> done Terrain/ - LandblockMesh.cs ← verified against ACME (done) - TerrainBlending.cs ← verified against ACME (done) + LandblockMesh.cs -> verified against ACME (done) + TerrainBlending.cs -> verified against ACME (done) Meshing/ - GfxObjMesh.cs ← cross-checked against ACME (done) - SetupMesh.cs ← cross-checked (done) + GfxObjMesh.cs -> cross-checked against ACME (done) + SetupMesh.cs -> cross-checked (done) Textures/ - SurfaceDecoder.cs ← done + SurfaceDecoder.cs -> done Dat/ - MotionResolver.cs ← done (move here from Meshing/) + MotionResolver.cs -> done (target move from Meshing/) AcDream.Core.Net/ Layer 2: networking - WorldSession.cs ← done (wire-compatible with ACE) - NetClient.cs ← done - Messages/ ← done (CreateObject, MoveToState, etc.) + WorldSession.cs -> done (wire-compatible with ACE) + NetClient.cs -> done + Messages/ -> done (CreateObject, MoveToState, etc.) AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces - IAcDreamPlugin.cs ← done - IPluginHost.cs ← done - IGameState.cs ← done - IEvents.cs ← done + IAcDreamPlugin.cs -> done + IPluginHost.cs -> done + IGameState.cs -> done + IEvents.cs -> done AcDream.App/ Layer 1 + Layer 4 wiring Rendering/ - GameWindow.cs ← TODO: thin down to GL calls only - TerrainRenderer.cs ← done - StaticMeshRenderer.cs ← done - TextureCache.cs ← done - ChaseCamera.cs ← done - FlyCamera.cs ← done + GameWindow.cs -> still owns too much runtime wiring + TerrainRenderer.cs -> done + StaticMeshRenderer.cs -> done + TextureCache.cs -> done + ChaseCamera.cs -> done + FlyCamera.cs -> done Streaming/ - StreamingController.cs ← done - GpuWorldState.cs ← done + StreamingController.cs -> done + GpuWorldState.cs -> done Input/ - PlayerMovementController.cs ← done (uses ported physics) + PlayerMovementController.cs -> active movement driver Plugins/ - AppPluginHost.cs ← done + AppPluginHost.cs -> done ``` --- -## GameEntity: The Unified Entity (TODO — the big refactor) +## Movement And Collision Architecture + +Phase L.2 is the current organizing program for physics, collision, +boundaries, buildings, sliding, cell ownership, movement packets, and server +authority. Detailed plan: `docs/plans/2026-04-29-movement-collision-conformance.md`. + +The active player movement spine is: + +```text +InputDispatcher / PlayerMovementController + -> MotionInterpreter + local body prediction + -> PhysicsEngine.ResolveWithTransition + -> TransitionTypes + BSPQuery + ShadowObjectRegistry + -> ResolveResult contact/cell state + -> MoveToState / AutonomousPosition outbound messages + -> WorldSession server echo or correction handling +``` + +What exists and is active: + +- `PhysicsEngine.ResolveWithTransition` is the path used for local player + collision resolution. +- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by + the transition path. +- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, + transition validation, step-up/down, contact-plane handling, and partial + slide behavior. +- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data from DATs. +- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and + buildings. +- `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain + Z" descriptions are historical B.3 language, not current architecture. + +What remains incomplete: + +- `CELLARRAY`, `CObjCell::find_cell_list`, adjacent-cell checks, and low outdoor + cell id updates across 24m seams. +- `cell_bsp` / `CellBSP` as the authoritative runtime owner for indoor and + building collision. +- Building portal transit and normal walking through building entry/exit + boundaries. +- Full retail `edge_slide`, `cliff_slide`, `precipice_slide`, and `NegPolyHit` + dispatch behavior. +- Exact `CSphere` / `CCylSphere` object-shape parity, especially for live + entities that currently collapse to a simplified cylinder fallback. +- Routine local/server correction diagnostics. ACE accepting a position is a + compatibility signal, not proof of fine retail collision parity. + +Ownership by phase: + +- B.3 is shipped MVP history: first resolver foundation and tests. +- L.1 owns animation/motion parity, including root-motion coupling. +- L.2 owns the movement/collision conformance stack listed above. +- G.3 owns dungeon streaming and portal-space delivery after L.2e gives it + trustworthy cell/building boundaries. + +--- + +## GameEntity: The Unified Entity (target refactor) Currently, entity state is scattered across: - `WorldEntity` (position, rotation, mesh refs) @@ -198,21 +260,21 @@ public sealed class GameEntity { Motion.ApplyCurrentMovement(); // set velocity from motion state Physics.UpdateObject(dt); // integrate position - // TODO: Transition.FindValidPosition // collision resolve - Cell.UpdateCell(Physics.Position); // check cell transitions + PhysicsEngine.ResolveWithTransition(); // current L.2 collision spine + Cell.UpdateCell(Physics.Position); // target: retail cell ownership Animation.Advance(dt); // advance animation frames RebuildMeshRefs(); // compute per-part transforms } } ``` -Every entity in the world — player, NPC, monster, lifestone, door, chest — -is a `GameEntity`. The renderer iterates them and draws. The plugin API -exposes them as `WorldEntitySnapshot`. GameWindow becomes thin. +Target state: every entity in the world — player, NPC, monster, lifestone, +door, chest — becomes a `GameEntity`. The renderer iterates them and draws. +The plugin API exposes them as `WorldEntitySnapshot`. GameWindow becomes thin. --- -## Per-Frame Update Order (matches retail) +## Per-Frame Update Order (current runtime) ``` 1. Network tick @@ -224,15 +286,16 @@ exposes them as `WorldEntitySnapshot`. GameWindow becomes thin. create terrain + scenery GameEntities 3. Input tick (player mode only) - └── Read WASD/mouse → MotionInterpreter.DoMotion → + └── InputDispatcher scopes → PlayerMovementController → + MotionInterpreter/body prediction → ResolveWithTransition → send MoveToState/AutonomousPosition to server -4. Entity tick (ALL entities, 30Hz fixed step) - └── For each GameEntity: entity.Update(dt) - This runs: motion → physics → collision → cell → animation +4. Entity / animation tick + └── Current code still has scattered world/entity state. L.1 owns + animation parity; L.2 owns movement/collision conformance. 5. Render tick - └── For each GameEntity: read MeshRefs, draw + └── Read current entity mesh refs, draw TerrainRenderer.Draw, StaticMeshRenderer.Draw (frustum cull, translucency pass, etc.) @@ -248,89 +311,26 @@ exposes them as `WorldEntitySnapshot`. GameWindow becomes thin. --- -## Execution Plan: How to Get There +## Roadmap Model -### Phase R1: GameEntity Refactor (the foundation) -**Goal:** Replace the scattered entity state with unified GameEntity. +The old R1-R8 architecture sequence was a useful early refactor sketch, but it +is no longer the execution plan. The strategic source of truth is now +`docs/plans/2026-04-11-roadmap.md`, with per-phase details in `docs/plans/` +and `docs/superpowers/specs/`. -1. Create `GameEntity` class in `AcDream.Core/World/` -2. Move `AnimatedEntity` fields into `GameEntity.Animation` -3. Move `WorldEntity` fields into `GameEntity.Physics` + position -4. Move `_entitiesByServerGuid` into `WorldState` -5. Move animation tick from `GameWindow.TickAnimations` into `GameEntity.Update` -6. GameWindow.OnRender reads `GameEntity.MeshRefs` instead of `WorldEntity.MeshRefs` +Current movement/collision ownership: -**Test:** Everything looks the same as before. No visual change. +- **B.3** is shipped MVP history: first collision resolver foundation. +- **L.1** owns animation/motion parity, including root-motion coupling. +- **L.2** owns movement and collision conformance: + `docs/plans/2026-04-29-movement-collision-conformance.md`. +- **G.3** owns dungeon streaming and portal-space delivery after L.2e lands + trustworthy `cell_bsp`, `CELLARRAY`, adjacent-cell checks, and building + entry/exit boundaries. -### Phase R2: Thin GameWindow -**Goal:** GameWindow does only GL calls + input dispatch. - -1. Extract entity creation from `OnLiveEntitySpawned` into `WorldState.SpawnEntity` -2. Extract motion updates from `OnLiveMotionUpdated` into `WorldState.UpdateMotion` -3. Extract player movement from the giant OnUpdate block into `PlayerController` -4. GameWindow.OnUpdate calls: network.Tick → streaming.Tick → input.Tick → worldState.Tick → render - -**Test:** Everything works the same. GameWindow.cs drops from 2000+ to ~500 lines. - -### Phase R3: CellBSP + Wall Collision -**Goal:** Entities can't walk through walls. - -1. Port CellBSP from decompiled code (sphere_intersects_cell) -2. Port Transition.FindValidPosition (swept sphere collision) -3. Wire into GameEntity.Update between physics and cell tracking -4. Indoor transitions become correct (wall stops you, doorway lets you through) - -**Test:** Walk into building wall → stopped. Walk through doorway → enter. - -### Phase R4: Complete Animation State Machine -**Goal:** Every animation works for every entity type. - -1. Port full MotionInterp.PerformMovement from decompiled (all 5 movement types) -2. Port Links table resolution for smooth transitions -3. Port idle modifiers (fidgets) -4. Jump animation (wire jump motion command through the pipeline) - -**Test:** All entity types animate correctly. Transitions are smooth. - -### Phase R5: Lighting from Retail -**Goal:** Sun, ambient, per-vertex lighting match retail. - -1. Port AdjustPlanes (FUN_00532440) — face normals + per-vertex lighting -2. Extract global lighting constants from decompiled DAT addresses -3. Replace hardcoded shader constants with ported values - -**Test:** Side-by-side with retail client shows matching lighting. - -### Phase R6: Server Compliance -**Goal:** ACE accepts all movement, no rubber-banding. - -1. Server-authoritative Z (trust server position, local is cosmetic) -2. Proper MoveToState with full RawMotionState packing -3. Keepalive ping (5s idle) -4. Graceful session management - -**Test:** Walk around, other clients see smooth movement. No ACE errors. - -### Phase R7: Interaction -**Goal:** Click NPCs, open doors, pick up items, chat. - -1. Use/UseWithTarget game actions -2. Door open animation (server sends UpdateMotion → animate) -3. Chat send/receive -4. Basic inventory (pickup/drop) - -**Test:** Open a door, talk to an NPC, send a chat message. - -### Phase R8: Plugin API Completion -**Goal:** Plugins can observe and control everything. - -1. IGameState exposes all GameEntity fields -2. IEvents fires for all world changes -3. IActions covers: Move, Cast, Use, Say, Pickup, Drop -4. IPacketPipeline hooks all 4 stages -5. Lua macro engine (MoonSharp) ships as a built-in plugin - -**Test:** A Lua script auto-loots gems. A C# plugin displays an overlay. +The GameEntity / thin GameWindow refactor remains a valid target architecture, +but it is not a prerequisite for L.2. Do not resurrect old R1-R8 phase numbers +for new work; add or update roadmap phases instead. --- @@ -339,11 +339,12 @@ exposes them as `WorldEntitySnapshot`. GameWindow becomes thin. ``` For every AC-specific behavior: -1. DECOMPILE → Find the function in docs/research/decompiled/ -2. CROSS-CHECK → Verify against ACE + ACME + holtburger +0. GREP NAMED → Search docs/research/named-retail/ by class::method +1. FALLBACK → Use older docs/research/decompiled/ chunks only if needed +2. CROSS-CHECK → Verify against ACE + ACME + holtburger where relevant 3. PSEUDOCODE → Translate to readable pseudocode 4. PORT → Faithful C# translation -5. TEST → Conformance test against decompiled golden values +5. TEST → Conformance test against retail/decomp golden values 6. INTEGRATE → Surgical wiring into the existing system 7. VERIFY → Visual + functional test ``` @@ -359,9 +360,9 @@ For acdream-specific code (renderer, plugin API, streaming): | Domain | Primary Oracle | Secondary | |--------|---------------|-----------| -| Physics/collision | Decompiled acclient.exe | ACE Physics/ | -| Animation | Decompiled + ACE Animation/ | — | -| Terrain | ACME ClientReference.cs | Decompiled | +| Physics/collision | `docs/research/named-retail/` | ACE Physics/ + older decompiled chunks | +| Animation | `docs/research/named-retail/` + ACE Animation/ | — | +| Terrain | ACME ClientReference.cs | named retail / older decompiled chunks | | Rendering | WorldBuilder (Silk.NET) | ACViewer | | Protocol | holtburger | AC2D | | Server behavior | ACE | — | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c4cb6ee8..a75fd3fe 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-04-11 after Phase 6, 7.1, 9.1, 9.2 landed. +**Status:** Living document. Updated 2026-04-29 for Phase L.2 movement/collision conformance planning. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -31,7 +31,7 @@ | A.1 | Streaming landblock loader — runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS`), camera-centered offline / player-centered live, hysteresis-based unloads, pending-spawn list for late CreateObject events | Live ✓ | | A.2 | Frustum culling — per-landblock AABB test (Gribb-Hartmann), terrain + static-mesh renderers skip culled landblocks, perf overlay in window title | Visual ✓ | | A.3 | Background net receive thread — dedicated daemon thread buffers UDP into Channel, render thread drains | Visual ✓ | -| B.3 | Physics collision engine — TerrainSurface (heightmap Z), CellSurface (indoor floor polygon projection), PhysicsEngine (resolver with step-height + cell transitions). Populated from streaming pipeline. | Tests ✓ | +| B.3 | Physics MVP resolver foundation — terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests ✓ | | B.2 | Player movement mode — Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live ✓ | | D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual ✓ | | E.1 | Motion-hook expansion — AnimationSequencer fires all 27 hook types per crossed frame; PosFrames root motion + vel/omega exposure; IAnimationHookSink + AnimationHookRouter fan-out | Tests ✓ | @@ -94,7 +94,7 @@ Plus polish that doesn't get its own phase number: **Sub-pieces:** - **✓ SHIPPED — B.1 — Outbound ack pump.** Shipped as Phase 4.9 — per-packet ACK_SEQUENCE, not periodic. Server no longer drops idle clients. - **✓ SHIPPED — B.2 — Player movement mode.** Tab-toggled WASD ground walking with collision-resolved outdoor terrain, walk/run/idle/turn-right animations, third-person chase camera, outbound MoveToState (0xF61C) + AutonomousPosition (0xF753) server messages, portal entry works. Outdoor→indoor transition disabled for MVP (CellSurface floor polygons too aggressive without portal-based detection). Minor polish remaining: strafe animation, turn-left animation. Spec: `docs/superpowers/specs/2026-04-12-player-movement-design.md`. -- **✓ SHIPPED — B.3 — Physics collision engine.** TerrainSurface (heightmap bilinear Z), CellSurface (indoor floor polygon projection via barycentric interpolation), PhysicsEngine (top-level resolver with step-height enforcement, outdoor↔indoor cell transitions, gravity reporting). Populated from streaming pipeline. 16 unit tests with fake data. Spec: `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md`. +- **✓ SHIPPED — B.3 — Physics MVP resolver foundation.** Terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. This shipped enough foundation for outdoor walking and early portal experiments, but it is not the complete retail collision system. Current conformance work lives under **Phase L.2 — Movement & Collision Conformance**. Spec history: `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md`. - **B.4 — `Use` / `UseWithTarget` / `PickUp`.** Outbound interaction messages. Drives opening doors, looting, talking to vendors. - **B.5 — Chat.** `SendTell`, `SendChat` outbound + receive/display inbound (display side depends on Phase D.1). @@ -204,7 +204,7 @@ Research: R9 + R12 + R13. - **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys. - **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload. -- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. See `r09-dungeon-portal-space.md`. +- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on L.2e** for trustworthy `cell_bsp`, indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`. **Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight. @@ -318,6 +318,11 @@ queues, speed scaling, and PosFrame root motion. **Plan of record:** `docs/plans/animation-system-audit.md`. +**Coupling to L.2:** L.1 owns animation/motion parity. L.2 owns collision, +contact truth, movement packets, and server-visible placement. They meet where +root motion or observer movement changes the predicted body path; any such +change must keep both phase plans in sync. + **Sub-pieces:** - **L.1a — Audit & inventory.** Map retail named-decomp evidence, ACE cross-references, existing acdream hook points, and current gaps for each @@ -349,6 +354,55 @@ queues, speed scaling, and PosFrame root motion. --- +### Phase L.2 — Movement & Collision Conformance + +**Status:** ACTIVE. + +**Goal:** make acdream's movement and collision behavior retail-faithful across +terrain, buildings, walls, roof edges, cell seams, portal boundaries, outbound +movement packets, and server correction. This is the holistic bucket for the +work previously scattered across B.3 physics follow-ups, L.1 motion coupling, +and G.3 dungeon/portal ownership. + +**Plan of record:** `docs/plans/2026-04-29-movement-collision-conformance.md`. + +**Current foundation:** `PhysicsEngine.ResolveWithTransition`, +`BSPQuery`, `TransitionTypes`, `PhysicsDataCache`, and +`ShadowObjectRegistry` exist and are active. They are partial retail ports and +diagnostic scaffolding, not yet the final collision system. + +**Sub-lanes:** +- **L.2a — Truth & diagnostics.** Local placement/contact/cell logs, object-hit + probes, correction-delta diagnostics, retail-observer capture workflow, and + real-DAT fixture capture. +- **L.2b — Movement wire/contact authority.** Fix outbound contact truth, + full-cell id handling, packet cadence, and routine server correction handling. +- **L.2c — Transition parity: edge/slide/neg-poly.** Port and test retail + `edge_slide`, `cliff_slide`, `precipice_slide`, step-up/down slide, and + `NegPolyHit` dispatch behavior. +- **L.2d — Shape fidelity: sphere/cylsphere/building objects.** Finish + `CSphere` / `CCylSphere` parity, live-entity object shapes, building object + collision identity, and `Setup.Radius` fallback audit. +- **L.2e — Cell ownership: outdoor seams, `CELLARRAY`, `cell_bsp`.** Update + low outdoor cell id across 24m seams, port adjacent-cell checks, activate + `cell_bsp`, and hand G.3 a trustworthy building/portal boundary model. +- **L.2f — Real-DAT and live retail-observer conformance.** Promote synthetic + tests to real-world fixtures and verify local acdream view plus retail + observer view. ACE accepting a position is a compatibility check, not proof + of fine-grained retail collision parity. + +**Acceptance:** +- A developer can trace the active movement path: input/motion -> body + prediction -> `ResolveWithTransition` -> contact/cell result -> outbound + packets -> server echo/correction. +- Buildings, edge-slide, wall-slide, cell seams, packet authority, and dungeon + portal ownership each have an L.2 lane. +- Every AC-specific algorithm port cites named retail decomp, or a documented + fallback when named retail lacks the body. +- `dotnet build` and `dotnet test` are green for each implementation slice. + +--- + ### Phase J — Long-tail (deferred / low-priority) Not detailed here; each gets its own brainstorm when it becomes relevant. @@ -431,7 +485,10 @@ port in any phase — no separate listing here. | Holtburg sign half-buried | **5 FIXED** ✓ | | Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ (5×5 default, `ACDREAM_STREAM_RADIUS` to tune) | | Frame hitch crossing landblock boundary | **Phase A.3** (synchronous loader for now; async returns when DatCollection is thread-safe) | -| Walking around doesn't move me on the server | **Phase B.3 FIXED** ✓ | +| Walking around doesn't move me on the server | **Phase B.2/B.3 FIXED** ✓ for coarse server movement; fine retail collision parity is **Phase L.2** | +| Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** | +| Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** | +| Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** | | Can't talk to NPCs | **Phase H.3** (emote scripts + dialogs) | | Can't open a door | **Phase F** (object-use action) | | Portals render as a rotating black disk | **Phase E.3** (particle system) | @@ -444,7 +501,7 @@ port in any phase — no separate listing here. | Combat doesn't show in the chat log | **I.7 FIXED** ✓ | | Accented character names show as `?` or garbled | **I.5 FIXED** ✓ (Windows-1252 codec) | | No sound | **Phase E.2** | -| Dungeons / foundry interior missing | **Phase G.3** | +| Dungeons / foundry interior missing | **Phase G.3** after **L.2e** cell/building ownership | | Can't fight monsters | **Phase F.3** (combat math + damage) | | Can't cast spells | **Phase F.4** | | No inventory panel | **Phase F.2 + F.5** | diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md new file mode 100644 index 00000000..f31acca0 --- /dev/null +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -0,0 +1,208 @@ +# Phase L.2 - Movement & Collision Conformance + +**Status:** ACTIVE planning document, created 2026-04-29. +**Roadmap owner:** Phase L.2 in `docs/plans/2026-04-11-roadmap.md`. +**Scope:** player movement prediction, retail collision/transition behavior, +building boundaries, edge and wall sliding, cell ownership, outbound movement +packets, and server-correction diagnostics. + +## Purpose + +Phase B.3 shipped the first usable physics foundation: terrain contact, +basic resolver behavior, streaming-populated collision inputs, and enough +movement wire support to walk on ACE. That was not the complete retail +collision system. + +Phase L.2 is the conformance program that turns that foundation into a +retail-faithful movement stack. It is the single organizing bucket for work +that otherwise looks scattered across B.3 physics, L.1 animation/motion, and +G.3 dungeon/portal space. + +The active movement spine is: + +```text +input + motion command + -> local body prediction / root-motion source + -> PhysicsEngine.ResolveWithTransition + -> TransitionTypes + BSPQuery + ShadowObjectRegistry contact/cell result + -> MoveToState / AutonomousPosition outbound packets + -> server echo or correction diagnostics +``` + +Live ACE accepting a position, or the absence of visible rubber-banding, is +not proof of retail collision parity. ACE can tolerate coarse or locally +invalid fine-grained movement. L.2 therefore requires retail-decomp evidence, +synthetic conformance tests, real-DAT fixtures, and live retail-observer checks. + +## Current Foundation + +Already active in acdream: + +- `PhysicsEngine.ResolveWithTransition` is the local player collision path. +- `BSPQuery` contains a partial retail-style BSP dispatcher and step/contact + logic. +- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, + transition validation, step-up/down, and partial slide behavior. +- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data from DATs. +- `ShadowObjectRegistry` gives the resolver a broadphase over nearby world + objects. +- `TerrainSurface` uses triangle-aware terrain sampling rather than the older + bilinear placeholder. + +Known incomplete areas: + +- Full `CELLARRAY` ownership and `CObjCell::find_cell_list` / adjacent-cell + checks are not ported. +- `cell_bsp` / `CellBSP` is not fully represented as a first-class runtime + owner. +- Building entry/exit and indoor/outdoor portal transit are not solved by the + normal walking path. +- Retail `edge_slide`, `cliff_slide`, and `precipice_slide` behavior is + incomplete; failed edge/step-down cases often hard-block instead of sliding. +- `NegPolyHit` handling is a stub relative to the retail transition dispatch. +- Live entities collapse to a simplified cylinder shape; exact retail + sphere/cylsphere and object-shape behavior is not yet matched. +- Outbound contact/cell fields can be too optimistic, so server agreement does + not necessarily mean local conformance. + +## Lane Model + +L.2 uses five working lanes. The roadmap breaks them into six sub-lanes because +real-DAT and live verification spans every lane. + +| Lane | Owns | Roadmap slice | +|---|---|---| +| Diagnostics | Truth probes, dump flags, server-correction logging, retail observer harness | L.2a, L.2f | +| Transition parity | `FindTransitionalPosition`, step-up/down, edge-slide, cliff-slide, precipice-slide, `NegPolyHit` dispatch | L.2c | +| Geometry fidelity | `CSphere`, `CCylSphere`, object shape extraction, building object collision, walkable polygon context | L.2d | +| Cell/building ownership | outdoor cell seams, low-cell id updates, `CELLARRAY`, `cell_bsp`, building entry/exit | L.2e | +| Movement/network authority | contact byte, full cell id, MoveToState / AutonomousPosition cadence, root motion vs velocity prediction, correction response | L.2b, L.2f | + +## Roadmap Slices + +### L.2a - Truth & Diagnostics + +Goal: make every bad movement outcome explainable. + +- Add targeted diagnostics for local placement, contact plane, object hit, + water, cell id, outbound packet fields, server echo, and correction delta. +- Keep diagnostics opt-in via env vars and devtools panels. +- Record enough data for side-by-side retail-observer runs without drowning + normal logs. +- Build real-DAT fixture capture for known walls, building ledges, rooftops, + slopes, landblock seams, and dungeon entrances. + +### L.2b - Movement Wire / Contact Authority + +Goal: stop sending movement packets that claim more certainty than the local +resolver has earned. + +- Fix outbound contact state so `AutonomousPosition` and `MoveToState` do not + always claim grounded contact. +- Track local result cell id and outbound full cell id separately from the last + server placement until correction proves they agree. +- Reconcile packet cadence with retail/holtburger references. +- Wire routine server correction handling and diagnostics, not only portal + reseating. + +### L.2c - Transition Parity: Edge / Slide / Neg-Poly + +Goal: match retail movement at walls, roof edges, step boundaries, and +precipices. + +- Port and test `edge_slide`, `cliff_slide`, `precipice_slide`, and + `step_up_slide` behavior from named retail. +- Preserve walkable polygon context needed for precipice/edge decisions. +- Replace `NegPolyHit` stub behavior with the retail dispatch path. +- Confirm the user-visible rule: walk-only motion is blocked by step, + edge, walkable, and collision rules; jumping clears `OnWalkable` and only + succeeds when the airborne path actually clears geometry. + +### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects + +Goal: object collisions use retail shape semantics, not one simplified +fallback. + +- Finish `CSphere` / `CCylSphere` parity for static and live objects. +- Stop treating all live entities as one root-centered cylinder. +- Preserve enough building identity to model `CBuildingObj` collision and + `bldg_check` behavior. +- Audit `Setup.Radius` and cylinder fallback behavior against retail before + relying on them for conformance. + +### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp + +Goal: the resolver knows which cell owns the movement and which adjacent cells +must be checked. + +- Update low outdoor cell id across 24m cell boundaries and landblock seams. +- Port the retail adjacent-cell search: `find_cell_list`, `check_other_cells`, + and `adjust_check_pos`. +- Promote `cell_bsp` / `CellBSP` from partial data to active runtime owner. +- Hand G.3 a trustworthy building/portal boundary so dungeon streaming is not + asked to solve collision ownership after the fact. + +### L.2f - Real-DAT and Live Retail-Observer Conformance + +Goal: prove the stack against real terrain/building/cell data and what a retail +client sees when observing acdream. + +- Add real-DAT fixtures for representative movement cases. +- Use retail client observer runs to verify motion packets, animation/movement + coupling, and server-visible placement. +- Treat ACE acceptance as a coarse compatibility check only. +- Require conformance notes in tests or research docs for every AC-specific + algorithm ported under L.2. + +## Named Retail Anchors + +Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. +Struct source: `docs/research/named-retail/acclient.h`. +Address lookup: `docs/research/named-retail/symbols.json`. + +Use these names before falling back to older `docs/research/decompiled/` +chunks: + +- `CTransition::find_transitional_position` - `0x0050BDF0` +- `CTransition::transitional_insert` - `0x0050B6F0` +- `CTransition::step_up` - `0x0050B610` +- `CTransition::step_down` - `0x0050B2A0` +- `CTransition::edge_slide` - `0x0050B3D0` +- `CTransition::cliff_slide` - `0x0050A6D0` +- `SPHEREPATH::step_up_slide` - `0x0050C3B0` +- `SPHEREPATH::precipice_slide` - `0x0050CC80` +- `SPHEREPATH::adjust_check_pos` - `0x0050CC00` +- `CTransition::adjust_offset` - `0x0050A370` +- `CTransition::check_other_cells` - `0x0050AE50` +- `CPhysicsObj::is_valid_walkable` - `0x0050F530` +- `CObjCell::find_cell_list` - `0x0052B4E0` +- `CBuildingObj::find_building_collisions` +- `CCellStruct::point_in_cell` +- `CCellStruct::sphere_intersects_cell` +- `CCellStruct::box_intersects_cell` +- `CCylSphere::intersects_sphere` +- `CSphere::intersects_sphere` +- `CSphere::slide_sphere` + +## Implementation Order + +1. Land L.2a diagnostics first. Do not make another physics change blind. +2. Fix L.2b packet/contact truth so logs and server echoes describe reality. +3. Port L.2c transition parity in narrow slices with named-retail citations and + conformance tests. +4. Improve L.2d shape fidelity where transition parity depends on object + contact semantics. +5. Land L.2e cell/building ownership before G.3 dungeon/portal work relies on + indoor/outdoor walking. +6. Promote each synthetic case to L.2f real-DAT and live observer coverage. + +## Acceptance + +- A developer can name the active movement path and the current incomplete + pieces without reading old chat logs. +- `dotnet build` and `dotnet test` stay green for each implementation slice. +- Every AC-specific port cites named retail decomp or a documented fallback. +- Real-DAT fixtures cover buildings, walls, roof edges, outdoor seams, and at + least one dungeon/building entrance path before L.2 is marked shipped. +- Retail observer view and acdream local view both agree on contact, position, + and movement state for the representative cases. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index dc1fcd1a..1866b886 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -1,131 +1,128 @@ -# Collision System Port — Status and Plan +# Collision System Port - Status and Plan -## Current State (2026-04-14) +## Current State (2026-04-29) -The collision system has been patched multiple times but does NOT match -retail. The user has explicitly requested a **full faithful port** of -the retail collision system — no shortcuts, no simplifications. +The collision system is no longer a pure placeholder and should not be treated +as "delete everything and start over." A partial retail transition port exists: -## What Went Wrong +- `PhysicsEngine.ResolveWithTransition` is the active player movement resolver. +- `BSPQuery` contains a partial retail-style BSP dispatcher. +- `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`, + transition, step, contact, and partial slide logic. +- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data. +- `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. +- `TerrainSurface` uses triangle-aware terrain contact. -Instead of porting the decompiled code line-by-line (as CLAUDE.md -mandates), I wrote simplified approximations: -- Static overlap instead of swept-sphere FindTimeOfCollision -- Custom FindObjCollisions instead of porting Sphere.IntersectsSphere -- Custom BSP query instead of porting BSPTree.find_collisions dispatcher -- Ad-hoc push-out instead of proper SlideSphere crease-projection -- Incremental patches that don't address root architectural issues +This foundation is useful, but it is not complete retail collision parity. +The project now tracks the remaining work as Phase L.2 - Movement & Collision +Conformance: -Each patch fixed one symptom but introduced new edge cases. The result -is a patchwork that handles ~60-70% of cases but fails on the rest. +- Plan: `docs/plans/2026-04-29-movement-collision-conformance.md` +- Roadmap owner: `docs/plans/2026-04-11-roadmap.md` +- Tactical follow-ups: `docs/ISSUES.md` #30-#34 -## What Must Happen Next +## Durable Lesson -**Delete the existing collision code and start fresh.** Port from ACE's -complete C# implementation, cross-referencing the decompiled code for -ground truth. ACE has the ENTIRE system already in C#: +Do not guess at AC physics, movement packets, terrain/cell ownership, or +collision constants. The previous patchwork failures came from simplified +approximations: -### Files to port from ACE (in order): +- static overlap instead of swept-sphere transition behavior +- custom object collision instead of retail `CSphere` / `CCylSphere` +- incomplete BSP dispatch +- ad-hoc push-out instead of retail slide / edge / precipice handling +- server "no rubber-band" treated as proof of local collision correctness -1. **Sphere.cs** — `IntersectsSphere` (FUN_005387c0), `SlideSphere` (both variants), `StepSphereUp`, `StepSphereDown`, `LandOnSphere`, `CollideWithPoint`, `CollidesWithSphere` +The named retail decomp is now the primary source. Search +`docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method` +before using older decompiled chunks or reference repos. -2. **BSPTree.cs** — `find_collisions` (6-path dispatcher) +## Active Approach -3. **BSPNode.cs** — `sphere_intersects_poly` (tree traversal with movement), `find_walkable`, `hits_walkable`, `sphere_intersects_solid` +Continue by conformance lanes rather than rewriting blindly: -4. **BSPLeaf.cs** — leaf-level polygon tests +1. **Truth & diagnostics (L.2a).** Add local placement/contact/cell, + object-hit, outbound-packet, server echo, and correction-delta probes. +2. **Movement wire/contact authority (L.2b).** Fix contact byte and full-cell + truth before using ACE acceptance as evidence. +3. **Transition parity (L.2c).** Port edge-slide, cliff-slide, + precipice-slide, step-up/down slide, and `NegPolyHit` dispatch. +4. **Shape fidelity (L.2d).** Finish `CSphere` / `CCylSphere` semantics, + live-entity shapes, and building object identity. +5. **Cell ownership (L.2e).** Port `CELLARRAY`, `find_cell_list`, + `check_other_cells`, `adjust_check_pos`, low-cell updates, and `cell_bsp`. +6. **Real-DAT and live observer conformance (L.2f).** Promote every synthetic + case to real-world fixtures and retail-observer checks. -5. **Polygon.cs** — `pos_hits_sphere`, `adjust_sphere_to_plane`, `check_walkable` +## What To Preserve -6. **Transition.cs** — `FindTransitionalPosition`, `TransitionalInsert`, `StepUp`, `StepDown`, `ValidateTransition`, `AdjustOffset` +- `CollisionPrimitives.cs` low-level helpers, while auditing remaining shape + gaps against named retail. +- `PhysicsDataCache.cs` DAT-backed collision data loading. +- `ShadowObjectRegistry.cs` broadphase concept. +- `TransitionTypes.cs` data structures and partial transition port. +- `BSPQuery.cs` partial dispatcher as the current porting surface. +- `PhysicsBody.cs`, `MotionInterpreter.cs`, and `PlayerWeenie.cs` foundations. -7. **SpherePath.cs** — `SetCheckPos`, `AddOffsetToCheckPos`, `CacheLocalSpaceSphere`, `SetCollide`, `SetWalkable`, `SetNegPolyHit` +## Known Gaps -8. **CollisionInfo.cs** — `SetContactPlane`, `SetSlidingNormal`, `SetCollisionNormal` +- Full `CELLARRAY` and adjacent-cell ownership are missing. +- `cell_bsp` is not yet a first-class runtime owner. +- Building portal transit and building entry/exit collision are incomplete. +- `edge_slide`, `cliff_slide`, `precipice_slide`, and `NegPolyHit` behavior are + incomplete. +- Live entity shape fidelity is simplified. +- Outbound movement contact/cell fields can be overconfident. +- Routine local/server correction diagnostics are missing. -9. **ObjectInfo.cs** — `ValidateWalkable` +## Retail Anchors -10. **LandCell.cs** — `FindEnvCollisions` (outdoor terrain) +Primary: -11. **EnvCell.cs** — `FindEnvCollisions` (indoor BSP) +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- `docs/research/named-retail/acclient.h` +- `docs/research/named-retail/symbols.json` -12. **ObjCell.cs** — `FindObjCollisions`, `find_cell_list` +Key names: -### ACE source locations: -- `references/ACE/Source/ACE.Server/Physics/Sphere.cs` -- `references/ACE/Source/ACE.Server/Physics/BSP/BSPTree.cs` -- `references/ACE/Source/ACE.Server/Physics/BSP/BSPNode.cs` -- `references/ACE/Source/ACE.Server/Physics/BSP/BSPLeaf.cs` -- `references/ACE/Source/ACE.Server/Physics/Polygon.cs` -- `references/ACE/Source/ACE.Server/Physics/Transition.cs` -- `references/ACE/Source/ACE.Server/Physics/SpherePath.cs` -- `references/ACE/Source/ACE.Server/Physics/Collision/CollisionInfo.cs` -- `references/ACE/Source/ACE.Server/Physics/Collision/ObjectInfo.cs` +- `CTransition::find_transitional_position` +- `CTransition::transitional_insert` +- `CTransition::step_up` +- `CTransition::step_down` +- `CTransition::edge_slide` +- `CTransition::cliff_slide` +- `SPHEREPATH::step_up_slide` +- `SPHEREPATH::precipice_slide` +- `SPHEREPATH::adjust_check_pos` +- `CTransition::check_other_cells` +- `CObjCell::find_cell_list` +- `CPhysicsObj::is_valid_walkable` +- `CBuildingObj::find_building_collisions` +- `CCellStruct::sphere_intersects_cell` +- `CCylSphere::intersects_sphere` +- `CSphere::intersects_sphere` +- `CSphere::slide_sphere` -### Decompiled ground truth (named-retail is now primary, 2026-04-25): -- **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — grep for - `BSPTree::`, `BSPNode::`, `BSPLeaf::`, `CPolygon::`, `CCylSphere::`, - `Transition::`, `CPhysicsObj::`, `SpherePath::` to find named bodies. -- **`docs/research/named-retail/acclient.h`** — verbatim retail struct - layouts for the BSP / Sphere / Transition types. -- **`docs/research/named-retail/symbols.json`** — name↔address lookup. -- `docs/research/decompiled/chunk_00530000.c` — older Ghidra fallback for - BSP / Polygon / Sphere collision (FUN_xxx names). -- `docs/research/decompiled/chunk_00500000.c` — older Ghidra fallback for - PhysicsObj / transition callers. -- `docs/research/acclient_function_map.md` — hand-curated cross-port index - (ACE / ACME mappings + struct-offset notes). +Older fallback: -### Pseudocode (already written): -- `docs/research/transition_pseudocode.md` — full system documented +- `docs/research/decompiled/chunk_00530000.c` +- `docs/research/decompiled/chunk_00500000.c` +- `docs/research/acclient_function_map.md` -## What to Keep +Reference aids: -- `CollisionPrimitives.cs` — 9 low-level functions already faithfully ported from decompiled code. These are CORRECT and match retail. -- `PhysicsDataCache.cs` — GfxObj/Setup/CellStruct physics data loading from dats. Correct. -- `ShadowObjectRegistry.cs` — cell-based spatial index. Correct concept, may need refinement. -- `TransitionTypes.cs` data structures — SpherePath, CollisionInfo, ObjectInfo, PhysicsGlobals. Mostly correct, may need field additions. -- `PhysicsBody.cs` — Euler integration. Correct. -- `MotionInterpreter.cs` — Motion state machine. Correct. -- `PlayerWeenie.cs` — Run/Jump formulas. Correct. +- `references/ACE/Source/ACE.Server/Physics/` +- `references/holtburger/` for movement wire behavior +- `references/AC2D/` for the older client-side movement packet reference -## What to Replace +## Mandatory Workflow -- `BSPQuery.cs` — replace with faithful port of BSPTree/BSPNode/BSPLeaf -- `TransitionTypes.cs` Transition methods — replace FindTransitionalPosition, TransitionalInsert, FindEnvCollisions, FindObjCollisions, SlideSphere, AdjustOffset with faithful ports -- `PhysicsEngine.ResolveWithTransition` — may need restructuring +For every AC-specific function: -## Approach (MANDATORY — per CLAUDE.md) - -For EVERY function: - -1. **GREP NAMED FIRST, then DECOMPILE FALLBACK.** Search the named - retail decomp first: `grep -n "ClassName::Method" docs/research/named-retail/acclient_2013_pseudo_c.txt`. - For struct layouts: `grep -n "^struct ClassName" docs/research/named-retail/acclient.h`. - Only if the named pseudo-C lacks a function (rare), fall back to the - older `docs/research/decompiled/` chunks via the function map at - `docs/research/acclient_function_map.md`. - -2. **CROSS-REFERENCE ACE.** Read ACE's C# port of the same function. - ACE provides naming and structure. Note any differences. - -3. **WRITE PSEUDOCODE.** Translate the decompiled C into readable - pseudocode BEFORE porting to C#. Add to - `docs/research/collision_port_pseudocode.md`. - -4. **PORT FAITHFULLY.** Translate pseudocode to C# line-by-line. - Same variable names, same control flow, same boundary conditions. - Do NOT "improve" or "simplify" the algorithm. - -5. **VERIFY.** When ACE and the decompiled code disagree, the - decompiled code wins. Document the difference. - -### Execution order: - -1. Sphere collision (Sphere.cs) — FUN_005387c0 and sub-functions -2. BSP tree (BSPTree/Node/Leaf) — find_collisions dispatcher -3. Polygon tests (Polygon.cs) — pos_hits_sphere, adjust_sphere_to_plane -4. Transition orchestrator (Transition.cs) — FindTransitionalPosition -5. Cell collision (LandCell/EnvCell/ObjCell) — FindEnvCollisions, FindObjCollisions -6. Wire into PhysicsEngine.ResolveWithTransition -7. Test: terrain → indoor walls → objects → step-up → every object type +1. Grep named retail first. +2. Cross-reference ACE / holtburger / AC2D where relevant. +3. Write readable pseudocode before porting. +4. Port faithfully; do not simplify. +5. Add conformance tests. +6. Integrate surgically into the active L.2 lane. +7. Verify with synthetic tests, real-DAT fixtures, and live observer evidence. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md new file mode 100644 index 00000000..f835b071 --- /dev/null +++ b/memory/project_movement_collision_conformance.md @@ -0,0 +1,49 @@ +# Movement & Collision Conformance Crib + +## Phase + +Active phase: **L.2 - Movement & Collision Conformance**. + +Plan: `docs/plans/2026-04-29-movement-collision-conformance.md`. + +Roadmap: `docs/plans/2026-04-11-roadmap.md`. + +## One-Sentence Framing + +B.3 shipped the MVP resolver foundation; L.2 is the holistic conformance +program for physics, collision, buildings, edge/wall sliding, cell ownership, +movement packets, and server correction. + +## Active Movement Spine + +```text +InputDispatcher / PlayerMovementController + -> MotionInterpreter + local body prediction + -> PhysicsEngine.ResolveWithTransition + -> TransitionTypes + BSPQuery + ShadowObjectRegistry + -> ResolveResult contact/cell state + -> MoveToState / AutonomousPosition outbound messages + -> WorldSession server echo or correction diagnostics +``` + +## Lane Ownership + +- L.2a: truth probes, diagnostics, fixture capture. +- L.2b: movement wire/contact truth, cell id on packets, correction handling. +- L.2c: transition parity, edge-slide, cliff-slide, precipice-slide, + `NegPolyHit`. +- L.2d: `CSphere` / `CCylSphere`, live entity shapes, building object identity. +- L.2e: outdoor seams, `CELLARRAY`, `find_cell_list`, adjacent-cell checks, + `cell_bsp`, building entry/exit boundaries. +- L.2f: real-DAT fixtures and live retail-observer conformance. + +## Non-Negotiables + +- Grep named retail before changing AC-specific physics or movement behavior. +- Do not treat ACE accepting a position as proof of retail collision parity. +- Do not reintroduce rewrite-from-zero collision guidance. Continue the partial + retail port by L.2 lanes. +- G.3 dungeon/portal delivery waits on L.2e for trustworthy cell/building + ownership. +- L.1 animation work must coordinate with L.2 when root motion or observer + movement changes the predicted body path. From 3be0c8b7c7ae64177b8170d614d6c230a07781fa Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 21:52:53 +0200 Subject: [PATCH 11/17] fix(physics): #30 #34 L.2a movement truth diagnostics Pass explicit grounded/airborne contact bytes from MovementResult into MoveToState and AutonomousPosition, and add ACDREAM_DUMP_MOVE_TRUTH logging for outbound movement plus player UpdatePosition echoes. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 76 +++++---------- .../project_movement_collision_conformance.md | 8 ++ src/AcDream.App/Rendering/GameWindow.cs | 97 ++++++++++++++++++- .../Messages/AutonomousPositionTests.cs | 17 ++++ .../Messages/MoveToStateTests.cs | 24 +++++ 5 files changed, 166 insertions(+), 56 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c47b778f..90dbc064 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,33 +177,6 @@ missing is the plugin-API surface. --- -## #30 — AutonomousPosition contact byte is too often grounded - -**Status:** OPEN -**Severity:** HIGH -**Filed:** 2026-04-29 -**Component:** physics / net / movement - -**Description:** Outbound movement can claim grounded contact even when the -local resolver result is uncertain or airborne. `AutonomousPosition.Build` -defaults `lastContact` to 1, and the app path needs an audit to ensure -`ResolveResult.IsOnGround` is what reaches the wire. - -**Root cause / status:** Tracked under Phase L.2b. This can make ACE accept -movement that is not actually retail-valid and can hide edge/step-down bugs. - -**Files:** `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`, -`src/AcDream.Core.Net/Messages/MoveToState.cs`, -`src/AcDream.App/Input/PlayerMovementController.cs`. - -**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. - -**Acceptance:** Ground contact byte is derived from the current resolved -movement result for both autonomous heartbeat and movement-state sends. Tests -cover grounded, airborne, and failed-transition cases. - ---- - ## #31 — Low outdoor cell id can go stale after transition movement **Status:** OPEN @@ -285,33 +258,6 @@ one live creature case no longer use the single-cylinder fallback. --- -## #34 — Missing routine local/server correction diagnostic - -**Status:** OPEN -**Severity:** MEDIUM -**Filed:** 2026-04-29 -**Component:** physics / net / diagnostics - -**Description:** The client needs an opt-in diagnostic that logs local predicted -position/contact/cell, outbound movement fields, server `UpdatePosition` echo, -and correction delta. Current correction visibility is too focused on portal -arrival and not enough on normal walking. - -**Root cause / status:** Tracked under Phase L.2a/L.2b. Without this probe, -ACE's tolerance can hide local collision divergence. - -**Files:** `src/AcDream.Core.Net/WorldSession.cs`, -`src/AcDream.App/Input/PlayerMovementController.cs`, -`src/AcDream.App/Rendering/GameWindow.cs`. - -**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. - -**Acceptance:** With the diagnostic enabled, a walking session logs local -resolved placement, outbound cell/contact fields, server echo placement, and -correction delta in a grep-friendly format. - ---- - ## #2 — Lightning visual mismatch (sky PES path disproved) @@ -494,6 +440,28 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the # Recently closed +## #34 — [DONE 2026-04-29] Missing routine local/server correction diagnostic + +**Closed:** 2026-04-29 +**Commit:** `(this commit)` +**Resolution:** Added `ACDREAM_DUMP_MOVE_TRUTH=1`, which logs local resolved +position/contact/cell, outbound movement fields, server `UpdatePosition` echo, +and local/server correction delta for the player in grep-friendly +`move-truth OUT` / `move-truth ECHO` lines. + +--- + +## #30 — [DONE 2026-04-29] AutonomousPosition contact byte is too often grounded + +**Closed:** 2026-04-29 +**Commit:** `(this commit)` +**Resolution:** `GameWindow` now derives the movement contact byte from +`MovementResult.IsOnGround` and passes it explicitly to both `MoveToState.Build` +and `AutonomousPosition.Build`. Added packet tests proving both builders encode +an explicit airborne contact byte. + +--- + ## #27 — [DONE 2026-04-26] Cloud meshes appeared missing or faint vs retail **Closed:** 2026-04-26 diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index f835b071..8dd54d34 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -47,3 +47,11 @@ InputDispatcher / PlayerMovementController ownership. - L.1 animation work must coordinate with L.2 when root motion or observer movement changes the predicted body path. + +## Shipped Slices + +- 2026-04-29: L.2a/L.2b first diagnostic slice. `ACDREAM_DUMP_MOVE_TRUTH=1` + logs `move-truth OUT` for outbound `MoveToState` / `AutonomousPosition` and + `move-truth ECHO` for player `UpdatePosition` echoes, including local/server + delta. `GameWindow` now passes explicit grounded/airborne contact bytes from + `MovementResult.IsOnGround` to both movement packet builders. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b2bd9677..7a343a7c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -409,6 +409,8 @@ public sealed class GameWindow : IDisposable private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm; private static readonly bool DevToolsEnabled = Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1"; + private static readonly bool DumpMoveTruthEnabled = + Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOVE_TRUTH") == "1"; // Phase I.3 — real ICommandBus for live sessions. Constructed when // the live session spins up (so SendChatCmd handlers can close over @@ -464,6 +466,19 @@ public sealed class GameWindow : IDisposable private uint? _playerCurrentAnimCommand; private float _playerCurrentAnimSpeed = 1f; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character + private MovementTruthOutbound? _lastMovementTruthOutbound; + + private readonly record struct MovementTruthOutbound( + string Kind, + uint Sequence, + System.DateTime TimeUtc, + System.Numerics.Vector3 LocalWorldPosition, + uint LocalCellId, + System.Numerics.Vector3 WirePosition, + uint WireCellId, + bool IsOnGround, + byte ContactByte, + System.Numerics.Vector3 Velocity); // K-fix7 (2026-04-26): server-authoritative Run + Jump skill values // received from PlayerDescription. -1 = "not yet received, fall back @@ -3076,6 +3091,7 @@ public sealed class GameWindow : IDisposable 0f); var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); + DumpMovementTruthServerEcho(update, worldPos); // Capture the pre-update render position for the soft-snap residual // calculation below. Assign entity.Position to the server truth up @@ -4853,6 +4869,7 @@ public sealed class GameWindow : IDisposable uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu); var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z); var wireRot = YawToAcQuaternion(_playerController.Yaw); + byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; if (result.MotionStateChanged) { @@ -4885,7 +4902,10 @@ public sealed class GameWindow : IDisposable instanceSequence: _liveSession.InstanceSequence, serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence); + forcePositionSequence: _liveSession.ForcePositionSequence, + contactLongJump: contactByte); + DumpMovementTruthOutbound( + "MTS", seq, result, wirePos, wireCellId, contactByte); _liveSession.SendGameAction(body); } @@ -4900,7 +4920,10 @@ public sealed class GameWindow : IDisposable instanceSequence: _liveSession.InstanceSequence, serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence); + forcePositionSequence: _liveSession.ForcePositionSequence, + lastContact: contactByte); + DumpMovementTruthOutbound( + "AP", seq, result, wirePos, wireCellId, contactByte); _liveSession.SendGameAction(body); } @@ -4924,6 +4947,76 @@ public sealed class GameWindow : IDisposable } } + private void DumpMovementTruthOutbound( + string kind, + uint sequence, + AcDream.App.Input.MovementResult result, + System.Numerics.Vector3 wirePosition, + uint wireCellId, + byte contactByte) + { + if (!DumpMoveTruthEnabled) return; + + var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero; + _lastMovementTruthOutbound = new MovementTruthOutbound( + kind, + sequence, + System.DateTime.UtcNow, + result.Position, + result.CellId, + wirePosition, + wireCellId, + result.IsOnGround, + contactByte, + velocity); + + Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}")); + } + + private void DumpMovementTruthServerEcho( + AcDream.Core.Net.WorldSession.EntityPositionUpdate update, + System.Numerics.Vector3 serverWorldPosition) + { + if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return; + + var now = System.DateTime.UtcNow; + var localPosition = _playerController?.Position; + var localCellId = _playerController?.CellId; + var deltaLocal = localPosition.HasValue + ? serverWorldPosition - localPosition.Value + : (System.Numerics.Vector3?)null; + + string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-"; + string localCellText = localCellId.HasValue + ? System.FormattableString.Invariant($"0x{localCellId.Value:X8}") + : "-"; + string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-"; + string deltaLocalLen = deltaLocal.HasValue + ? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}") + : "-"; + + string lastText = "-"; + if (_lastMovementTruthOutbound is { } last) + { + var deltaOut = serverWorldPosition - last.LocalWorldPosition; + var ageMs = (now - last.TimeUtc).TotalMilliseconds; + lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}"); + } + + string state = _playerController?.State.ToString() ?? "-"; + string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-"; + + Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}")); + } + + private static string Fmt(System.Numerics.Vector3 v) => + System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})"); + + private static string FmtCmd(uint? command) => + command.HasValue + ? System.FormattableString.Invariant($"0x{command.Value:X8}") + : "-"; + /// /// Convert our internal yaw (math convention: 0=+X East, PI/2=+Y North) /// to AC's quaternion heading convention. diff --git a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs index 7552d809..630cae6f 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs @@ -105,6 +105,23 @@ public class AutonomousPositionTests Assert.Equal(56, body.Length); } + [Fact] + public void Build_UsesExplicitAirborneContactByte() + { + var body = AutonomousPosition.Build( + gameActionSequence: 7, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0, + lastContact: 0); + + Assert.Equal(0, body[52]); + } + [Fact] public void Build_ContainsIdentityRotation_AfterPosition() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs index 8070cca5..53f95d60 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs @@ -142,6 +142,30 @@ public class MoveToStateTests Assert.Equal(0, body.Length % 4); } + [Fact] + public void Build_UsesExplicitAirborneContactByte() + { + var body = MoveToState.Build( + gameActionSequence: 7, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0, + contactLongJump: 0); + + Assert.Equal(0, body[56]); + } + [Fact] public void Build_WithHoldKey_IncludesHoldKeyFlag() { From 9fea9b13ad1abd901d883b1ac44f9f90cbb88000 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 22:00:30 +0200 Subject: [PATCH 12/17] fix(physics): #31 update outdoor cell id during transition movement --- docs/ISSUES.md | 39 +++++---------- .../project_movement_collision_conformance.md | 4 ++ src/AcDream.Core/Physics/PhysicsEngine.cs | 43 ++++++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 4 ++ .../Physics/PhysicsEngineTests.cs | 48 +++++++++++++++++++ 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 90dbc064..8c970871 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,33 +177,6 @@ missing is the plugin-API surface. --- -## #31 — Low outdoor cell id can go stale after transition movement - -**Status:** OPEN -**Severity:** HIGH -**Filed:** 2026-04-29 -**Component:** physics / cells / movement - -**Description:** Local movement can cross 24m outdoor cell boundaries while -the low cell id used for outbound full cell id remains stale. This can combine -correct landblock high bits with the wrong outdoor-cell low byte. - -**Root cause / status:** Tracked under Phase L.2e. `CELLARRAY`, -`CObjCell::find_cell_list`, adjacent-cell checks, and low-cell ownership are -not fully ported. - -**Files:** `src/AcDream.Core/Physics/PhysicsEngine.cs`, -`src/AcDream.Core/Physics/TransitionTypes.cs`, -`src/AcDream.App/Input/PlayerMovementController.cs`. - -**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`. - -**Acceptance:** Crossing a 24m outdoor-cell seam updates the local resolved -cell id and the outbound full cell id. Tests cover intra-landblock seams and -landblock-edge seams. - ---- - ## #32 — Retail edge-slide / cliff-slide / precipice-slide incomplete **Status:** OPEN @@ -440,6 +413,18 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the # Recently closed +## #31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement + +**Closed:** 2026-04-29 +**Commit:** `(this commit)` +**Resolution:** `ResolveWithTransition` now refreshes outdoor cell ownership +from the resolved world position while the sphere sweep runs. Intra-landblock +24m outdoor seams update the low cell id, and full-cell callers crossing a +landblock seam get the destination landblock prefix plus the correct outdoor +low cell. + +--- + ## #34 — [DONE 2026-04-29] Missing routine local/server correction diagnostic **Closed:** 2026-04-29 diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 8dd54d34..66cbfdbe 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -55,3 +55,7 @@ InputDispatcher / PlayerMovementController `move-truth ECHO` for player `UpdatePosition` echoes, including local/server delta. `GameWindow` now passes explicit grounded/airborne contact bytes from `MovementResult.IsOnGround` to both movement packet builders. +- 2026-04-29: L.2e first cell-ownership fix. `ResolveWithTransition` refreshes + outdoor cell ownership from world position during the sphere sweep, so 24m + outdoor seams update low cell ids and full-cell callers crossing landblock + seams get the destination landblock prefix plus the correct outdoor low cell. diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 4a934031..be2494b2 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -162,6 +162,38 @@ public sealed class PhysicsEngine return null; } + /// + /// Resolve the outdoor cell id that owns a world-space position. + /// Indoor ids are preserved because EnvCell ownership still comes from + /// portal/cell BSP state; outdoor ids are derived from the registered + /// landblock that currently contains the point. + /// + internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId) + { + if (fallbackCellId == 0) + return 0; + + uint fallbackLow = fallbackCellId & 0xFFFFu; + if (fallbackLow >= 0x0100u) + return fallbackCellId; + + foreach (var kvp in _landblocks) + { + var lb = kvp.Value; + float localX = worldPos.X - lb.WorldOffsetX; + float localY = worldPos.Y - lb.WorldOffsetY; + if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) + { + uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); + return (fallbackCellId & 0xFFFF0000u) == 0 + ? lowCellId + : (kvp.Key & 0xFFFF0000u) | lowCellId; + } + } + + return fallbackCellId; + } + /// /// Resolve an entity's movement from by /// applying (XY only) and computing the correct Z @@ -471,7 +503,10 @@ public sealed class PhysicsEngine bool onGround = ci.ContactPlaneValid || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); - return new ResolveResult(sp.CheckPos, sp.CheckCellId, onGround); + return new ResolveResult( + sp.CheckPos, + ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId), + onGround); } // Transition failed (e.g., stuck in corner, too many steps). @@ -483,6 +518,10 @@ public sealed class PhysicsEngine || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable) || isOnGround; - return new ResolveResult(sp.CheckPos, sp.CheckCellId != 0 ? sp.CheckCellId : cellId, partialOnGround); + uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; + return new ResolveResult( + sp.CheckPos, + ResolveOutdoorCellId(sp.CheckPos, partialCellId), + partialOnGround); } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 64947898..5b11b102 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -705,6 +705,10 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; + uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId); + if (resolvedOutdoorCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); + Vector3 footCenter = sp.GlobalSphere[0].Origin; float sphereRadius = sp.GlobalSphere[0].Radius; diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index d6e6e554..0f212fa4 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -190,6 +190,54 @@ public class PhysicsEngineTests Assert.True(result.Position.X > 192f); } + [Fact] + public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId() + { + var engine = MakeFlatEngine(terrainZ: 50f); + + var result = engine.ResolveWithTransition( + currentPos: new Vector3(23f, 10f, 50f), + targetPos: new Vector3(25f, 10f, 50f), + cellId: 0x0001u, + sphereRadius: 0.5f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: true); + + Assert.True(result.IsOnGround); + Assert.InRange(result.Position.X, 24.9f, 25.1f); + Assert.Equal(0x0009u, result.CellId); + } + + [Fact] + public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() + { + var engine = new PhysicsEngine(); + + var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); + engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(), + Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); + + var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); + engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(), + Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f); + + var result = engine.ResolveWithTransition( + currentPos: new Vector3(191f, 10f, 50f), + targetPos: new Vector3(193f, 10f, 50f), + cellId: 0xA9B40039u, + sphereRadius: 0.5f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: true); + + Assert.True(result.IsOnGround); + Assert.InRange(result.Position.X, 192.9f, 193.1f); + Assert.Equal(0xAAB40001u, result.CellId); + } + [Fact] public void Resolve_LeaveIndoorCell_TransitionsToOutdoor() { From 1ec40f2a4fc4c8ca6e279cb495ae09825efd6e47 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 07:40:43 +0200 Subject: [PATCH 13/17] fix(physics): #32 L.2c wire edge-slide movement flag --- docs/ISSUES.md | 9 ++++- .../project_movement_collision_conformance.md | 5 +++ .../Input/PlayerMovementController.cs | 8 +++- src/AcDream.App/Rendering/GameWindow.cs | 6 ++- src/AcDream.Core/Physics/TransitionTypes.cs | 40 ++++++++++++++----- .../Physics/PhysicsEngineTests.cs | 21 ++++++++++ 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8c970871..7112f33c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -179,7 +179,7 @@ missing is the plugin-API surface. ## #32 — Retail edge-slide / cliff-slide / precipice-slide incomplete -**Status:** OPEN +**Status:** IN-PROGRESS **Severity:** HIGH **Filed:** 2026-04-29 **Component:** physics / collision @@ -188,7 +188,12 @@ missing is the plugin-API surface. 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. Named retail anchors include +**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`. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 66cbfdbe..1fa54b84 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -59,3 +59,8 @@ InputDispatcher / PlayerMovementController outdoor cell ownership from world position during the sphere sweep, so 24m outdoor seams update low cell ids and full-cell callers crossing landblock seams get the destination landblock prefix plus the correct outdoor low cell. +- 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. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 2a832d0c..35daf3fc 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -428,12 +428,18 @@ public sealed class PlayerMovementController stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, body: _body, // persist ContactPlane across frames for slope tracking + // L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes + // EdgeSlide, and PhysicsObj.get_object_info copies that bit into + // OBJECTINFO. Keep it explicit here so edge/cliff handling runs + // under the same flag profile as retail player movement. + // // Commit C 2026-04-29 — local player is always IsPlayer. // The PK/PKLite/Impenetrable bits come from PlayerDescription's // PlayerKillerStatus property; not yet parsed (non-PK pair → walks // through other non-PK players, which is retail's default for // ACE's character creation defaults too). - moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer); + moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide); // Apply resolved position. _body.Position = resolveResult.Position; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7a343a7c..771c37d3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5856,7 +5856,11 @@ public sealed class GameWindow : IDisposable // branch zeroes the +Z offset every step (same bug // we hit on the local jump). isOnGround: !rm.Airborne, - body: rm.Body); // persist ContactPlane across frames for slope tracking + body: rm.Body, // persist ContactPlane across frames for slope tracking + // Retail default physics state includes EdgeSlide. + // Remote dead-reckoning should exercise the same + // edge/cliff branch as local movement. + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); rm.Body.Position = resolveResult.Position; if (resolveResult.CellId != 0) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 5b11b102..8bdaef6c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -345,6 +345,9 @@ public sealed class Transition public SpherePath SpherePath = new(); public CollisionInfo CollisionInfo = new(); + private static bool DumpEdgeSlideEnabled => + Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; + // ----------------------------------------------------------------------- // Public entry point // ----------------------------------------------------------------------- @@ -665,18 +668,19 @@ public sealed class Transition } } - // L.2.3e (2026-04-29): step-down failed — the move would put + // L.2c (2026-04-30): step-down failed — the move would put // the player off an edge with no walkable surface within reach. - // Retail's EdgeSlide (ACE Transition.cs:268-320) maps this to - // SetEdgeSlide(true, true, OK) which restores CheckPos to the - // saved (post-step) position — but our outer ValidateTransition - // accepts CheckPos as the new CurPos, defeating the intent. + // Retail's EdgeSlide path then needs either: + // - a steep contact plane for CliffSlide, or + // - SpherePath.Walkable polygon context for PrecipiceSlide. // - // Returning Collided here makes ValidateTransition revert to - // CurPos (pre-step) — the always-on retail "stop at edge" - // behavior. Confirmed against ACE Transition.cs:317 where - // EdgeSlide returns Collided when no walkable surface is - // found and the EdgeSlide flag is unset (player default). + // acdream does not yet preserve the full walkable polygon + // context from terrain/BSP step-down, so this is still the + // conservative stop-at-edge fallback. The diagnostic below is + // intentionally narrow: it tells the next L.2c slice whether + // we are missing precipice context, a steep contact plane, or + // merely the EdgeSlide flag. + DumpEdgeSlideStepDownFailed(stepDownHeight, zVal); sp.RestoreCheckPos(); return TransitionState.Collided; } @@ -689,6 +693,22 @@ public sealed class Transition return TransitionState.Slid; } + private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal) + { + if (!DumpEdgeSlideEnabled) return; + + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + + 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}")); + } + + private static string Fmt(Vector3 value) => + System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})"); + // ----------------------------------------------------------------------- // Environment collision — outdoor terrain // ----------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 0f212fa4..d6f08cfa 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -210,6 +210,27 @@ public class PhysicsEngineTests Assert.Equal(0x0009u, result.CellId); } + [Fact] + public void ResolveWithTransition_EdgeSlideFlag_AllowsNormalFlatMovement() + { + var engine = MakeFlatEngine(terrainZ: 50f); + + var result = engine.ResolveWithTransition( + currentPos: new Vector3(96f, 96f, 50f), + targetPos: new Vector3(98f, 96f, 50f), + cellId: 0x0025u, + sphereRadius: 0.5f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: true, + moverFlags: ObjectInfoState.EdgeSlide); + + Assert.True(result.IsOnGround); + Assert.InRange(result.Position.X, 97.9f, 98.1f); + Assert.Equal(0x0025u, result.CellId); + } + [Fact] public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() { From 261322b48e85f6b4203fd4ae4843ca186893ada4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 08:04:37 +0200 Subject: [PATCH 14/17] 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 7112f33c..6033efe9 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 f31acca0..90de9436 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 00000000..8ddc4c8f --- /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 1fa54b84..5db6326d 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 a0947d0f..b86ccc4f 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 be2494b2..22f48ef0 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 6a375069..caa54935 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 8bdaef6c..d48a0a01 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 d6f08cfa..79d6ac86 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 fb70cd42..7ceda8fd 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() { From a1c27b3afb3ae516f2833c0973c1d569fe270953 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 09:41:04 +0200 Subject: [PATCH 15/17] =?UTF-8?q?feat(physics):=20L.3a=20=E2=80=94=20wall-?= =?UTF-8?q?bounce=20velocity=20reflection=20on=20airborne=20hits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent research agents converged: retail's "bouncy walls" feel comes from CPhysicsObj::handle_all_collisions (acclient_2013_pseudo_c.txt: 282699-282715, ACE PhysicsObj.cs:2692-2697) which applies the canonical reflection v_new = v - (1 + e) * dot(v, n) * n to the body's velocity after every transition resolves. Player elasticity = 0.05 (5% bounce); INELASTIC_PS = 0x20000 zeros velocity entirely (used by spell projectiles). acdream had the data plumbed (PhysicsBody.Elasticity = 0.05 was already set, ci.CollisionNormal was being populated in 8+ code paths) but ResolveWithTransition discarded the normal before returning. Hence "sticky walls on jumps" — perpendicular velocity got removed by SlideSphere's geometric resolution, but never reflected back, so hitting a wall mid-jump zeroed forward motion entirely instead of producing a small push-back. Files: - PhysicsBody.cs: add PhysicsStateFlags.Inelastic = 0x20000. - ResolveResult.cs: surface CollisionNormalValid + CollisionNormal. - PhysicsEngine.cs:599-624: copy ci.CollisionNormal into ResolveResult before returning (both ok and partial paths). - PlayerMovementController.cs:445-503: after position commit, apply reflection per the retail formula. Inelastic → zero velocity; else → reflect with v += n * -(dot(v,n) * (e + 1)). apply_bounce rule (more conservative than retail by design): - Sledding: retail's strict rule — bounce unless both grounded. - Otherwise: bounce ONLY when both prev and now airborne. Suppress on landing (prev air, now ground) to avoid micro-bouncing on floor — the post-reflection upward Z defeats the controller's Velocity.Z<=0 landing-snap gate. Retail's elasticity 0.05 makes the artifact visually imperceptible there; acdream's per-frame architecture amplifies it. Tests: 1491 → 1491 still pass (existing AirborneFrames + WalkOffLedge tests confirmed the conservative apply_bounce rule keeps landings clean). Live verification needed: jump into a wall mid-air — should produce a visible bounce-back rather than sticking. Walking along corridor with side-clip should still slide. Landing should still settle without micro-bounce. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Input/PlayerMovementController.cs | 87 +++++++++++++++++++ src/AcDream.Core/Physics/PhysicsBody.cs | 24 +++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 64 +++++++++++++- src/AcDream.Core/Physics/ResolveResult.cs | 19 +++- 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 35daf3fc..64f9f5c8 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -444,6 +444,93 @@ public sealed class PlayerMovementController // Apply resolved position. _body.Position = resolveResult.Position; + // L.3a (2026-04-30): retail wall-bounce / velocity reflection. + // + // Retail's CPhysicsObj::handle_all_collisions runs after every + // SetPositionInternal. It reads the wall normal that the + // transition's slide computed and reflects the body's velocity: + // + // v_new = v - (1 + elasticity) * dot(v, n) * n + // + // This is what gives retail its "bouncy" feel — fast head-on + // jumps push the player back from the wall, glancing angles + // produce a small deflection. acdream's transition resolver + // SLID position correctly but never updated velocity, so the + // player kept driving into walls until the controller's input + // changed direction. Felt sticky / fragile. + // + // Suppression rule (apply_bounce): grounded movement on a wall + // SHOULDN'T bounce — sliding along a corridor is expected. Only + // airborne wall hits reflect. Mirrors retail's `var_10_1` guard + // and ACE PhysicsObj.cs:2656-2660 `apply_bounce`. + // + // Inelastic flag (spell projectiles, missiles) zeros velocity + // entirely instead of reflecting. The player never has it set. + // + // Sources: + // acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions) + // acclient.h:2834 (INELASTIC_PS = 0x20000) + // ACE PhysicsObj.cs:2656-2721 (line-for-line port) + // PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f + if (resolveResult.CollisionNormalValid) + { + bool prevOnWalkable = _body.OnWalkable; + bool nowOnWalkable = resolveResult.IsOnGround; + + // apply_bounce: bounce ONLY when the body stays airborne both + // before and after this step. That is: jumping into a wall + // mid-flight, hitting a ceiling, etc. Specifically NOT: + // + // - prev grounded + now grounded → wall-slide along corridor + // (bounce would feel sticky on every wall touch). + // - prev airborne + now grounded → terrain landing + // (terrain normal is mostly +Z; reflecting downward velocity + // would push the body upward and prevent the landing snap + // from firing — player perpetually micro-bouncing on the + // floor instead of resting). + // - prev grounded + now airborne → walked off cliff + // (gravity should take over, not lateral bounce). + // + // Sledding mode reverts to retail's broader rule (bounce + // unless both grounded), since sledding intentionally bounces + // off ramps. + // + // This is more conservative than retail's strict + // `!(prev && now && !sledding)` rule — retail bounces on + // landing too, but at elasticity 0.05 the visual effect is + // imperceptible there. acdream's per-frame architecture + // amplifies the artifact (the post-reflection upward Z + // defeats the controller's `Velocity.Z <= 0` landing-snap + // gate), so we suppress it on landing to avoid the + // micro-bounce death spiral. + bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding) + ? !(prevOnWalkable && nowOnWalkable) + : (!prevOnWalkable && !nowOnWalkable); + + if (applyBounce) + { + if (_body.State.HasFlag(PhysicsStateFlags.Inelastic)) + { + // Full stop on impact. Spell projectiles / missiles. + _body.Velocity = Vector3.Zero; + } + else + { + var v = _body.Velocity; + var n = resolveResult.CollisionNormal; + float dotVN = Vector3.Dot(v, n); + if (dotVN < 0f) + { + // Reflect the into-wall component back out. + // Player elasticity is 0.05 → 105% of perpendicular + // velocity reflects (subtle bounce). + float k = -(dotVN * (_body.Elasticity + 1f)); + _body.Velocity = v + n * k; + } + } + } + } + bool justLanded = false; if (resolveResult.IsOnGround) { diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 9c939150..2c2b20a8 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -31,6 +31,14 @@ public enum PhysicsStateFlags : uint ReportCollisions = 0x00000010, Gravity = 0x00000400, // bit 10 — apply downward gravity Hidden = 0x00001000, + /// + /// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834). + /// When set, wall-collisions zero the velocity instead of reflecting. + /// Used by spell projectiles and missiles that should embed/explode on + /// impact rather than bounce. The player NEVER has this flag set — + /// player wall-hits use the reflection path with elasticity ~0.05. + /// + Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS Sledding = 0x00800000, // bit 23 — sledding (modified friction) } @@ -44,6 +52,7 @@ public enum TransientStateFlags : uint None = 0, Contact = 0x00000001, // bit 0 — touching any surface OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface + Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition Active = 0x00000080, // bit 7 — object needs per-frame update } @@ -87,6 +96,9 @@ public sealed class PhysicsBody /// Ground contact-plane normal (+0x130/134/138). public Vector3 GroundNormal { get; set; } = Vector3.UnitZ; + /// Last wall/object sliding normal (retail transient Sliding state). + public Vector3 SlidingNormal { get; set; } + // ── persisted contact-plane state (retail PhysicsObj fields) ─────────── // // Retail's PhysicsObj carries its last contact plane FORWARD across frames. @@ -113,6 +125,18 @@ public sealed class PhysicsBody /// Whether the contact plane is a water surface (affects step behavior). public bool ContactPlaneIsWater { get; set; } + /// Whether the previous walkable polygon is available for edge slide. + public bool WalkablePolygonValid { get; set; } + + /// Most recent walkable polygon plane (world-space). + public System.Numerics.Plane WalkablePlane { get; set; } + + /// Most recent walkable polygon vertices (world-space). + public Vector3[]? WalkableVertices { get; set; } + + /// Up vector used by the most recent walkable polygon probe. + public Vector3 WalkableUp { get; set; } = Vector3.UnitZ; + /// Elasticity coefficient (+0xB0). public float Elasticity { get; set; } = 0.05f; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 22f48ef0..2fee39be 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -518,8 +518,29 @@ public sealed class PhysicsEngine body.ContactPlaneIsWater); } + // Retail CPhysicsObj::get_object_info also seeds SlidingNormal when + // transient_state has bit 2 set. This matters for one-step/frame hits: + // a wall collision at the end of one transition must project the next + // frame's movement along the wall instead of hard-stopping again. + if (body is not null + && body.TransientState.HasFlag(TransientStateFlags.Sliding) + && body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal); + } + transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight); + if (isOnGround && body is not null + && body.WalkablePolygonValid + && body.WalkableVertices is { Length: >= 3 }) + { + transition.SpherePath.SetWalkable( + body.WalkablePlane, + body.WalkableVertices, + body.WalkableUp); + } + bool ok = transition.FindTransitionalPosition(this); var sp = transition.SpherePath; @@ -548,8 +569,43 @@ public sealed class PhysicsEngine { body.ContactPlaneValid = false; } + + if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null) + { + body.WalkablePolygonValid = true; + body.WalkablePlane = sp.LastWalkablePlane; + body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone(); + body.WalkableUp = sp.LastWalkableUp; + } + else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid) + { + body.WalkablePolygonValid = false; + body.WalkableVertices = null; + } + + if (ci.SlidingNormalValid + && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + body.SlidingNormal = ci.SlidingNormal; + body.TransientState |= TransientStateFlags.Sliding; + } + else + { + body.SlidingNormal = Vector3.Zero; + body.TransientState &= ~TransientStateFlags.Sliding; + } } + // L.3a (2026-04-30): surface the wall normal so callers can apply + // retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions + // at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs: + // 2692-2697). The reflection itself is applied in + // PlayerMovementController after the position commit, gated on + // apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall + // hits bounce, grounded wall slides don't. + bool collisionNormalValid = ci.CollisionNormalValid; + Vector3 collisionNormal = ci.CollisionNormal; + if (ok) { bool onGround = ci.ContactPlaneValid @@ -558,7 +614,9 @@ public sealed class PhysicsEngine return new ResolveResult( sp.CheckPos, ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId), - onGround); + onGround, + collisionNormalValid, + collisionNormal); } // Transition failed (e.g., stuck in corner, too many steps). @@ -574,6 +632,8 @@ public sealed class PhysicsEngine return new ResolveResult( sp.CheckPos, ResolveOutdoorCellId(sp.CheckPos, partialCellId), - partialOnGround); + partialOnGround, + collisionNormalValid, + collisionNormal); } } diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index cc7fef83..63d18452 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -6,8 +6,25 @@ namespace AcDream.Core.Physics; /// Result of : the validated /// position after collision, the cell the entity ended up in, /// and whether they're standing on a surface. +/// +/// +/// L.3a (2026-04-30): added optional collision-normal fields so the +/// caller (typically ) +/// can apply retail's velocity-reflection bounce +/// (v_new = v - (1 + elasticity) * dot(v, n) * n) to the +/// PhysicsBody after the geometric resolve completes. ACE port mirror: +/// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2692-2697; +/// retail equivalent: CPhysicsObj::handle_all_collisions at +/// acclient_2013_pseudo_c.txt:282699-282715. +/// /// public readonly record struct ResolveResult( Vector3 Position, uint CellId, - bool IsOnGround); + bool IsOnGround, + /// True when a wall collision occurred during this resolve + /// and is meaningful. + bool CollisionNormalValid = false, + /// Outward surface normal of the wall the sphere hit. Used + /// by the velocity-reflection step. Pointing away from the wall. + Vector3 CollisionNormal = default); From 851e88364dbc5b87b49fe40c934c53a10165bb81 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 09:43:27 +0200 Subject: [PATCH 16/17] =?UTF-8?q?feat(net):=20L.3b=20=E2=80=94=20capture?= =?UTF-8?q?=20per-object=20friction=20+=20elasticity=20from=20CreateObject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to L.3a (a1c27b3) which ported the velocity-reflection bounce. Previously the CreateObject parser did `pos += 4` for both Friction and Elasticity floats — silently dropping the wire data so every entity got the PhysicsBody constructor default (0.05 elasticity, 0.5 friction). Server-set bouncier surfaces or stickier objects therefore felt identical to inert walls on collision. Inelastic projectiles via PhysicsState bit 0x20000 (already plumbed in Commit A) had no per- object elasticity to override. Now the parser captures the floats, surfaces them on Parsed + EntitySpawn, leaving the values at default (null) when their PhysicsDescriptionFlag bits aren't set. Subscribers (e.g., the remote-entity dead-reckoning path, future spell-projectile rendering) can apply them when they wire elasticity to PhysicsBody.Elasticity. The local player's PhysicsBody is constructed at controller init, not from a CreateObject — so this commit alone produces no user-visible local-player change. Effect lands when remote/projectile physics consume EntitySpawn.Elasticity. Files: - CreateObject.cs:284-294: declare friction + elasticity accumulators. - CreateObject.cs:467-487: parse floats instead of skipping. - CreateObject.cs:543-555: propagate to Parsed via both return paths. - WorldSession.cs:67-71: extend EntitySpawn record. - WorldSession.cs:665-668: pipe through to subscribers. Tests: 1491 still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core.Net/Messages/CreateObject.cs | 44 ++++++++++++++++--- src/AcDream.Core.Net/WorldSession.cs | 12 ++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index d574887b..4b68d428 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -120,7 +120,13 @@ public static class CreateObject ushort ServerControlSequence = 0, ushort ForcePositionSequence = 0, uint? PhysicsState = null, - uint? ObjectDescriptionFlags = null); + uint? ObjectDescriptionFlags = null, + // L.3b (2026-04-30): per-object friction + elasticity from the + // wire. Default to null when their PhysicsDescriptionFlag bits + // weren't set; subscribers fall back to PhysicsBody constructor + // defaults (0.05f elasticity, 0.5f friction). + float? Friction = null, + float? Elasticity = null); /// /// The relevant subset of the server-sent MovementData / @@ -286,6 +292,13 @@ public static class CreateObject // "ObjectDescriptionFlags" at the WeenieHeader trailer. uint? physicsState = null; uint? objectDescriptionFlags = null; + // L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded + // when their PhysicsDescriptionFlag bits are set. Default values + // come from PhysicsBody constructors; these overrides drive the + // velocity-reflection bounce magnitude per object (e.g., bouncier + // platforms vs. inert walls). + float? friction = null; + float? elasticity = null; try { @@ -453,8 +466,25 @@ public static class CreateObject objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; } - if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4; - if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4; + if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) + { + if (body.Length - pos < 4) return PartialResult(); + friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) + { + // L.3b (2026-04-30): capture instead of skipping. The wire + // float is the per-object elasticity used by the velocity- + // reflection bounce (CPhysicsObj::set_elasticity at + // acclient_2013_pseudo_c.txt:277817, clamped to [0, 0.1]). + // Was previously dropped — every object got the default + // 0.05f, so server-set bouncier surfaces felt identical to + // walls. + if (body.Length - pos < 4) return PartialResult(); + elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3 if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12; @@ -510,14 +540,18 @@ public static class CreateObject return new Parsed(guid, position, setupTableId, animParts, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, - physicsState, objectDescriptionFlags); + physicsState, objectDescriptionFlags, + friction, elasticity); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). Parsed PartialResult() => new( guid, position, setupTableId, animParts, textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId, - PhysicsState: physicsState, ObjectDescriptionFlags: objectDescriptionFlags); + PhysicsState: physicsState, + ObjectDescriptionFlags: objectDescriptionFlags, + Friction: friction, + Elasticity: elasticity); } catch { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 030573b8..47ef10bc 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -63,7 +63,13 @@ public sealed class WorldSession : IDisposable // ObjectDescriptionFlags: retail PWD._bitfield (acclient.h:6431-6463) // — drives IsPlayer/IsPK/IsPKLite/IsImpenetrable for PvP gating. uint? PhysicsState = null, - uint? ObjectDescriptionFlags = null); + uint? ObjectDescriptionFlags = null, + // L.3b (2026-04-30): per-object physics tuning from the wire. + // Friction defaults to PhysicsBody constructor value (0.5f). + // Elasticity defaults to 0.05f. When set, drives the velocity- + // reflection bounce magnitude (clamped to [0, 0.1] retail-side). + float? Friction = null, + float? Elasticity = null); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -657,7 +663,9 @@ public sealed class WorldSession : IDisposable parsed.Value.MotionState, parsed.Value.MotionTableId, parsed.Value.PhysicsState, - parsed.Value.ObjectDescriptionFlags)); + parsed.Value.ObjectDescriptionFlags, + parsed.Value.Friction, + parsed.Value.Elasticity)); } } else if (op == DeleteObject.Opcode) From 1abb699c6805ab00f7ca515cdedac2912310fca2 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 09:46:42 +0200 Subject: [PATCH 17/17] =?UTF-8?q?docs(physics):=20L.3c=20attempt=20?= =?UTF-8?q?=E2=80=94=20friction=20threshold=20investigation,=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tried bumping calc_friction's gate from `dot >= 0f` to `dot >= 0.25f` per retail acclient_2013_pseudo_c.txt:276705. Build green but PlayerMovementControllerTests immediately showed forward motion dropping from ~3m to ~0.16m over a 1-second simulated walk — friction now hammers active locomotion in our architecture. Root cause is deeper than a single threshold. Retail line 276702 has a state-flag check (`(this->state & ...) == 0`) gating the friction block that the decompile renders as a corrupted string and we didn't fully characterize. Best read: retail skips this friction block while locomotion is actively driving velocity, applying it only to residual motion after locomotion stops. acdream's controller sets velocity once per frame from input, then UpdatePhysicsInternal substeps friction through it — at 0.25 threshold the substep compounding eats most of the velocity before integration completes. Reverting to the previous behavior (0.0 threshold). Filing the proper investigation as L.3c-followup: needs to read retail's `(this->state & ...)` flag at acclient_2013_pseudo_c.txt:276702, identify whether it gates on an active-locomotion bit, and either honor that gate or restructure acdream's per-frame locomotion → integration ordering so friction fires only on residual velocity. Tests: 1491 still pass after revert. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/PhysicsBody.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 2c2b20a8..753db366 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -280,6 +280,16 @@ public sealed class PhysicsBody /// /// Cross-checked with ACE PhysicsObj.calc_friction which uses 0.25f as /// the threshold instead; the decompile uses 0.0. We match the decompile. + /// + /// L.3c attempt (2026-04-30, REVERTED): tried bumping to 0.25f per + /// retail acclient_2013_pseudo_c.txt:276705. Build green but + /// PlayerMovementControllerTests showed forward locomotion dropping + /// from ~3m/s to ~0.16m/s — friction now hammers normal walking. + /// Retail's friction block is gated by an additional state check at + /// line 276702 (`(this->state & ...) == 0`) that we didn't decode + /// fully; locomotion is probably skipped from the friction path + /// while actively walking. Filed as L.3c-followup; keeping the + /// matching-the-decompile-as-read 0.0 threshold for now. /// public void calc_friction(float dt, float velocityMag2) {