Merge L.3 motion port — queue-only chase for grounded player remotes

Brings the elated-aryabhata-208d5e branch into main. 7 commits implementing
the L.3 retail-faithful remote-entity motion port:

  de129bc  M1  Fresh InterpolationManager port + retail spec
  40d88b9  M2  Queue routing for player-remote UPs + entity-position sync
  2365c8c  M3  Animation root motion fallback for idle queue
  d57ace0  M6  Cleanup — dead fields + stale env-var references
  c26bbbb  M4  Jump-CellId fix + #42 filed
  b37b713      #42 root cause confirmed via A/B test
  5cc2812      Handoff prompt for #42 PhysicsEngine investigation

User-verified visual checks: smooth body chase on running/walking/strafing,
no per-UP rubber-band, no slope staircase, NPCs pathing correctly, jumps
land cleanly. Two follow-up issues filed:

  #41  sub-decimeter steady-state blips (velocity-synthesis residual; LOW)
  #42  airborne XY drift on jumps (PhysicsEngine.ResolveWithTransition
       depenetration; root cause confirmed; deep-dive prompt at
       docs/research/2026-05-05-issue-42-handoff.md)

Replaces the env-var-gated experimental path (ACDREAM_INTERP_MANAGER=1)
which was marked DO-NOT-ENABLE — the env-var no longer toggles anything.
NPCs and airborne player remotes still use the legacy path; only grounded
player remotes route through the new retail-faithful queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-05 15:51:29 +02:00
commit 086e65dfe6
22 changed files with 11199 additions and 459 deletions

View file

