diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 655aee7..e44a975 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6117,6 +6117,12 @@ public sealed class GameWindow : IDisposable // semantics — retail's PositionManager::adjust_offset // overwrites the offset frame with the catch-up direction, // not adding to it. + // + // 2026-05-03 (Commit B fix for staircase regression): capture + // the pre-translation position so the collision sweep below + // (Step 4b) can resolve the full per-tick movement through + // BSP + terrain. + var preIntegratePos = rm.Body.Position; float maxSpeed = rm.Motion.GetMaxSpeed(); System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, @@ -6181,6 +6187,93 @@ public sealed class GameWindow : IDisposable // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); + // Step 4b (Commit B fix 2026-05-03): collision sweep — port of + // retail update_object's FUN_005148A0 Transition::FindTransitionalPosition. + // This was MISSING in the env-var path introduced by e94e791 + // (L.3.1+L.3.2 Task 3). The legacy (env-var off) path at the + // bottom of this function has it (line ~6483 "Step 4: collision + // sweep"); we just need the same call here. + // + // Without this: + // - Body Z drifts on slopes (visible "staircase" — horizontal + // Euler motion up a slope sinks into rising ground until + // the next UP pops it up). + // - Body slides through walls / objects between UPs. + // - Step-up / step-down doesn't engage on ledges. + // - Edge-slide doesn't engage on cliff edges. + // + // The env-var path was originally designed to mirror retail + // CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330) — a network + // packet handler entry point that does minimal work. But + // TickAnimations is the per-frame physics tick (mirrors retail + // FUN_00515020 update_object), which DOES include the collision + // sweep. Adding the sweep here makes the env-var path retail- + // faithful for the per-frame tick (matching the legacy path, + // which had it). + var postIntegratePos = rm.Body.Position; + if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) + { + // Sphere dims match local-player + legacy-path defaults + // (~0.48m radius, ~1.2m height humanoid). Step-up/down 0.4m + // matches L.2.3a retail human-scale. EdgeSlide is the retail + // default mover-flags state. + var resolveResult = _physicsEngine.ResolveWithTransition( + preIntegratePos, postIntegratePos, rm.CellId, + sphereRadius: 0.48f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + // Airborne remotes must NOT pre-seed the ContactPlane — + // mirrors K-fix9 in the legacy path; otherwise + // AdjustOffset's snap-to-plane branch zeroes the +Z + // offset every step on a jump arc. + isOnGround: !rm.Airborne, + body: rm.Body, + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); + + rm.Body.Position = resolveResult.Position; + if (resolveResult.CellId != 0) + rm.CellId = resolveResult.CellId; + + // Post-resolve landing detection — mirrors K-fix15 in the + // legacy path. When the resolver says we're on ground AND + // velocity is no longer pointing up, transition back to + // grounded. Without this, gravity keeps building negative Z + // velocity until the sphere-sweep clamps each frame, but + // Airborne stays true forever. + if (rm.Airborne + && resolveResult.IsOnGround + && rm.Body.Velocity.Z <= 0f) + { + rm.Airborne = false; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.Velocity = new System.Numerics.Vector3( + rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); + rm.Motion.HitGround(); + + // Reset sequencer cycle from Falling back to whatever + // InterpretedState says. Mirrors K-fix17 in the legacy + // path. + if (ae.Sequencer is not null) + { + uint landStyle = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(landStyle, landingCmd, landingSpeed); + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + } + // Step 5: landing fallback. The retail-faithful path leaves // the landing transition to OnLivePositionUpdated when ACE // sends IsGrounded=true. In practice ACE doesn't always