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 <noreply@anthropic.com>
This commit is contained in:
parent
062e19f463
commit
ae79e34a6d
1 changed files with 333 additions and 291 deletions
|
|
@ -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 ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue