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);
+ }
}