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