diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index bdce230f..11173ada 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -124,12 +124,52 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g.
- No spurious cycle thrashing during turning while running (ObservedOmega
doesn't trigger velocity-bucket changes).
-## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
+## #42 — [DONE 2026-05-05 · ec59a08] Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
-**Status:** OPEN
+**Status:** DONE
**Severity:** MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix)
**Filed:** 2026-05-05 (root cause confirmed same day)
-**Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior)
+**Closed:** 2026-05-05
+**Commit:** `ec59a08`
+**Component:** physics (`PhysicsEngine.ResolveWithTransition` → `FindObjCollisions` self-skip)
+
+**Resolution (2026-05-05):** Self-collision in `FindObjCollisions`, not
+any of the three originally-hypothesised mechanisms below. Live
+entities (local player, remotes) register a Cylinder in
+`ShadowObjectRegistry` at spawn (`GameWindow.cs:2545`) which
+`UpdatePosition` keeps tracking the entity's live world position.
+With no self-skip filter, the moving sphere's own cylinder is always
+sitting at the body's exact position and `CylinderCollision` slides
+the sphere out of overlap on every airborne tick. Validated by the
+[SWEEP-OBJ] diagnostic added in commit `a36369d`: every drift event
+showed `gfxObj=0x02000001` (humanoid setup) at `obj.Position` exactly
+matching the body's `pre`. Mirrors retail's `CObjCell::find_obj_collisions`
+self-skip at named-retail line 308931:
+
+```c
+if ((physobj->parent == 0 && physobj != arg2->object_info.object))
+ result = CPhysicsObj::FindObjCollisions(physobj, arg2);
+```
+
+Plumbing: `ObjectInfo.SelfEntityId` field, optional
+`movingEntityId = 0` parameter on `ResolveWithTransition`,
+`PlayerMovementController.LocalEntityId` refreshed per-tick from
+`_entitiesByServerGuid[_playerServerGuid].Id`, remote sweep at
+`GameWindow.cs:6474` passes `kv.Key`. Lock-the-fix unit test at
+`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`.
+
+Verified via two visual + log runs (`launch-42-verify.log` /
+`launch-42-verify2.log`): zero stationary-jump drift across both,
+`gfxObj=0x02000001` phantom no longer appears in `[SWEEP-OBJ]`,
+no >0.5m pushes anywhere. The originally-listed hypotheses (H1
+slope-driven AdjustOffset projection, H2 step-down probe, H3
+EdgeSlide) were all RULED OUT by the first evidence run — `cpN`
+was `(0, 0, 1)` flat for every drift event.
+
+**Diagnostic kept in tree:** `ACDREAM_AIRBORNE_DIAG=1` enables the
+`[SWEEP]` + `[SWEEP-OBJ]` traces for future regression hunts.
+
+The original investigation log is preserved below for context.
**Root cause (verified 2026-05-05 via A/B test):**
@@ -1188,6 +1228,53 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
# Recently closed
+## #43 — [DONE 2026-05-05 · 9e4772a] Slope staircase on observed player remotes (anim-only fallback ignored slope)
+
+**Closed:** 2026-05-05
+**Commit:** `9e4772a`
+**Component:** motion (`PositionManager.ComputeOffset` queue-empty fallback)
+
+**Resolution:** Grounded player remotes showed a ~5 Hz Z staircase when
+running up/down hills. `PositionManager.ComputeOffset` has two modes:
+queue-active (3D direction toward server's broadcast position, Z
+follows naturally) and queue-empty / head-reached (`seqVel × dt`
+rotated into world). Every locomotion cycle bakes Z=0 in body-local,
+so the world result has Z=0 too. With server UPs at ~5 Hz and
+catchUpSpeed = 2× maxSpeed, body chases each waypoint in ~100ms (Z
+ramps), then sits in seqVel-only mode for ~100ms (Z flat) until the
+next UP. Visible 5 Hz staircase.
+
+Fix mirrors retail's `CTransition::adjust_offset` contact-plane
+projection (named-retail acclient_2013_pseudo_c.txt:272296-272346),
+applied at the queue-empty boundary instead of inside the sweep.
+`ComputeOffset` gains an optional `Vector3? terrainNormal`; when
+the seqVel fallback runs and the supplied normal is non-trivial,
+`rootMotionWorld -= N × dot(rootMotionWorld, N)`. XY motion gains a
+Z component proportional to slope × forward speed; body Z follows the
+terrain mesh between UPs. No-op on flat ground (N ≈ +Z, dot ≈ 0) so
+no regression to L.3 M2's flat-ground verification.
+
+`GameWindow.TickAnimations` grounded-remote path samples
+`PhysicsEngine.SampleTerrainNormal` (a thin public wrapper over the
+existing internal `SampleTerrainWalkable`) at the body's current XY
+each tick and passes it to `ComputeOffset`.
+
+Two unit tests in `PositionManagerTests`: 30° east-tilted slope
+(asserts `(3.0, 0, −1.732)` for 4 m/s east motion over 1s — body
+descends along slope) + flat-ground no-op (asserts unchanged
+behaviour with `N = +Z`).
+
+Verified via `launch-slope-verify.log` over a 34m vertical traversal:
+9,193 queue-empty-with-non-zero-offset.Z ticks on slopes (the path
+that previously stair-cased), 26,497 sloped-normal ticks total, zero
+#42 regressions.
+
+**Diagnostic kept in tree:** `ACDREAM_SLOPE_DIAG=1` enables the
+`[SLOPE]` per-tick trace (`bodyZ` before/after, offset, queue active,
+sampled `cpN.Z`) for future regression hunts.
+
+---
+
## #31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement
**Closed:** 2026-04-29
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index c904b264..a57ccd97 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 2666fb50..01ffc0d8 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
@@ -6137,15 +6149,42 @@ public sealed class GameWindow : IDisposable
// velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail − body) when fail_count > 3.
float maxSpeed = rm.Motion.GetMaxSpeed();
+ // Slope-staircase fix (2026-05-05): sample terrain normal
+ // at the body's current XY so PositionManager can project
+ // the seqVel-only fallback onto the local slope. Without
+ // this, the queue-empty interval between UPs left Z flat
+ // (anim cycles bake Z=0 body-local) — visible ~5 Hz
+ // staircase when a remote runs up/down hills. The
+ // projection is a no-op on flat ground.
+ System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
+ rm.Body.Position.X, rm.Body.Position.Y);
+
+ System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVel,
ori: rm.Body.Orientation,
interp: rm.Interp,
- maxSpeed: maxSpeed);
+ maxSpeed: maxSpeed,
+ terrainNormal: terrainNormal);
rm.Body.Position += offset;
+ // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
+ // Prints per-tick body Z trajectory + queue state + projected
+ // offset.Z so we can grep before/after the fix and confirm Z
+ // changes continuously between UPs on slopes (no flat
+ // intervals followed by snaps).
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
+ {
+ bool queueActive = rm.Interp.IsActive;
+ float nz = terrainNormal?.Z ?? 1.0f;
+ System.Console.WriteLine(
+ $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
+ + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
+ + $"queue={queueActive} cpN.Z={nz:F3}");
+ }
+
// Step 2.5: angular velocity → body orientation. Prefer
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
// the wire's TurnCommand + signed TurnSpeed) over the
@@ -6487,7 +6526,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 da6711e6..fe308ae7 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -169,6 +169,21 @@ public sealed class PhysicsEngine
return null;
}
+ ///
+ /// Public surface for callers that only need the local terrain plane
+ /// normal at a world-space XY (e.g., the grounded-remote tick path
+ /// projecting anim root motion onto the slope to avoid the staircase
+ /// between server position updates). Returns null when no registered
+ /// landblock covers the point. Mirrors the plane component of
+ /// without exposing the internal
+ /// TerrainWalkableSample shape.
+ ///
+ public Vector3? SampleTerrainNormal(float worldX, float worldY)
+ {
+ var sample = SampleTerrainWalkable(worldX, worldY);
+ return sample?.Plane.Normal;
+ }
+
///
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as
@@ -473,12 +488,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
@@ -625,6 +650,33 @@ public sealed class PhysicsEngine
bool collisionNormalValid = ci.CollisionNormalValid;
Vector3 collisionNormal = ci.CollisionNormal;
+ // #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
+ // source of the ~1m XY drift on retail-observed stationary jumps.
+ // Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
+ // resolve call. deltaXY = post - target tells us how much the sweep
+ // diverged from the requested target; for a clean stationary +Z
+ // jump we expect (0,0). cp=valid with a tilted normal would confirm
+ // H1 (initial-overlap depenetration → next-step AdjustOffset projects
+ // the +Z offset along a non-+Z normal). User repros at flat plaza /
+ // east hillside / north hillside; if drift direction tracks terrain
+ // orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
+ if (!isOnGround
+ && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
+ {
+ var post = sp.CheckPos;
+ float dx = post.X - targetPos.X;
+ float dy = post.Y - targetPos.Y;
+ string cpInfo = ci.ContactPlaneValid
+ ? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
+ : "none";
+ Console.WriteLine(
+ $"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
+ $"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
+ $"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
+ $"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
+ $"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
+ }
+
if (ok)
{
bool onGround = ci.ContactPlaneValid
diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs
index be3dbc0d..e3ff2aec 100644
--- a/src/AcDream.Core/Physics/PositionManager.cs
+++ b/src/AcDream.Core/Physics/PositionManager.cs
@@ -34,13 +34,28 @@ public sealed class PositionManager
/// Body orientation; used to rotate seqVel from body-local to world.
/// The remote's InterpolationManager (for AdjustOffset call).
/// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp.
+ ///
+ /// Optional local terrain plane normal at the body's current XY. When
+ /// supplied AND the queue-empty / head-reached fallback path runs, the
+ /// world-space anim root motion is projected onto the plane so XY motion
+ /// produces a corresponding Z change on slopes. Without this, the
+ /// fallback advances XY at the locomotion cycle's pace but leaves Z at
+ /// the last UP's reported Z — visible as a ~5 Hz staircase on slopes
+ /// (the rate of server UpdatePositions). Mirrors retail's
+ /// CTransition::adjust_offset contact-plane projection
+ /// (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
+ /// motion, applied here at the queue-empty boundary instead of inside
+ /// the sweep. Pass null on flat ground / when no terrain sample
+ /// is available — projection is a no-op when normal == +Z.
+ ///
public Vector3 ComputeOffset(
double dt,
Vector3 currentBodyPosition,
Vector3 seqVel,
Quaternion ori,
InterpolationManager interp,
- float maxSpeed)
+ float maxSpeed,
+ Vector3? terrainNormal = null)
{
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
@@ -71,6 +86,23 @@ public sealed class PositionManager
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
- return Vector3.Transform(rootMotionLocal, ori);
+ Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
+
+ // Slope projection (queue-empty fallback only). Locomotion cycles
+ // bake Z=0 in body-local, so without projection the body's Z stays
+ // at the last UP's reported value while XY advances at the running
+ // pace — visible ~5 Hz staircase between UPs on hills. Projecting
+ // the world-space anim motion onto the local terrain plane gives
+ // it a Z component proportional to slope × forward speed, so the
+ // body follows the terrain mesh smoothly. No-op on flat ground
+ // (normal ≈ +Z, dot ≈ 0) so it can't regress the M2 flat-ground
+ // verification.
+ if (terrainNormal.HasValue && terrainNormal.Value.Z > 0.01f)
+ {
+ Vector3 N = terrainNormal.Value;
+ float into = Vector3.Dot(rootMotionWorld, N);
+ rootMotionWorld -= N * into;
+ }
+ return rootMotionWorld;
}
}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index c7db51d0..f2c4f6c6 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 dc53d93d..d9d08f8e 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);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
index 7839bc60..0242c2ad 100644
--- a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
@@ -176,4 +176,64 @@ public sealed class PositionManagerTests
Assert.Equal(0f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, precision: 4);
}
+
+ // =========================================================================
+ // Test 7: slope projection — anim root motion gains Z proportional to slope
+ //
+ // Lock-the-fix for the "remote running on a slope shows ~5 Hz Z staircase"
+ // bug: the queue-empty fallback was returning a flat (Z=0) world motion
+ // because animation cycles bake Z=0 in body-local. Projecting onto the
+ // local terrain plane gives the motion a Z component matching slope angle
+ // × forward speed.
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
+ {
+ var pm = Make();
+ var interp = EmptyInterp(); // queue empty → fallback path runs
+
+ // Slope tilted 30° eastward (+X is downhill). Plane normal points
+ // up-and-east-of-vertical: (sin 30°, 0, cos 30°) = (0.5, 0, 0.866).
+ Vector3 N = Vector3.Normalize(new Vector3(0.5f, 0f, MathF.Sqrt(3f) / 2f));
+
+ // Body running due east at 4 m/s, dt = 1s → rootMotionWorld initially
+ // (4, 0, 0). After projection onto the plane:
+ // into = dot((4,0,0), (0.5,0,0.866)) = 2.0
+ // result = (4,0,0) - (0.5,0,0.866) * 2.0 = (3.0, 0, -1.732)
+ // i.e. body moves east AND descends ~1.73m for the second.
+ Vector3 offset = pm.ComputeOffset(
+ dt: 1.0,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(4f, 0f, 0f),
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 0f,
+ terrainNormal: N);
+
+ Assert.Equal( 3.000f, offset.X, precision: 3);
+ Assert.Equal( 0.000f, offset.Y, precision: 3);
+ Assert.Equal(-1.732f, offset.Z, precision: 3);
+ }
+
+ [Fact]
+ public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange()
+ {
+ var pm = Make();
+ var interp = EmptyInterp();
+
+ // Flat ground: normal = +Z. Projection should be a no-op.
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(0f, 4f, 0f),
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 0f,
+ terrainNormal: Vector3.UnitZ);
+
+ Assert.Equal(0f, offset.X, precision: 4);
+ Assert.Equal(0.4f, offset.Y, precision: 4);
+ Assert.Equal(0f, offset.Z, precision: 4);
+ }
}