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
|
&& serverGuid != _playerServerGuid
|
||||||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
|
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
|
||||||
{
|
{
|
||||||
// Stop detection is handled explicitly on packet receipt:
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
|
||||||
// - 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)
|
|
||||||
{
|
{
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
// ── NEW PATH: queued position-chase via InterpolationManager ──
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
// (L.3.1 Task 5 — ACDREAM_INTERP_MANAGER=1 gates this path)
|
||||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
//
|
||||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
|
// 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;
|
float maxSpeed = rm.Motion.GetMaxSpeed();
|
||||||
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
System.Numerics.Vector3 delta = rm.Interp.AdjustOffset((double)dt, rm.Body.Position, maxSpeed);
|
||||||
{
|
rm.Body.Position += delta;
|
||||||
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)
|
// 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
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
// steering (HandleMoveToPosition @ 0x00529d80).
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||||
// Steer body orientation toward the latest
|
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||||
// server-supplied destination, then let
|
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
|
||||||
// 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;
|
double velocityAge = nowSec - rm.LastServerPosTime;
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
||||||
}
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
// Within arrival window — zero velocity until the
|
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||||
// next MoveTo packet refreshes the destination
|
rm.HasServerVelocity = false;
|
||||||
// (or the server explicitly stops us with an
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
// interpreted-motion UM cmd=Ready).
|
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;
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Steering active — apply_current_movement reads
|
var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
|
||||||
// InterpretedState.ForwardCommand=RunForward (set
|
.Drive(
|
||||||
// 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.Position,
|
||||||
rm.Body.Velocity,
|
rm.Body.Orientation,
|
||||||
rm.MoveToDestinationWorld,
|
rm.MoveToDestinationWorld,
|
||||||
arrivalThreshold,
|
rm.MoveToMinDistance,
|
||||||
|
rm.MoveToDistanceToObject,
|
||||||
(float)dt,
|
(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)
|
||||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
|
{
|
||||||
{
|
// MoveTo flag set but we haven't seen a path payload
|
||||||
// MoveTo flag set but we haven't seen a path payload
|
// yet (e.g. truncated packet, or a brand-new entity
|
||||||
// yet (e.g. truncated packet, or a brand-new entity
|
// whose first cycle UM is still in flight). Hold
|
||||||
// whose first cycle UM is still in flight). Hold
|
// velocity at zero — same conservative stance as the
|
||||||
// velocity at zero — same conservative stance as the
|
// 882a07c stabilizer for incomplete state.
|
||||||
// 882a07c stabilizer for incomplete state.
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
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
|
// Step 2: integrate rotation manually per tick. We can't
|
||||||
// rely on PhysicsBody.update_object here — its MinQuantum
|
// rely on PhysicsBody.update_object here — its MinQuantum
|
||||||
// gate (1/30 s) causes it to SKIP integration when our
|
// gate (1/30 s) causes it to SKIP integration when our
|
||||||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||||||
// rotation never advances. Measured snap per UP was ~129°
|
// rotation never advances. Measured snap per UP was ~129°
|
||||||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||||||
// between-tick rotation.
|
// between-tick rotation.
|
||||||
//
|
//
|
||||||
// Manual integration matches retail's FUN_005256b0
|
// Manual integration matches retail's FUN_005256b0
|
||||||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||||||
// ObservedOmega derived from server UP rotation deltas so
|
// ObservedOmega derived from server UP rotation deltas so
|
||||||
// the rate exactly matches server physics — hard-snap on
|
// the rate exactly matches server physics — hard-snap on
|
||||||
// next UP becomes invisible by construction.
|
// next UP becomes invisible by construction.
|
||||||
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
|
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
|
||||||
if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
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)
|
|
||||||
{
|
{
|
||||||
rm.Airborne = false;
|
float omegaMag = rm.ObservedOmega.Length();
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
var axis = rm.ObservedOmega / omegaMag;
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
float angle = omegaMag * dt;
|
||||||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||||
rm.Body.Velocity = new System.Numerics.Vector3(
|
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||||
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
|
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||||
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;
|
// Step 3: integrate physics — retail FUN_005111D0
|
||||||
ae.Entity.Rotation = rm.Body.Orientation;
|
// 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 ──
|
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue