From ae79e34a6d2db713ace657c7a5505c289236d248 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 2 May 2026 19:31:03 +0200 Subject: [PATCH] feat(motion): per-frame Interp.AdjustOffset in remote tick (L.3.1 Task 5) Wraps the existing legacy per-frame remote tick (apply_current_movement + force-OnWalkable + Euler-extrapolate) in ACDREAM_INTERP_MANAGER=1 env-var guard. When set: - if Interp.IsActive: rm.Body.Position += Interp.AdjustOffset(dt, pos, maxSpeed) - still call body.UpdatePhysicsInternal so airborne arcs (gravity) continue to integrate via the OnLiveVectorUpdated-set velocity. When env-var unset (default), legacy path runs unchanged. Mirrors retail's per-tick CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730) which calls InterpolationManager::adjust_offset (@ 0x00555D30) every frame. Old legacy path will be removed in Task 8 cleanup commit after visual verification. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 624 +++++++++++++----------- 1 file changed, 333 insertions(+), 291 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index af85140..0d4210a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5763,320 +5763,362 @@ public sealed class GameWindow : IDisposable && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - // Stop detection is handled explicitly on packet receipt: - // - UpdateMotion with ForwardCommand flag CLEARED → Ready. - // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. - // Both map to retail's "flag-absent = Invalid = reset to - // default" semantics (FUN_0051F260 bulk-copy). No timer-based - // inference needed — the server sends the right signal every - // time a remote stops. - - // Retail per-tick motion pipeline applied to every remote. - // Mirrors retail FUN_00515020 update_object → FUN_00513730 - // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: - // - // 1. apply_current_movement (FUN_00529210) — recomputes - // body.Velocity from InterpretedState via get_state_velocity. - // 2. Pull omega from the sequencer (baked MotionData.Omega - // for TurnRight / TurnLeft cycles, scaled by speedMod). - // 3. body.update_object(now) — Euler-integrates - // position += Velocity × dt + 0.5 × Accel × dt² AND - // orientation += omega × dt. - // - // On UpdatePosition receipt we hard-snap body.Position and - // body.Orientation — if integration matched server physics, - // each snap is small/invisible. - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - - // Step 1: re-apply current motion commands → body.Velocity. - // Forces OnWalkable + Contact so the gate in apply_current_movement - // always succeeds (remotes are server-authoritative; we don't - // simulate airborne physics for them). - // - // K-fix9 (2026-04-26): SKIP this when the remote is airborne. - // Otherwise the force-OnWalkable + apply_current_movement - // path stomps the +Z velocity we set in OnLiveVectorUpdated, - // and gravity never gets to integrate the arc. The airborne - // body keeps the launch velocity from the VectorUpdate; - // UpdatePhysicsInternal below applies gravity each tick; - // the next UpdatePosition snaps to the new ground location - // and re-grounds. - if (!rm.Airborne) + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable - | AcDream.Core.Physics.TransientStateFlags.Active; - if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) + // ── NEW PATH: queued position-chase via InterpolationManager ── + // (L.3.1 Task 5 — ACDREAM_INTERP_MANAGER=1 gates this path) + // + // Walking remotes have m_velocityVector == 0 in retail; all + // visible horizontal motion comes from + // InterpolationManager::adjust_offset (acclient @ 0x00555D30) + // walking the body toward the head of the waypoint queue at + // 2 × motion_max_speed × dt (clamped, 7.5 m/s fallback). + // + // Mirrors retail CPhysicsObj::UpdateObjectInternal + // (acclient @ 0x00513730) which calls adjust_offset every frame + // before UpdatePhysicsInternal integrates gravity. + // + // For airborne remotes, OnLiveVectorUpdated has set + // body.Velocity (launch arc); we still call + // UpdatePhysicsInternal below so gravity applies each frame and + // produces the parabolic arc. The IsActive gate prevents + // AdjustOffset from pulling against an in-flight arc when no + // waypoints are queued for a jumping remote. + if (rm.Interp.IsActive) { - double velocityAge = nowSec - rm.LastServerPosTime; - if (velocityAge > ServerControlledVelocityStaleSeconds) - { - rm.ServerVelocity = System.Numerics.Vector3.Zero; - rm.HasServerVelocity = false; - rm.Body.Velocity = System.Numerics.Vector3.Zero; - ApplyServerControlledVelocityCycle( - serverGuid, - ae, - rm, - System.Numerics.Vector3.Zero); - } - else - { - rm.Body.Velocity = rm.ServerVelocity; - } + float maxSpeed = rm.Motion.GetMaxSpeed(); + System.Numerics.Vector3 delta = rm.Interp.AdjustOffset((double)dt, rm.Body.Position, maxSpeed); + rm.Body.Position += delta; } - else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive - && rm.HasMoveToDestination) + + // Gravity integration: retail's UpdatePhysicsInternal still + // fires every frame regardless of the interpolation path. + // For grounded remotes body.Velocity == 0 so this is a no-op; + // for airborne remotes it applies gravity to the arc. + rm.Body.UpdatePhysicsInternal(dt); + + ae.Entity.Position = rm.Body.Position; + ae.Entity.Rotation = rm.Body.Orientation; + } + else + { + // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ── + // + // Stop detection is handled explicitly on packet receipt: + // - UpdateMotion with ForwardCommand flag CLEARED → Ready. + // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. + // Both map to retail's "flag-absent = Invalid = reset to + // default" semantics (FUN_0051F260 bulk-copy). No timer-based + // inference needed — the server sends the right signal every + // time a remote stops. + + // Retail per-tick motion pipeline applied to every remote. + // Mirrors retail FUN_00515020 update_object → FUN_00513730 + // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: + // + // 1. apply_current_movement (FUN_00529210) — recomputes + // body.Velocity from InterpretedState via get_state_velocity. + // 2. Pull omega from the sequencer (baked MotionData.Omega + // for TurnRight / TurnLeft cycles, scaled by speedMod). + // 3. body.update_object(now) — Euler-integrates + // position += Velocity × dt + 0.5 × Accel × dt² AND + // orientation += omega × dt. + // + // On UpdatePosition receipt we hard-snap body.Position and + // body.Orientation — if integration matched server physics, + // each snap is small/invisible. + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + + // Step 1: re-apply current motion commands → body.Velocity. + // Forces OnWalkable + Contact so the gate in apply_current_movement + // always succeeds (remotes are server-authoritative; we don't + // simulate airborne physics for them). + // + // K-fix9 (2026-04-26): SKIP this when the remote is airborne. + // Otherwise the force-OnWalkable + apply_current_movement + // path stomps the +Z velocity we set in OnLiveVectorUpdated, + // and gravity never gets to integrate the arc. The airborne + // body keeps the launch velocity from the VectorUpdate; + // UpdatePhysicsInternal below applies gravity each tick; + // the next UpdatePosition snaps to the new ground location + // and re-grounds. + if (!rm.Airborne) { - // Phase L.1c port of retail MoveToManager per-tick - // steering (HandleMoveToPosition @ 0x00529d80). - // Steer body orientation toward the latest - // server-supplied destination, then let - // apply_current_movement set Velocity from the - // RunForward cycle through the now-correct heading. - - // Stale-destination guard (2026-04-28): if no - // MoveTo packet has refreshed the destination - // recently, the entity has likely left our - // streaming view or the server cancelled the - // move without us seeing the cancel UM. Continuing - // to steer toward a stale point produces the - // "monster runs in place after popping back into - // view" symptom. Clear and stand down. - double moveToAge = nowSec - rm.LastMoveToPacketTime; - if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active; + if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) { - rm.HasMoveToDestination = false; - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - else - { - var driveResult = AcDream.Core.Physics.RemoteMoveToDriver - .Drive( - rm.Body.Position, - rm.Body.Orientation, - rm.MoveToDestinationWorld, - rm.MoveToMinDistance, - rm.MoveToDistanceToObject, - (float)dt, - rm.MoveToMoveTowards, - out var steeredOrientation); - rm.Body.Orientation = steeredOrientation; - - if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver - .DriveResult.Arrived) + double velocityAge = nowSec - rm.LastServerPosTime; + if (velocityAge > ServerControlledVelocityStaleSeconds) { - // Within arrival window — zero velocity until the - // next MoveTo packet refreshes the destination - // (or the server explicitly stops us with an - // interpreted-motion UM cmd=Ready). + rm.ServerVelocity = System.Numerics.Vector3.Zero; + rm.HasServerVelocity = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + ApplyServerControlledVelocityCycle( + serverGuid, + ae, + rm, + System.Numerics.Vector3.Zero); + } + else + { + rm.Body.Velocity = rm.ServerVelocity; + } + } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive + && rm.HasMoveToDestination) + { + // Phase L.1c port of retail MoveToManager per-tick + // steering (HandleMoveToPosition @ 0x00529d80). + // Steer body orientation toward the latest + // server-supplied destination, then let + // apply_current_movement set Velocity from the + // RunForward cycle through the now-correct heading. + + // Stale-destination guard (2026-04-28): if no + // MoveTo packet has refreshed the destination + // recently, the entity has likely left our + // streaming view or the server cancelled the + // move without us seeing the cancel UM. Continuing + // to steer toward a stale point produces the + // "monster runs in place after popping back into + // view" symptom. Clear and stand down. + double moveToAge = nowSec - rm.LastMoveToPacketTime; + if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) + { + rm.HasMoveToDestination = false; rm.Body.Velocity = System.Numerics.Vector3.Zero; } else { - // Steering active — apply_current_movement reads - // InterpretedState.ForwardCommand=RunForward (set - // when the MoveTo packet arrived) and emits - // velocity along +Y in body local space. Our - // updated orientation rotates that into the right - // world direction toward the target. - rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); - - // Clamp horizontal velocity so we don't overshoot - // the arrival threshold during the final tick of - // approach. Without this, a 4 m/s body advances - // ~6 cm/tick and visibly runs slightly through - // the target before the swing UM lands. - float arrivalThreshold = rm.MoveToMoveTowards - ? rm.MoveToDistanceToObject - : rm.MoveToMinDistance; - rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver - .ClampApproachVelocity( + var driveResult = AcDream.Core.Physics.RemoteMoveToDriver + .Drive( rm.Body.Position, - rm.Body.Velocity, + rm.Body.Orientation, rm.MoveToDestinationWorld, - arrivalThreshold, + rm.MoveToMinDistance, + rm.MoveToDistanceToObject, (float)dt, - rm.MoveToMoveTowards); + rm.MoveToMoveTowards, + out var steeredOrientation); + rm.Body.Orientation = steeredOrientation; + + if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver + .DriveResult.Arrived) + { + // Within arrival window — zero velocity until the + // next MoveTo packet refreshes the destination + // (or the server explicitly stops us with an + // interpreted-motion UM cmd=Ready). + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + // Steering active — apply_current_movement reads + // InterpretedState.ForwardCommand=RunForward (set + // when the MoveTo packet arrived) and emits + // velocity along +Y in body local space. Our + // updated orientation rotates that into the right + // world direction toward the target. + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + + // Clamp horizontal velocity so we don't overshoot + // the arrival threshold during the final tick of + // approach. Without this, a 4 m/s body advances + // ~6 cm/tick and visibly runs slightly through + // the target before the swing UM lands. + float arrivalThreshold = rm.MoveToMoveTowards + ? rm.MoveToDistanceToObject + : rm.MoveToMinDistance; + rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver + .ClampApproachVelocity( + rm.Body.Position, + rm.Body.Velocity, + rm.MoveToDestinationWorld, + arrivalThreshold, + (float)dt, + rm.MoveToMoveTowards); + } } } - } - else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) - { - // MoveTo flag set but we haven't seen a path payload - // yet (e.g. truncated packet, or a brand-new entity - // whose first cycle UM is still in flight). Hold - // velocity at zero — same conservative stance as the - // 882a07c stabilizer for incomplete state. - rm.Body.Velocity = System.Numerics.Vector3.Zero; + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) + { + // MoveTo flag set but we haven't seen a path payload + // yet (e.g. truncated packet, or a brand-new entity + // whose first cycle UM is still in flight). Hold + // velocity at zero — same conservative stance as the + // 882a07c stabilizer for incomplete state. + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } } else { - rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + // Airborne — keep Active flag (so UpdatePhysicsInternal + // doesn't early-return) but DON'T set Contact / OnWalkable. + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - } - else - { - // Airborne — keep Active flag (so UpdatePhysicsInternal - // doesn't early-return) but DON'T set Contact / OnWalkable. - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; - } - // Step 2: integrate rotation manually per tick. We can't - // rely on PhysicsBody.update_object here — its MinQuantum - // gate (1/30 s) causes it to SKIP integration when our - // 60fps render dt (~0.016s) is below the quantum, meaning - // rotation never advances. Measured snap per UP was ~129° - // = the full expected 1s × 2.24 rad/s, confirming zero - // between-tick rotation. - // - // Manual integration matches retail's FUN_005256b0 - // apply_physics (Orientation *= quat(ω × dt)). Use - // ObservedOmega derived from server UP rotation deltas so - // the rate exactly matches server physics — hard-snap on - // next UP becomes invisible by construction. - rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object - if (rm.ObservedOmega.LengthSquared() > 1e-8f) - { - float omegaMag = rm.ObservedOmega.Length(); - var axis = rm.ObservedOmega / omegaMag; - float angle = omegaMag * dt; - var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); - rm.Body.Orientation = System.Numerics.Quaternion.Normalize( - System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); - } - - // Step 3: integrate physics — retail FUN_005111D0 - // UpdatePhysicsInternal. Pure Euler: - // position += velocity × dt + 0.5 × accel × dt² - // - // Call UpdatePhysicsInternal DIRECTLY rather than via - // PhysicsBody.update_object (FUN_00515020). update_object gates - // on MinQuantum = 1/30s: at our 60fps render tick (~16ms), - // deltaTime < MinQuantum → early return AND LastUpdateTime is - // NOT advanced. Net effect: position never integrates between - // UpdatePositions and the only Body.Position changes come - // from the UP hard-snap, producing a visible teleport-stride - // on slopes (the "staircase" the user reported). - // - // PlayerMovementController.cs:358 calls UpdatePhysicsInternal - // directly for the same reason. Remote motion mirrors that. - // Omega is already integrated manually above, so we zero it - // here to prevent UpdatePhysicsInternal's own omega pass from - // double-integrating. - var preIntegratePos = rm.Body.Position; - rm.Body.calc_acceleration(); - rm.Body.UpdatePhysicsInternal(dt); - var postIntegratePos = rm.Body.Position; - - // Step 4: collision sweep — retail FUN_00514B90 → - // FUN_005148A0 → Transition::FindTransitionalPosition. - // Projects the sphere from preIntegratePos to postIntegratePos - // through the BSP + terrain, resolving: - // - terrain Z snap along the slope (fixes the "staircase" where - // horizontal Euler motion up a slope sinks into rising ground - // until the next UP pops it up) - // - indoor BSP walls (via the 6-path dispatcher in BSPQuery) - // - object collisions via ShadowObjectRegistry - // - step-up / step-down against walkable ledges - // ResolveWithTransition is the same call PlayerMovementController - // uses for the local player; remotes now get the full retail - // treatment between UpdatePositions instead of pure kinematics. - // - // Skipped when rm.CellId == 0 (no UP landed yet — can't build - // a SpherePath without a starting cell). One-frame grace until - // the first UP arrives; harmless because the entity is - // server-freshly-spawned at a valid Z anyway. - if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) - { - // Sphere dims match local-player defaults (human Setup - // bounds — ~0.48m radius, ~1.2m height). Good enough for - // grounded humanoid remotes; can be setup-derived later - // if creatures of wildly different sizes need different - // collision profiles. - var resolveResult = _physicsEngine.ResolveWithTransition( - preIntegratePos, postIntegratePos, rm.CellId, - sphereRadius: 0.48f, - sphereHeight: 1.2f, - stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f - stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f - // K-fix9 (2026-04-26): mirror the K-fix7 gate — - // airborne remotes must NOT pre-seed the - // ContactPlane, otherwise AdjustOffset's snap-to-plane - // branch zeroes the +Z offset every step (same bug - // we hit on the local jump). - isOnGround: !rm.Airborne, - body: rm.Body, // persist ContactPlane across frames for slope tracking - // 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); - - rm.Body.Position = resolveResult.Position; - if (resolveResult.CellId != 0) - rm.CellId = resolveResult.CellId; - - // K-fix15 (2026-04-26): post-resolve landing - // detection for airborne remotes. Mirrors - // PlayerMovementController's local-player landing - // path: when the resolver says we're on ground AND - // velocity is no longer pointing up, transition - // back to grounded — clear Airborne, restore - // Contact + OnWalkable, remove Gravity, zero any - // residual downward velocity, and trigger - // HitGround so the sequencer can swap from - // Falling → idle/locomotion. Without this, an - // airborne remote falls through the floor (gravity - // keeps building Velocity.Z negative until the - // sphere-sweep clamps each frame, but Airborne - // stays true forever). - if (rm.Airborne - && resolveResult.IsOnGround - && rm.Body.Velocity.Z <= 0f) + // Step 2: integrate rotation manually per tick. We can't + // rely on PhysicsBody.update_object here — its MinQuantum + // gate (1/30 s) causes it to SKIP integration when our + // 60fps render dt (~0.016s) is below the quantum, meaning + // rotation never advances. Measured snap per UP was ~129° + // = the full expected 1s × 2.24 rad/s, confirming zero + // between-tick rotation. + // + // Manual integration matches retail's FUN_005256b0 + // apply_physics (Orientation *= quat(ω × dt)). Use + // ObservedOmega derived from server UP rotation deltas so + // the rate exactly matches server physics — hard-snap on + // next UP becomes invisible by construction. + rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object + if (rm.ObservedOmega.LengthSquared() > 1e-8f) { - 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(); - - // K-fix17 (2026-04-26): reset the sequencer cycle - // from Falling back to whatever the interpreted - // motion state says they should be doing now. - // Without this, the remote stays in the Falling - // pose forever (legs folded) until the next - // server-sent UpdateMotion arrives. Use the - // sequencer's current style (preserved across - // jump) and pick the cycle from - // InterpretedState.ForwardCommand: Ready - // (idle), WalkForward, RunForward, WalkBackward. - // SideStep / Turn aren't strict locomotion - // priorities — the next UM the server sends will - // refine the cycle if the player is mid-strafe - // when they land; this just gets the legs out - // of Falling immediately. - if (ae.Sequencer is not null) - { - uint style = 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(style, landingCmd, landingSpeed); - } - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + float omegaMag = rm.ObservedOmega.Length(); + var axis = rm.ObservedOmega / omegaMag; + float angle = omegaMag * dt; + var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); + rm.Body.Orientation = System.Numerics.Quaternion.Normalize( + System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); } - } - ae.Entity.Position = rm.Body.Position; - ae.Entity.Rotation = rm.Body.Orientation; + // Step 3: integrate physics — retail FUN_005111D0 + // UpdatePhysicsInternal. Pure Euler: + // position += velocity × dt + 0.5 × accel × dt² + // + // Call UpdatePhysicsInternal DIRECTLY rather than via + // PhysicsBody.update_object (FUN_00515020). update_object gates + // on MinQuantum = 1/30s: at our 60fps render tick (~16ms), + // deltaTime < MinQuantum → early return AND LastUpdateTime is + // NOT advanced. Net effect: position never integrates between + // UpdatePositions and the only Body.Position changes come + // from the UP hard-snap, producing a visible teleport-stride + // on slopes (the "staircase" the user reported). + // + // PlayerMovementController.cs:358 calls UpdatePhysicsInternal + // directly for the same reason. Remote motion mirrors that. + // Omega is already integrated manually above, so we zero it + // here to prevent UpdatePhysicsInternal's own omega pass from + // double-integrating. + var preIntegratePos = rm.Body.Position; + rm.Body.calc_acceleration(); + rm.Body.UpdatePhysicsInternal(dt); + var postIntegratePos = rm.Body.Position; + + // Step 4: collision sweep — retail FUN_00514B90 → + // FUN_005148A0 → Transition::FindTransitionalPosition. + // Projects the sphere from preIntegratePos to postIntegratePos + // through the BSP + terrain, resolving: + // - terrain Z snap along the slope (fixes the "staircase" where + // horizontal Euler motion up a slope sinks into rising ground + // until the next UP pops it up) + // - indoor BSP walls (via the 6-path dispatcher in BSPQuery) + // - object collisions via ShadowObjectRegistry + // - step-up / step-down against walkable ledges + // ResolveWithTransition is the same call PlayerMovementController + // uses for the local player; remotes now get the full retail + // treatment between UpdatePositions instead of pure kinematics. + // + // Skipped when rm.CellId == 0 (no UP landed yet — can't build + // a SpherePath without a starting cell). One-frame grace until + // the first UP arrives; harmless because the entity is + // server-freshly-spawned at a valid Z anyway. + if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) + { + // Sphere dims match local-player defaults (human Setup + // bounds — ~0.48m radius, ~1.2m height). Good enough for + // grounded humanoid remotes; can be setup-derived later + // if creatures of wildly different sizes need different + // collision profiles. + var resolveResult = _physicsEngine.ResolveWithTransition( + preIntegratePos, postIntegratePos, rm.CellId, + sphereRadius: 0.48f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f + stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f + // K-fix9 (2026-04-26): mirror the K-fix7 gate — + // airborne remotes must NOT pre-seed the + // ContactPlane, otherwise AdjustOffset's snap-to-plane + // branch zeroes the +Z offset every step (same bug + // we hit on the local jump). + isOnGround: !rm.Airborne, + body: rm.Body, // persist ContactPlane across frames for slope tracking + // 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); + + rm.Body.Position = resolveResult.Position; + if (resolveResult.CellId != 0) + rm.CellId = resolveResult.CellId; + + // K-fix15 (2026-04-26): post-resolve landing + // detection for airborne remotes. Mirrors + // PlayerMovementController's local-player landing + // path: when the resolver says we're on ground AND + // velocity is no longer pointing up, transition + // back to grounded — clear Airborne, restore + // Contact + OnWalkable, remove Gravity, zero any + // residual downward velocity, and trigger + // HitGround so the sequencer can swap from + // Falling → idle/locomotion. Without this, an + // airborne remote falls through the floor (gravity + // keeps building Velocity.Z negative 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(); + + // K-fix17 (2026-04-26): reset the sequencer cycle + // from Falling back to whatever the interpreted + // motion state says they should be doing now. + // Without this, the remote stays in the Falling + // pose forever (legs folded) until the next + // server-sent UpdateMotion arrives. Use the + // sequencer's current style (preserved across + // jump) and pick the cycle from + // InterpretedState.ForwardCommand: Ready + // (idle), WalkForward, RunForward, WalkBackward. + // SideStep / Turn aren't strict locomotion + // priorities — the next UM the server sends will + // refine the cycle if the player is mid-strafe + // when they land; this just gets the legs out + // of Falling immediately. + if (ae.Sequencer is not null) + { + uint style = 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(style, landingCmd, landingSpeed); + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + } + + ae.Entity.Position = rm.Body.Position; + ae.Entity.Rotation = rm.Body.Orientation; + } } // ── Get per-part (origin, orientation) from either sequencer or legacy ──