diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 11173ada..bdce230f 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -124,52 +124,12 @@ 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 — [DONE 2026-05-05 · ec59a08] Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
+## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
-**Status:** DONE
+**Status:** OPEN
**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)
-**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.
+**Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior)
**Root cause (verified 2026-05-05 via A/B test):**
@@ -1228,53 +1188,6 @@ 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 a57ccd97..c904b264 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -130,17 +130,6 @@ 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;
///
@@ -569,12 +558,7 @@ 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,
- // 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);
+ | AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
// 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 01ffc0d8..2666fb50 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5264,18 +5264,6 @@ 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
@@ -6149,42 +6137,15 @@ 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,
- terrainNormal: terrainNormal);
+ maxSpeed: maxSpeed);
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
@@ -6526,16 +6487,7 @@ 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,
- // 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);
+ moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
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 fe308ae7..da6711e6 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -169,21 +169,6 @@ 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
@@ -488,22 +473,12 @@ public sealed class PhysicsEngine
float stepUpHeight, float stepDownHeight,
bool isOnGround,
PhysicsBody? body = null,
- ObjectInfoState moverFlags = ObjectInfoState.None,
- uint movingEntityId = 0)
+ ObjectInfoState moverFlags = ObjectInfoState.None)
{
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
@@ -650,33 +625,6 @@ 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 e3ff2aec..be3dbc0d 100644
--- a/src/AcDream.Core/Physics/PositionManager.cs
+++ b/src/AcDream.Core/Physics/PositionManager.cs
@@ -34,28 +34,13 @@ 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,
- Vector3? terrainNormal = null)
+ float maxSpeed)
{
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
@@ -86,23 +71,6 @@ public sealed class PositionManager
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
- 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;
+ return Vector3.Transform(rootMotionLocal, ori);
}
}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index f2c4f6c6..c7db51d0 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -56,19 +56,6 @@ 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);
@@ -1388,19 +1375,6 @@ 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;
@@ -1423,21 +1397,6 @@ 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;
@@ -1524,19 +1483,7 @@ 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 d9d08f8e..dc53d93d 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -377,80 +377,4 @@ 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 0242c2ad..7839bc60 100644
--- a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
@@ -176,64 +176,4 @@ 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);
- }
}