@ -295,11 +295,6 @@ public sealed class GameWindow : IDisposable
/// </summary>
public double LastMoveToPacketTime;
/// <summary>
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
/// </summary>
public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity;
/// <summary>
/// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed
/// (π/2 × turnSpeed, signed). Applied per tick to body orientation
/// via manual integration (bypassing <c>PhysicsBody.update_object</c>'s
@ -334,34 +329,21 @@ public sealed class GameWindow : IDisposable
/// <summary>
/// Per-remote position-waypoint queue + catch-up math (retail's
/// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset).
/// Replaces the hard-snap-then-Euler-extrapolate path when
/// <c>ACDREAM_INTERP_MANAGER=1</c> — see Phase L.3.1 spec at
/// <c>docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md</c>.
/// Field exists from Task 3 onwards; consumed in Tasks 4 + 5.
/// Drives per-tick body translation for grounded player remotes
/// via <see cref="Position"/>.
/// </summary>
public AcDream.Core.Physics.InterpolationManager Interp { get; } =
new AcDream.Core.Physics.InterpolationManager();
/// <summary>
/// Per-frame combiner for animation root motion + InterpolationManager
/// correction (Phase L.3.2). Consumed in TickAnimations to compute the
/// per-frame body.Position delta.
/// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30:
/// queue catch-up REPLACES anim when active; anim stands when queue
/// is idle.
/// </summary>
public AcDream.Core.Physics.PositionManager Position { get; } =
new AcDream.Core.Physics.PositionManager();
/// <summary>
/// Most recent server-broadcast Z coordinate from any UpdatePosition
/// (including mid-arc airborne UPs). Used by the
/// <c>ACDREAM_INTERP_MANAGER=1</c> per-tick path as a landing-fallback
/// floor: if gravity drags the body's Z below this value while
/// <see cref="Airborne"/> is still set, force-land locally because
/// the server has effectively told us where the ground is even if
/// it never sent an IsGrounded=true UP. Initialized to NaN so the
/// fallback is a no-op until the first UP arrives.
/// </summary>
public float LastServerZ = float.NaN;
/// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
/// previous UpdatePosition's world position + timestamp. The per-tick
@ -3462,29 +3444,27 @@ public sealed class GameWindow : IDisposable
rmState.Body.Orientation = rot;
}
// L.3.1 Task 4: env-var gated retail-faithful MoveOrTeleport routing.
// Mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330).
// Enabled only when ACDREAM_INTERP_MANAGER=1 to keep default behavior
// identical to before this commit. Legacy hard-snap path remains below.
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
// InterpolateTo. Gated on IsPlayerGuid so NPCs continue through
// the legacy synth-velocity branch below; their motion comes
// from ServerVelocity / ServerMoveTo which the legacy path
// already handles correctly.
//
if (IsPlayerGuid(update.Guid))
{
// Orientation always snaps on receipt — InterpolationManager walks
// position only; heading would otherwise lag the queue.
rmState.Body.Orientation = rot;
// Track the most recent GROUNDED server-broadcast Z. Read by
// the per-tick landing-fallback in TickAnimations: if gravity
// drags the body more than 0.5 m below this floor while still
// airborne, we force-land locally even when the server never
// sent an IsGrounded=true UP for the actual landing frame.
//
// Only updated for grounded UPs — mid-arc airborne UPs would
// raise this value to the player's peak Z, then the body's
// descent would cross (peak - 0.5) and trigger a force-land
// mid-air, producing the user-reported "small landing in the
// air before landing on the ground" when jumping while moving.
if (update.IsGrounded)
rmState.LastServerZ = worldPos.Z;
// Adopt server's cell ID on every UP (airborne or grounded).
// Required by the legacy airborne path's per-tick
// ResolveWithTransition gate (rm.CellId != 0); without this
// an airborne player remote falls through the floor because
// the sphere sweep is skipped. Note: enabling the sweep also
// exposes a pre-existing depenetration bug — see #42.
rmState.CellId = p.LandblockId;
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
// server-pos snapshot forward AND print the per-UP comparison
@ -3525,7 +3505,15 @@ public sealed class GameWindow : IDisposable
// integrating gravity via per-frame UpdatePhysicsInternal. Server is
// authoritative for the arc; we don't predict it locally.
if (!update.IsGrounded)
{
// Undo the unconditional entity hard-snap at the top of the
// function (entity.Position = worldPos): the body is mid-arc
// and TickAnimations will write entity = body next frame
// anyway. Setting entity = body now prevents a 1-frame
// teleport-to-server-then-yank-back rubber-band.
entity.Position = rmState.Body.Position;
return;
}
// ── LANDING TRANSITION ────────────────────────────────────────
// First IsGrounded=true UP after rmState.Airborne signals landed.
@ -3573,12 +3561,26 @@ public sealed class GameWindow : IDisposable
else
{
// Within view bubble: enqueue waypoint for adjust_offset to walk to.
// PositionManager (called per-frame in TickAnimations) handles the
// actual body advancement — mix of animation root motion + queue
// correction.
// The per-frame TickAnimations player-remote path drives the
// actual body advancement via InterpolationManager.AdjustOffset.
// Pass body's current position so the InterpolationManager can
// detect a far-distance enqueue (>100 m from body) and pre-arm
// an immediate blip — avoids body drifting visibly toward a
// far waypoint instead of teleporting to it.
float headingFromQuat = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false);
rmState.Interp.Enqueue(
worldPos,
headingFromQuat,
isMovingTo: false,
currentBodyPosition: rmState.Body.Position);
}
// Sync the visible entity to the body — overrides the unconditional
// entity.Position = worldPos snap at the top of this function.
// For the far-snap branch this is a no-op (body == worldPos); for
// the near-enqueue branch this prevents a 1-frame teleport-then-
// yank-back rubber-band as TickAnimations chases worldPos via the
// queue.
entity.Position = rmState.Body.Position;
return;
}
@ -3639,7 +3641,6 @@ public sealed class GameWindow : IDisposable
// a halved "observed" rate → visible slow-start. Formula-only
// is stable and simple; hard-snap fixes any drift.
rmState.Body.Orientation = rot;
rmState.TargetOrientation = rot;
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = nowSec;
// Align the body's physics clock with our clock so update_object
@ -6065,76 +6066,38 @@ public sealed class GameWindow : IDisposable
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
{
// ⚠️ REGRESSED 2026-05-03 — DO NOT ENABLE — see docs/ISSUES.md #40 ⚠️
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
//
// Introduced by e94e791 (L.3.1+L.3.2 Task 3) intending to
// mirror retail CPhysicsObj::MoveOrTeleport (network-packet
// entry point — minimal work). Wrong retail function for the
// per-frame tick — the actual per-frame chain is retail's
// update_object (FUN_00515020), which the LEGACY path below
// correctly mirrors (apply_current_movement →
// UpdatePhysicsInternal → ResolveWithTransition collision
// sweep). This env-var path strips the collision sweep AND
// clears body.Velocity, leaving only PositionManager queue
// catch-up — which stair-steps with the 1 Hz UP cadence on
// slopes and produces visible position blips on flat ground.
// Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
// 04-interp-manager.md +
// 05-position-manager-and-partarray.md):
//
// Commit B (039149a, 2026-05-03) ported ResolveWithTransition
// here but symptom persists because body.Velocity=0 means
// pre/postIntegrate sweep input is just the queue catch-up,
// which itself snaps in steps. Fix requires re-integrating
// PositionManager as ADDITIVE adjust_offset on top of the
// legacy chain — separate L.3 follow-up phase.
// - For a grounded REMOTE player, m_velocityVector stays at 0.
// - apply_current_movement is NEVER called per tick on remotes
// (it's the local-player-only velocity feed).
// - UpdatePhysicsInternal's translation step is gated on
// velocity² > 0, so it's a no-op when body.Velocity = 0.
// - ResolveWithTransition is NOT called — the server already
// collision-resolved the broadcast position.
// - Per-tick body translation per retail UpdatePositionInternal:
// 1. CPartArray::Update writes anim root motion (body-local
// seqVel × dt) into the local frame.
// 2. PositionManager::adjust_offset OVERWRITES the local
// frame's origin with the queue catch-up vector when
// the queue is active and the head is not yet reached
// — REPLACE, not additive.
// 3. Frame::combine composes the local frame with the
// body's world pose.
// Net: catch-up replaces anim during the chase phase, anim
// stands when the queue is empty / head reached. PositionManager.
// ComputeOffset implements this exact REPLACE dichotomy.
//
// Until that lands, stay on the legacy path (env-var unset).
// ── NEW PATH: retail-faithful per-frame remote tick ──
// (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path)
//
// Per retail's CPhysicsObj::UpdateObjectInternal (0x005156b0)
// → UpdatePositionInternal (0x00512c30) → CSequence::update
// chain (decomp investigation 2026-05-03):
//
// For a REMOTE entity (not local player), per physics tick
// the world-position advance is the sum of:
// A) animation root motion accumulated by
// update_internal (Frame::combine of crossed
// per-keyframe pos_frames deltas) OR replaced by
// InterpolationManager::adjust_offset's catch-up
// when the body is far from the queue head.
// B) body.Velocity × dt + 0.5 × accel × dt²
// (UpdatePhysicsInternal). For remotes, retail does
// NOT call apply_current_movement per tick — body.
// Velocity stays at whatever the last
// InterpolationManager type-3 ("set velocity") node
// set it to (typically zero unless the server is
// explicitly pushing velocity via VectorUpdate).
//
// So for normal grounded run/walk/strafe with no server-
// pushed velocity, ALL per-tick translation comes from (A).
//
// Acdream port mapping:
// - We don't extract per-keyframe pos_frames from the .anm
// assets. Our AnimationSequencer.CurrentVelocity is the
// synthesized equivalent (RunAnimSpeed × ForwardSpeed)
// which averages to the same effective body translation.
// - Pass it as seqVel to ComputeOffset so the
// animation-root-motion path drives body translation.
// - DO NOT call apply_current_movement per tick — that
// would set body.Velocity to RunAnimSpeed × ForwardSpeed,
// and UpdatePhysicsInternal would then add ANOTHER
// 11.7 m/s × dt on top of the seqVel motion already
// applied by ComputeOffset, producing 2× server pace
// (the user-reported "way too fast" + 1-Hz blip from
// the catch-up walking back the overshoot).
// - body.Velocity stays at 0 for grounded remotes; non-
// zero only when OnLiveVectorUpdated set it (jump
// start) — UpdatePhysicsInternal then integrates
// gravity for the airborne arc.
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
// Airborne player remotes (rm.Airborne) and NPCs fall through to
// the legacy path below — unchanged from main per the M2 plan.
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
@ -6159,18 +6122,20 @@ public sealed class GameWindow : IDisposable
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 2: per-frame body translation. ComputeOffset returns
// either the queue catch-up (when active) or the animation
// root motion (seqVel × dt rotated to world). REPLACE
// semantics — retail's PositionManager::adjust_offset
// overwrites the offset frame with the catch-up direction,
// not adding to it.
//
// 2026-05-03 (Commit B fix for staircase regression): capture
// the pre-translation position so the collision sweep below
// (Step 4b) can resolve the full per-tick movement through
// BSP + terrain.
var preIntegratePos = rm.Body.Position;
// Step 2 (M3): queue + anim translation via PositionManager.
// ComputeOffset returns:
// - Vector3.Zero when queue is empty AND seqVel is zero
// (idle remote between UPs after head reached) — body
// stays still.
// - Direction × min(catchUpSpeed × dt, dist) when the
// queue is active and head is not reached — body chases
// the head waypoint at up to 2× motion-table max speed
// (REPLACES anim for this frame).
// - Anim root motion (seqVel × dt rotated into world) when
// the queue is empty OR head is within DesiredDistance —
// body advances with the locomotion cycle's baked
// velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail body) when fail_count > 3.
float maxSpeed = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
@ -6235,140 +6200,18 @@ public sealed class GameWindow : IDisposable
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
rm.Body.UpdatePhysicsInternal(dt);
// Step 4b (Commit B fix 2026-05-03): collision sweep — port of
// retail update_object's FUN_005148A0 Transition::FindTransitionalPosition.
// This was MISSING in the env-var path introduced by e94e791
// (L.3.1+L.3.2 Task 3). The legacy (env-var off) path at the
// bottom of this function has it (line ~6483 "Step 4: collision
// sweep"); we just need the same call here.
// Step 4b INTENTIONALLY OMITTED in M2:
// ResolveWithTransition is NOT called — the server has
// already collision-resolved the broadcast position, and
// running our sweep on tiny per-frame queue catch-up deltas
// amplifies micro-bounces into visible position blips
// (issue #40 staircase + flat-ground blips). Per retail
// spec the per-tick body advance for a remote is purely
// the queue catch-up; collision is the sender's problem.
//
// Without this:
// - Body Z drifts on slopes (visible "staircase" — horizontal
// Euler motion up a slope sinks into rising ground until
// the next UP pops it up).
// - Body slides through walls / objects between UPs.
// - Step-up / step-down doesn't engage on ledges.
// - Edge-slide doesn't engage on cliff edges.
//
// The env-var path was originally designed to mirror retail
// CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330) — a network
// packet handler entry point that does minimal work. But
// TickAnimations is the per-frame physics tick (mirrors retail
// FUN_00515020 update_object), which DOES include the collision
// sweep. Adding the sweep here makes the env-var path retail-
// faithful for the per-frame tick (matching the legacy path,
// which had it).
var postIntegratePos = rm.Body.Position;
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
{
// Sphere dims match local-player + legacy-path defaults
// (~0.48m radius, ~1.2m height humanoid). Step-up/down 0.4m
// matches L.2.3a retail human-scale. EdgeSlide is the retail
// default mover-flags state.
var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: 0.48f,
sphereHeight: 1.2f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
// Airborne remotes must NOT pre-seed the ContactPlane —
// mirrors K-fix9 in the legacy path; otherwise
// AdjustOffset's snap-to-plane branch zeroes the +Z
// offset every step on a jump arc.
isOnGround: !rm.Airborne,
body: rm.Body,
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId;
// Post-resolve landing detection — mirrors K-fix15 in the
// legacy path. When the resolver says we're on ground AND
// velocity is no longer pointing up, transition back to
// grounded. Without this, gravity keeps building negative Z
// velocity until the sphere-sweep clamps each frame, but
// Airborne stays true forever.
if (rm.Airborne
&& resolveResult.IsOnGround
&& rm.Body.Velocity.Z <= 0f)
{
rm.Airborne = false;
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
rm.Motion.HitGround();
// Reset sequencer cycle from Falling back to whatever
// InterpretedState says. Mirrors K-fix17 in the legacy
// path.
if (ae.Sequencer is not null)
{
uint landStyle = ae.Sequencer.CurrentStyle != 0
? ae.Sequencer.CurrentStyle
: 0x8000003Du;
uint landingCmd = rm.Motion.InterpretedState.ForwardCommand;
if (landingCmd == 0)
landingCmd = AcDream.Core.Physics.MotionCommand.Ready;
float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed;
if (landingSpeed <= 0f) landingSpeed = 1f;
ae.Sequencer.SetCycle(landStyle, landingCmd, landingSpeed);
}
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
}
// Step 5: landing fallback. The retail-faithful path leaves
// the landing transition to OnLivePositionUpdated when ACE
// sends IsGrounded=true. In practice ACE doesn't always
// broadcast that flag promptly — the body keeps falling
// under gravity and visibly disappears into the ground until
// the next non-stop UP arrives (e.g. when the player turns).
// The remote's most recent server-reported Z is an
// authoritative ground floor: if our predicted body has
// sunk below it by more than half a meter, snap up to it
// and clear airborne, mirroring the OnLivePositionUpdated
// landing-transition branch. Threshold matches retail's
// MIN_DISTANCE_TO_REACH_POSITION-style tolerance.
if (rm.Airborne
&& !float.IsNaN(rm.LastServerZ)
&& rm.Body.Position.Z < rm.LastServerZ - 0.5f)
{
rm.Airborne = false;
rm.Body.Velocity = System.Numerics.Vector3.Zero;
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Interp.Clear();
rm.Body.Position = new System.Numerics.Vector3(
rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ);
// Swap the sequencer out of Falling — without this the
// legs stay folded in the airborne pose forever even
// though the body is now planted on the ground. Mirrors
// the legacy K-fix17 path at the bottom of TickAnimations
// (line ~6284): pick the cycle from the last-known
// InterpretedState.ForwardCommand, falling back to Ready
// when nothing is held. The next UpdateMotion the server
// sends will refine if the player was strafing/turning
// mid-jump; this just gets them out of Falling now.
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);
}
}
// Step 5 (landing fallback) is unreachable in this branch —
// we're gated on !rm.Airborne. Airborne player remotes fall
// through to the legacy path below where K-fix15 still fires.
// Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
// Track the maximum sequencer velocity magnitude seen since