diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index c904b26..a57ccd9 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -130,6 +130,17 @@ public sealed class PlayerMovementController public Vector3 Position => _body.Position; public uint CellId { get; private set; } + /// + /// Local-player entity id used to skip self-collision in the + /// airborne sweep. GameWindow updates this whenever the local + /// `+Acdream` entity (re)spawns. Default 0 = no filter (matches + /// retail's CObjCell::find_obj_collisions self-skip when the + /// caller's OBJECTINFO::object pointer is null). Without this the + /// sweep collides with its own ShadowEntry registered at + /// GameWindow.cs:2545 — see #42. + /// + public uint LocalEntityId { get; set; } + public bool IsAirborne => !_body.OnWalkable; /// @@ -558,7 +569,12 @@ public sealed class PlayerMovementController // through other non-PK players, which is retail's default for // ACE's character creation defaults too). moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer - | AcDream.Core.Physics.ObjectInfoState.EdgeSlide); + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + // Fix #42: skip self in FindObjCollisions. Wired by GameWindow + // when the local player entity spawns (or stays 0 in tests, in + // which case there's no registered ShadowEntry to collide with + // anyway). + movingEntityId: LocalEntityId); // L.4-diag (2026-04-30): trace position transitions so we can see // whether the body is actually moving frame-to-frame on the steep diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2666fb5..9c2e816 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5264,6 +5264,18 @@ public sealed class GameWindow : IDisposable MouseDeltaX: 0f, Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump)); + // Fix #42 (2026-05-05): keep PlayerMovementController's + // LocalEntityId in sync with the live local player entity so + // FindObjCollisions skips its own ShadowEntry. Re-fetched per + // tick so re-spawns / character switches don't leave a stale + // id on the controller. Pre-spawn or between-character it + // stays 0 (no filter), which is harmless because there's no + // ShadowEntry registered yet. + _playerController.LocalEntityId = + _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var localEnt) + ? localEnt.Id + : 0u; + var result = _playerController.Update((float)dt, input); // Update the player entity's position + rotation so it renders at @@ -6487,7 +6499,16 @@ public sealed class GameWindow : IDisposable // 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); + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + // Fix #42 (2026-05-05): skip the moving remote's + // own ShadowEntry. _animatedEntities is keyed by + // entity.Id so kv.Key matches the EntityId the + // ShadowObjectRegistry has for this remote. + // Without this, the airborne sweep collides with + // the remote's own cylinder and produces ~1m of + // horizontal drift on the first jump frame + // (validated by [SWEEP-OBJ] traces). + movingEntityId: kv.Key); rm.Body.Position = resolveResult.Position; if (resolveResult.CellId != 0) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 5868fe0..671eb2b 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -473,12 +473,22 @@ public sealed class PhysicsEngine float stepUpHeight, float stepDownHeight, bool isOnGround, PhysicsBody? body = null, - ObjectInfoState moverFlags = ObjectInfoState.None) + ObjectInfoState moverFlags = ObjectInfoState.None, + uint movingEntityId = 0) { var transition = new Transition(); transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDown = true; + // Fix #42 (2026-05-05): the moving entity's ShadowEntry must be + // skipped in FindObjCollisions or the sweep collides with self. + // Default 0 keeps tests / one-shot callers (no registered entity) + // working. Plumbed through ObjectInfo because retail stores the + // self pointer on OBJECTINFO::object (named-retail + // acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init → + // this->object = arg2). The skip itself is at + // CObjCell::find_obj_collisions line 308931. + transition.ObjectInfo.SelfEntityId = movingEntityId; // Commit C 2026-04-29 — caller-supplied mover flags drive the // retail PvP exemption block in FindObjCollisions. The local diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c7db51d..f2c4f6c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -56,6 +56,19 @@ public sealed class ObjectInfo public bool StepDown = true; public float Scale = 1.0f; + /// + /// EntityId of the moving entity, used by FindObjCollisions to + /// skip the moving object's own ShadowEntry. Mirrors retail + /// CObjCell::find_obj_collisions at named-retail line 308931 + /// (physobj != arg2->object_info.object) — the self pointer + /// stored on OBJECTINFO::object. Default 0 = no filter (tests + /// and one-shot callers that don't have a registered entity). + /// Without this gate the local player and airborne remotes collide + /// with their own registered cylinder in ShadowObjectRegistry, + /// producing the ~1m horizontal push-out documented in #42. + /// + public uint SelfEntityId; + // Convenience flag checks public bool Contact => State.HasFlag(ObjectInfoState.Contact); public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable); @@ -1375,6 +1388,19 @@ public sealed class Transition if (engine.DataCache is null) return TransitionState.OK; var sp = SpherePath; + var oi = ObjectInfo; + + // #42 diagnostic (2026-05-05): identify which static object causes + // the airborne first-frame ~1m push. Capture sphere check pos at + // entry; on a non-OK return, we'll log the (object, delta) pair + // gated on ACDREAM_AIRBORNE_DIAG=1 + airborne. The first evidence + // run ruled out H1 (slope-driven AdjustOffset projection); cpN was + // (0,0,1) flat for every drift event, so the horizontal push must + // come from CylinderCollision or BSPQuery.FindCollisions inside + // this function. Logging the object identity tells us which one. + bool airborneDiag = !oi.Contact + && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1"; + Vector3 sphereCheckBefore = sp.CheckPos; Vector3 checkPos = sp.GlobalSphere[0].Origin; Vector3 currPos = sp.GlobalCurrCenter[0].Origin; @@ -1397,6 +1423,21 @@ public sealed class Transition foreach (var obj in nearbyObjs) { + // Self-skip — fix #42 (2026-05-05). Mirrors retail + // CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt + // 308931: `physobj != arg2->object_info.object` rejects the + // moving entity's own shadow entry. Live entities (creatures, + // players) register a Cylinder in ShadowObjectRegistry at + // GameWindow.cs:2545 which UpdatePosition tracks live, so + // without this skip the moving sphere collides with itself + // every frame the path produces non-zero motion. The grounded + // case mostly hides behind motion that escapes the radius; + // airborne stationary jumps expose it as a one-shot ~1m + // horizontal push (validated by [SWEEP-OBJ] traces with + // gfxObj=0x02000001 at exactly the entity's own position). + if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId) + continue; + // Broad-phase: can the moving sphere reach this object? Vector3 deltaToCurr = currPos - obj.Position; float distToCurr; @@ -1483,7 +1524,19 @@ public sealed class Transition } if (result != TransitionState.OK) + { + if (airborneDiag) + { + var sphereCheckAfter = sp.CheckPos; + var d = sphereCheckAfter - sphereCheckBefore; + Console.WriteLine( + $"[SWEEP-OBJ] type={obj.CollisionType} gfxObj=0x{obj.GfxObjId:X8} " + + $"objPos=({obj.Position.X:F3},{obj.Position.Y:F3},{obj.Position.Z:F3}) " + + $"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " + + $"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})"); + } return result; + } } return TransitionState.OK; diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index dc53d93..d9d08f8 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -377,4 +377,80 @@ public class PhysicsEngineTests Assert.False(result.IsOnGround); } + + /// + /// #42 lock — when the moving entity's own ShadowEntry is registered + /// in at the body's exact position + /// (the production pattern from GameWindow.cs:2545 spawn → register + /// + UpdatePosition live tracking), the airborne sweep MUST skip + /// it. Without the gate, FindObjCollisions sees the cylinder as + /// a foreign collidable and slides the sphere ~1m horizontally on the + /// first non-zero-motion frame — the bug observed by the [SWEEP-OBJ] + /// trace and reported as the post-jump XY drift in #42. + /// + /// Mirrors retail's self-skip at CObjCell::find_obj_collisions + /// (named-retail acclient_2013_pseudo_c.txt:308931): + /// physobj != arg2->object_info.object. + /// + /// + [Fact] + public void ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches() + { + var engine = MakeFlatEngine(terrainZ: 50f); + // FindObjCollisions early-returns when DataCache is null. An empty + // cache is enough for cylinder objects; only BSP objects look up + // entries inside. + engine.DataCache = new PhysicsDataCache(); + + const uint movingEntityId = 0xDEADBEEFu; + var bodyPos = new Vector3(96f, 96f, 50f); + var targetPos = bodyPos + new Vector3(0f, 0f, 0.022f); // stationary +Z + + // Register the moving entity's own ShadowEntry — humanoid Cylinder + // sized to match the live-spawn registration in production + // (GameWindow.cs:2545). The gfxObj id 0x02000001 is the standard + // human setup; radius/height match the [SWEEP-OBJ] trace observed + // during run #2 of the #42 investigation. + engine.ShadowObjects.Register( + entityId: movingEntityId, + gfxObjId: 0x02000001u, + worldPos: bodyPos, + rotation: Quaternion.Identity, + radius: 0.679f, + worldOffsetX: 0f, worldOffsetY: 0f, + landblockId: 0xA9B4FFFFu, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f); + + // Without the gate (movingEntityId == 0): the sweep must self-push. + // This proves the registry actually causes a collision, so the + // following filtered case is not a vacuous pass. + var unfiltered = engine.ResolveWithTransition( + currentPos: bodyPos, targetPos: targetPos, + cellId: 0xA9B40039u, + sphereRadius: 0.48f, sphereHeight: 1.2f, + stepUpHeight: 0.4f, stepDownHeight: 0.4f, + isOnGround: false, + movingEntityId: 0u); + + float unfilteredXY = MathF.Sqrt( + (unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) + + (unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y)); + Assert.True(unfilteredXY > 0.5f, + $"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)"); + + // With the gate: the sweep must leave XY unchanged. + var filtered = engine.ResolveWithTransition( + currentPos: bodyPos, targetPos: targetPos, + cellId: 0xA9B40039u, + sphereRadius: 0.48f, sphereHeight: 1.2f, + stepUpHeight: 0.4f, stepDownHeight: 0.4f, + isOnGround: false, + movingEntityId: movingEntityId); + + float filteredXY = MathF.Sqrt( + (filtered.Position.X - targetPos.X) * (filtered.Position.X - targetPos.X) + + (filtered.Position.Y - targetPos.Y) * (filtered.Position.Y - targetPos.Y)); + Assert.InRange(filteredXY, 0f, 0.001f); + } }