From a36369d8ca316dabe42fcfdc8cbef7f0c1fdd9c2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 18:16:29 +0200 Subject: [PATCH 1/4] diag(physics): #42 add ACDREAM_AIRBORNE_DIAG [SWEEP] trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of #42 root-cause investigation per the handoff doc. We A/B confirmed (commit b37b713) that the ~1m XY drift on retail- observed stationary jumps comes from inside ResolveWithTransition when the per-tick airborne sweep runs (CellId fix at GameWindow.cs 3467). What we don't yet know: whether the drift originates in H1 (initial-overlap depenetration along a tilted-terrain normal), H2 (step-down probe firing despite isOnGround=false), or H3 (EdgeSlide on near-vertical motion grazing a wall). This diagnostic gates a one-line Console trace on ACDREAM_AIRBORNE_DIAG=1 AND !isOnGround so it doesn't pollute grounded movement, and prints: [SWEEP] airborne pre=(...) target=(...) post=(...) cell=PRE->POST ok=BOOL deltaXY=(dx,dy) cp=valid|none cpN=(nx,ny,nz) deltaXY = post - target — for a clean stationary +Z jump we expect (0,0). Non-zero with cp=valid and a tilted cpN confirms H1; non-zero direction tracking actor facing instead of terrain orientation points to H2/H3. Code-walk findings recorded for the next investigation pass: - K-fix7 already prevents seeding ContactPlane on entry for airborne (PhysicsEngine.cs:493-519), so step 0's AdjustOffset cannot consume a stale plane. - BUT ValidateWalkable can still SET ContactPlane during step 0's collision pass via the "below plane" branch (TransitionTypes.cs 1320-1352) when sphere lowPoint dips below the tilted terrain triangle. Step 1's AdjustOffset would then consume that fresh plane and the "moving away from contact plane" branch (TransitionTypes.cs:1749-1754) projects the +Z offset along the slope normal, redirecting Z motion into XY. - Step-down branch is correctly gated on oi.Contact (matches retail CTransition::transitional_insert at named-retail acclient_2013_pseudo_c.txt:273249, "(state & 1) == 0" returns OK without firing step-down). - Retail's IS_VIEWER_OI=0x4 branch in OBJECTINFO::validate_walkable (acclient.h:6185) is never set anywhere in the named decomp, so the airborne path runs the same code in retail as in acdream. User repros at flat plaza / east hillside / north hillside; the direction-correlation of deltaXY with local terrain orientation identifies which hypothesis is firing. Build green; 13 PhysicsEngine tests green. No behavior change when ACDREAM_AIRBORNE_DIAG is unset. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/PhysicsEngine.cs | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index da6711e6..5868fe04 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -625,6 +625,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 From ec59a08db5b846c6acc8b943daf3ad7e0aa707bb Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 19:01:07 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(physics):=20#42=20skip=20self=20in=20Fi?= =?UTF-8?q?ndObjCollisions=20=E2=80=94=20airborne=20XY=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause confirmed via two-run diagnostic and the named-retail decomp: the airborne sweep was colliding with the moving entity's OWN ShadowEntry because FindObjCollisions had no self-skip filter. Live entities (local player, remotes) register a Cylinder in ShadowObjectRegistry on spawn (GameWindow.cs:2545) and UpdatePosition tracks its world position each tick, so the moving sphere's own cylinder is always at the body's position. Without a gate, CylinderCollision sees the sphere overlapping its own cylinder volume and slides the sphere ~1m horizontally on every frame the path produces non-zero motion. Why grounded mostly hides it and airborne exposes it: - Stationary grounded → numSteps=0, TransitionalInsert never runs. - Walking grounded → push fires but motion escapes the cyl radius and the deflection blends into normal motion. - Stationary airborne (jump) → pure +Z motion; the cyl push is the only horizontal contribution and manifests as a clean ~1m drift. Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679, H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run 1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection). Retail does the same skip — CObjCell::find_obj_collisions at named-retail acclient_2013_pseudo_c.txt:308931: if ((physobj->parent == 0 && physobj != arg2->object_info.object)) `arg2->object_info.object` is the OBJECTINFO::object self-pointer set by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port mirrors this with an EntityId-based filter: - ObjectInfo gains a SelfEntityId field (default 0 = no filter). - ResolveWithTransition gains an optional `uint movingEntityId = 0` parameter that sets it. - FindObjCollisions skips entries whose EntityId matches SelfEntityId when the id is non-zero. - PlayerMovementController gains a LocalEntityId property; GameWindow refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`. - GameWindow's airborne-remote ResolveWithTransition call site passes `movingEntityId: kv.Key` (kv.Key is the local entity id keying `_animatedEntities`, same id used at the spawn-time ShadowObjects.Register). Default 0 keeps tests and one-shot callers (no registered ShadowEntry) working unchanged. Lock-the-fix unit test: `PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches` registers a humanoid Cylinder at the body's exact position (matching GameWindow's spawn pattern), then asserts that: - movingEntityId=0 (control) → unfiltered XY drift > 0.5m - movingEntityId=registered id (fix) → XY drift ≈ 0 Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no output in normal use but lets us verify the fix on the live client and debug future regressions. Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff prompt — verified by stashing this change; the BSPStepUp C3 failure is on the prior commit too). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Input/PlayerMovementController.cs | 18 ++++- src/AcDream.App/Rendering/GameWindow.cs | 23 +++++- src/AcDream.Core/Physics/PhysicsEngine.cs | 12 ++- src/AcDream.Core/Physics/TransitionTypes.cs | 53 +++++++++++++ .../Physics/PhysicsEngineTests.cs | 76 +++++++++++++++++++ 5 files changed, 179 insertions(+), 3 deletions(-) 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..9c2e816d 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 5868fe04..671eb2b9 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 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); + } } From 9e4772a8f87cf2b12aebd7b61543f2a145c3094c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 21:37:42 +0200 Subject: [PATCH 3/4] fix(motion): project anim root motion onto terrain plane (slope staircase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounded player remotes were showing a ~5 Hz Z staircase when running up/down slopes — the rate of server UpdatePositions. Body Z stayed flat between UPs, then ramped over ~100ms during the queue-active chase to each new server position, then went flat again until the next UP. Diagnosis (no diagnostic needed — the math is unambiguous): PositionManager.ComputeOffset has two modes via InterpolationManager.AdjustOffset: - Queue active (body chasing a waypoint): returns `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction, Z follows server's reported Z naturally. - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the most recent UP): returns Vector3.Zero. ComputeOffset falls back to `seqVel × dt rotated into world` — pure animation root motion. Every locomotion cycle bakes Z=0 in body-local, so the world result has Z=0 too. XY advances at the running pace; Z stays at the last UP. For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms (queue-active 3D path, Z ramps), then sits in seqVel-only mode for ~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase. Fix mirrors retail's CTransition::adjust_offset contact-plane projection (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded motion, applied at the queue-empty boundary instead of inside the sweep: PositionManager.ComputeOffset gains an optional Vector3? terrainNormal. When the seqVel-only fallback runs AND a non-trivial terrain normal is supplied, project rootMotionWorld onto the plane: result = rootMotionWorld − N × dot(rootMotionWorld, N) Anim XY motion gains a corresponding Z component proportional to slope angle × forward speed, so body Z follows the terrain mesh between UPs. No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's flat-ground verification. GameWindow.TickAnimations grounded-remote path samples PhysicsEngine.SampleTerrainNormal at the body's current XY each tick and passes it to ComputeOffset. SampleTerrainNormal is a thin public wrapper over the existing internal SampleTerrainWalkable that returns just the plane normal (no need to expose the internal sample shape). Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with guid, body Z before/after, offset, queue active flag, and the sampled plane Nz so we can grep before/after the fix and confirm Z changes continuously between UPs on slopes. Tests: PositionManagerTests gains two cases: - slope projection: 30° east-tilted plane, body running due east at 4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866) × 2.0 = (3.0, 0, −1.732). - flat-ground no-op: N = +Z, expect identical Y-only motion as the pre-fix behavior. Build green. 357 pass / 6 pre-existing fail (same set as ec59a08; verified by stashing this change). The pre-existing `ComputeOffset_BothActive_Combined` failure reflects an outdated additive-design test docstring; the M2 commit (40d88b9) deliberately changed the implementation to REPLACE semantics to fix the prior 3×-server-pace overshoot. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 29 ++++++++- src/AcDream.Core/Physics/PhysicsEngine.cs | 15 +++++ src/AcDream.Core/Physics/PositionManager.cs | 36 ++++++++++- .../Physics/PositionManagerTests.cs | 60 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9c2e816d..01ffc0d8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6149,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 diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 671eb2b9..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 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/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); + } } From 5f2e2e28ff9bfd8e945cba0263cb07f6dec10f61 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 21:51:12 +0200 Subject: [PATCH 4/4] docs(issues): close #42 (self-skip ec59a08), file + close #43 staircase (9e4772a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #42 — moved from OPEN to DONE in place (rich investigation log preserved below the new Resolution block). The originally-listed mechanisms (H1 slope-driven AdjustOffset projection, H2 step-down probe, H3 EdgeSlide) were all RULED OUT by the first evidence run; root cause was self- collision in FindObjCollisions, not in-sweep mechanism choice. Added forward-pointer to retail's CObjCell::find_obj_collisions self-skip (named-retail acclient_2013_pseudo_c.txt:308931). #43 — new entry in Recently closed for the slope staircase on grounded player remotes. Diagnosis: PositionManager.ComputeOffset's seqVel-only fallback returned flat-Z motion because anim cycles bake Z=0 body-local, producing visible 5 Hz Z stepping at the server-UP cadence. Fix: project the fallback onto the local terrain plane (mirrors retail's CTransition::adjust_offset contact-plane projection at the queue-empty boundary). Verified via 9193 queue-empty-with-non-zero-offset.Z ticks across a 34m vertical traversal. Both diagnostic env-vars kept in tree for future regression hunts: ACDREAM_AIRBORNE_DIAG=1 and ACDREAM_SLOPE_DIAG=1. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 3 deletions(-) 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